init
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
.table-tag:hover {
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.custom-row .hover-button {
|
||||
visibility: hidden;
|
||||
transition:
|
||||
visibility 0s,
|
||||
opacity ease-in-out;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.custom-row:hover .hover-button {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.table-tag {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.project-progress {
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
132
worklenz-frontend/src/components/project-list/TableColumns.tsx
Normal file
132
worklenz-frontend/src/components/project-list/TableColumns.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
import { IProjectViewModel } from '@/types/project/projectViewModel.types';
|
||||
import { InlineMember } from '@/types/teamMembers/inlineMember.types';
|
||||
import { ColumnsType } from 'antd/es/table';
|
||||
import { ColumnFilterItem } from 'antd/es/table/interface';
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { NavigateFunction } from 'react-router-dom';
|
||||
import Avatars from '../avatars/avatars';
|
||||
import { ActionButtons } from './project-list-table/project-list-actions/project-list-actions';
|
||||
import { CategoryCell } from './project-list-table/project-list-category/project-list-category';
|
||||
import { ProgressListProgress } from './project-list-table/project-list-progress/progress-list-progress';
|
||||
import { ProjectListUpdatedAt } from './project-list-table/project-list-updated-at/project-list-updated';
|
||||
import { ProjectNameCell } from './project-list-table/project-name/project-name-cell';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { ProjectRateCell } from './project-list-table/project-list-favorite/project-rate-cell';
|
||||
|
||||
const createFilters = (items: { id: string; name: string }[]) =>
|
||||
items.map(item => ({ text: item.name, value: item.id })) as ColumnFilterItem[];
|
||||
|
||||
interface ITableColumnsProps {
|
||||
navigate: NavigateFunction;
|
||||
filteredInfo: any;
|
||||
}
|
||||
|
||||
const TableColumns = ({
|
||||
navigate,
|
||||
filteredInfo,
|
||||
}: ITableColumnsProps): ColumnsType<IProjectViewModel> => {
|
||||
const { t } = useTranslation('all-project-list');
|
||||
const dispatch = useAppDispatch();
|
||||
const isOwnerOrAdmin = useAuthService().isOwnerOrAdmin();
|
||||
|
||||
const { projectStatuses } = useAppSelector(state => state.projectStatusesReducer);
|
||||
const { projectCategories } = useAppSelector(state => state.projectCategoriesReducer);
|
||||
const { filteredCategories, filteredStatuses } = useAppSelector(
|
||||
state => state.projectsReducer
|
||||
);
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: '',
|
||||
dataIndex: 'favorite',
|
||||
key: 'favorite',
|
||||
render: (text: string, record: IProjectViewModel) => (
|
||||
<ProjectRateCell key={record.id} t={t} record={record} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('name'),
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
sorter: true,
|
||||
showSorterTooltip: false,
|
||||
defaultSortOrder: 'ascend',
|
||||
render: (text: string, record: IProjectViewModel) => (
|
||||
<ProjectNameCell navigate={navigate} key={record.id} t={t} record={record} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('client'),
|
||||
dataIndex: 'client_name',
|
||||
key: 'client_name',
|
||||
sorter: true,
|
||||
showSorterTooltip: false,
|
||||
},
|
||||
{
|
||||
title: t('category'),
|
||||
dataIndex: 'category',
|
||||
key: 'category_id',
|
||||
filters: createFilters(
|
||||
projectCategories.map(category => ({ id: category.id || '', name: category.name || '' }))
|
||||
),
|
||||
filteredValue: filteredInfo.category_id || filteredCategories || [],
|
||||
filterMultiple: true,
|
||||
render: (text: string, record: IProjectViewModel) => (
|
||||
<CategoryCell key={record.id} t={t} record={record} />
|
||||
),
|
||||
sorter: true,
|
||||
},
|
||||
{
|
||||
title: t('status'),
|
||||
dataIndex: 'status',
|
||||
key: 'status_id',
|
||||
filters: createFilters(
|
||||
projectStatuses.map(status => ({ id: status.id || '', name: status.name || '' }))
|
||||
),
|
||||
filteredValue: filteredInfo.status_id || [],
|
||||
filterMultiple: true,
|
||||
sorter: true,
|
||||
},
|
||||
{
|
||||
title: t('tasksProgress'),
|
||||
dataIndex: 'tasksProgress',
|
||||
key: 'tasksProgress',
|
||||
render: (_: string, record: IProjectViewModel) => <ProgressListProgress record={record} />,
|
||||
},
|
||||
{
|
||||
title: t('updated_at'),
|
||||
dataIndex: 'updated_at',
|
||||
key: 'updated_at',
|
||||
sorter: true,
|
||||
showSorterTooltip: false,
|
||||
render: (_: string, record: IProjectViewModel) => <ProjectListUpdatedAt record={record} />,
|
||||
},
|
||||
{
|
||||
title: t('members'),
|
||||
dataIndex: 'names',
|
||||
key: 'members',
|
||||
render: (members: InlineMember[]) => <Avatars members={members} />,
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
key: 'button',
|
||||
dataIndex: '',
|
||||
render: (record: IProjectViewModel) => (
|
||||
<ActionButtons
|
||||
t={t}
|
||||
record={record}
|
||||
dispatch={dispatch}
|
||||
isOwnerOrAdmin={isOwnerOrAdmin}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
[t, projectCategories, projectStatuses, filteredInfo, filteredCategories, filteredStatuses]
|
||||
);
|
||||
return columns as ColumnsType<IProjectViewModel>;
|
||||
};
|
||||
|
||||
export default TableColumns;
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useGetProjectsQuery } from '@/api/projects/projects.v1.api.service';
|
||||
import { AppDispatch } from '@/app/store';
|
||||
import { fetchProjectData, setProjectId, toggleProjectDrawer } from '@/features/project/project-drawer.slice';
|
||||
import {
|
||||
toggleArchiveProjectForAll,
|
||||
toggleArchiveProject,
|
||||
} from '@/features/projects/projectsSlice';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import useIsProjectManager from '@/hooks/useIsProjectManager';
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
import { IProjectViewModel } from '@/types/project/projectViewModel.types';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { SettingOutlined, InboxOutlined } from '@ant-design/icons';
|
||||
import { Tooltip, Button, Popconfirm, Space } from 'antd';
|
||||
import { evt_projects_archive, evt_projects_archive_all, evt_projects_settings_click } from '@/shared/worklenz-analytics-events';
|
||||
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
|
||||
|
||||
interface ActionButtonsProps {
|
||||
t: (key: string) => string;
|
||||
record: IProjectViewModel;
|
||||
dispatch: AppDispatch;
|
||||
isOwnerOrAdmin: boolean;
|
||||
}
|
||||
|
||||
export const ActionButtons: React.FC<ActionButtonsProps> = ({
|
||||
t,
|
||||
record,
|
||||
dispatch,
|
||||
isOwnerOrAdmin,
|
||||
}) => {
|
||||
// Add permission hooks
|
||||
const isProjectManager = useIsProjectManager();
|
||||
const isEditable = isOwnerOrAdmin;
|
||||
const { trackMixpanelEvent } = useMixpanelTracking();
|
||||
|
||||
const { requestParams } = useAppSelector(state => state.projectsReducer);
|
||||
const { refetch: refetchProjects } = useGetProjectsQuery(requestParams);
|
||||
|
||||
const handleSettingsClick = () => {
|
||||
if (record.id) {
|
||||
trackMixpanelEvent(evt_projects_settings_click);
|
||||
dispatch(setProjectId(record.id));
|
||||
dispatch(fetchProjectData(record.id));
|
||||
dispatch(toggleProjectDrawer());
|
||||
}
|
||||
};
|
||||
|
||||
const handleArchiveClick = async () => {
|
||||
if (!record.id) return;
|
||||
try {
|
||||
if (isOwnerOrAdmin) {
|
||||
trackMixpanelEvent(evt_projects_archive_all);
|
||||
await dispatch(toggleArchiveProjectForAll(record.id));
|
||||
} else {
|
||||
trackMixpanelEvent(evt_projects_archive);
|
||||
await dispatch(toggleArchiveProject(record.id));
|
||||
}
|
||||
refetchProjects();
|
||||
} catch (error) {
|
||||
logger.error('Failed to archive project:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Space onClick={e => e.stopPropagation()}>
|
||||
<Tooltip title={t('setting')}>
|
||||
<Button
|
||||
className="action-button"
|
||||
size="small"
|
||||
onClick={handleSettingsClick}
|
||||
icon={<SettingOutlined />}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title={isEditable ? (record.archived ? t('unarchive') : t('archive')) : t('noPermission')}>
|
||||
<Popconfirm
|
||||
title={record.archived ? t('unarchive') : t('archive')}
|
||||
description={record.archived ? t('unarchiveConfirm') : t('archiveConfirm')}
|
||||
onConfirm={handleArchiveClick}
|
||||
okText={t('yes')}
|
||||
cancelText={t('no')}
|
||||
disabled={!isEditable}
|
||||
>
|
||||
<Button
|
||||
className="action-button"
|
||||
size="small"
|
||||
icon={<InboxOutlined />}
|
||||
disabled={!isEditable}
|
||||
/>
|
||||
</Popconfirm>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
import { IProjectViewModel } from '@/types/project/projectViewModel.types';
|
||||
import { Tooltip, Tag } from 'antd';
|
||||
import { TFunction } from 'i18next';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { setFilteredCategories, setRequestParams } from '@/features/projects/projectsSlice';
|
||||
import '../../TableColumns.css';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
|
||||
export const CategoryCell: React.FC<{
|
||||
record: IProjectViewModel;
|
||||
t: TFunction;
|
||||
}> = ({ record, t }) => {
|
||||
if (!record.category_name) return '-';
|
||||
|
||||
const { requestParams } = useAppSelector(
|
||||
state => state.projectsReducer
|
||||
);
|
||||
const dispatch = useAppDispatch();
|
||||
const newParams: Partial<typeof requestParams> = {};
|
||||
const filterByCategory = (categoryId: string | undefined) => {
|
||||
if (!categoryId) return;
|
||||
newParams.categories = categoryId;
|
||||
dispatch(setFilteredCategories([categoryId]));
|
||||
dispatch(setRequestParams(newParams));
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip title={`${t('clickToFilter')} "${record.category_name}"`}>
|
||||
<Tag
|
||||
color={record.category_color}
|
||||
className="rounded-full table-tag"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
filterByCategory(record.category_id);
|
||||
}}
|
||||
>
|
||||
{record.category_name}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
useGetProjectsQuery,
|
||||
useToggleFavoriteProjectMutation,
|
||||
} from '@/api/projects/projects.v1.api.service';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { colors } from '@/styles/colors';
|
||||
import { IProjectViewModel } from '@/types/project/projectViewModel.types';
|
||||
import { StarFilled } from '@ant-design/icons';
|
||||
import { Button, ConfigProvider, Tooltip } from 'antd';
|
||||
import { TFunction } from 'i18next';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
export const ProjectRateCell: React.FC<{
|
||||
record: IProjectViewModel;
|
||||
t: TFunction;
|
||||
}> = ({ record, t }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const [toggleFavoriteProject] = useToggleFavoriteProjectMutation();
|
||||
const { requestParams } = useAppSelector(state => state.projectsReducer);
|
||||
const { refetch: refetchProjects } = useGetProjectsQuery(requestParams);
|
||||
|
||||
const [isFavorite, setIsFavorite] = useState(record.favorite);
|
||||
|
||||
const handleFavorite = useCallback(async () => {
|
||||
if (record.id) {
|
||||
setIsFavorite(prev => !prev);
|
||||
await toggleFavoriteProject(record.id);
|
||||
// refetchProjects();
|
||||
}
|
||||
}, [dispatch, record.id]);
|
||||
|
||||
const checkIconColor = useMemo(
|
||||
() => (isFavorite ? colors.yellow : colors.lightGray),
|
||||
[isFavorite]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setIsFavorite(record.favorite);}, [record.favorite]);
|
||||
|
||||
return (
|
||||
<ConfigProvider wave={{ disabled: true }}>
|
||||
<Tooltip title={record.favorite ? 'Remove from favorites' : 'Add to favourites'}>
|
||||
<Button
|
||||
type="text"
|
||||
className="borderless-icon-btn"
|
||||
style={{ backgroundColor: colors.transparent }}
|
||||
shape="circle"
|
||||
icon={<StarFilled style={{ color: checkIconColor, fontSize: '20px' }} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleFavorite();
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</ConfigProvider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
import { IProjectViewModel } from '@/types/project/projectViewModel.types';
|
||||
import { getTaskProgressTitle } from '@/utils/project-list-utils';
|
||||
import { Tooltip, Progress } from 'antd';
|
||||
|
||||
export const ProgressListProgress: React.FC<{ record: IProjectViewModel }> = ({ record }) => {
|
||||
return (
|
||||
<Tooltip title={getTaskProgressTitle(record)}>
|
||||
<Progress percent={record.progress} className="project-progress" />
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import { IProjectViewModel } from '@/types/project/projectViewModel.types';
|
||||
import { calculateTimeDifference } from '@/utils/calculate-time-difference';
|
||||
import { formatDateTimeWithLocale } from '@/utils/format-date-time-with-locale';
|
||||
import { Tooltip } from 'antd';
|
||||
|
||||
export const ProjectListUpdatedAt: React.FC<{ record: IProjectViewModel }> = ({ record }) => {
|
||||
return (
|
||||
<Tooltip title={record.updated_at ? formatDateTimeWithLocale(record.updated_at) : ''}>
|
||||
{record.updated_at ? calculateTimeDifference(record.updated_at) : ''}
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
import {
|
||||
useGetProjectsQuery,
|
||||
useToggleFavoriteProjectMutation,
|
||||
} from '@/api/projects/projects.v1.api.service';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { IProjectViewModel } from '@/types/project/projectViewModel.types';
|
||||
import { formatDateRange } from '@/utils/project-list-utils';
|
||||
import { CalendarOutlined } from '@ant-design/icons';
|
||||
import { Badge, Tooltip } from 'antd';
|
||||
import { TFunction } from 'i18next';
|
||||
import { NavigateFunction } from 'react-router-dom';
|
||||
|
||||
export const ProjectNameCell: React.FC<{
|
||||
record: IProjectViewModel;
|
||||
t: TFunction;
|
||||
navigate: NavigateFunction;
|
||||
}> = ({ record, t, navigate }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const [toggleFavoriteProject] = useToggleFavoriteProjectMutation();
|
||||
const { requestParams } = useAppSelector(state => state.projectsReducer);
|
||||
const { refetch: refetchProjects } = useGetProjectsQuery(requestParams);
|
||||
|
||||
const selectProject = (record: IProjectViewModel) => {
|
||||
if (!record.id) return;
|
||||
|
||||
let viewTab = 'tasks-list';
|
||||
switch (record.team_member_default_view) {
|
||||
case 'TASK_LIST':
|
||||
viewTab = 'tasks-list';
|
||||
break;
|
||||
case 'BOARD':
|
||||
viewTab = 'board';
|
||||
break;
|
||||
default:
|
||||
viewTab = 'tasks-list';
|
||||
}
|
||||
|
||||
const searchParams = new URLSearchParams({
|
||||
tab: viewTab,
|
||||
pinned_tab: viewTab,
|
||||
});
|
||||
|
||||
navigate({
|
||||
pathname: `/worklenz/projects/${record.id}`,
|
||||
search: searchParams.toString(),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<Badge color="geekblue" className="mr-2" />
|
||||
<span className="cursor-pointer">
|
||||
<span onClick={() => selectProject(record)}>{record.name}</span>
|
||||
{(record.start_date || record.end_date) && (
|
||||
<Tooltip
|
||||
title={formatDateRange({
|
||||
startDate: record.start_date || null,
|
||||
endDate: record.end_date || null,
|
||||
})}
|
||||
overlayStyle={{ width: '200px' }}
|
||||
>
|
||||
<CalendarOutlined className="ml-2" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user