init
This commit is contained in:
27
worklenz-frontend/src/pages/projects/project-list.css
Normal file
27
worklenz-frontend/src/pages/projects/project-list.css
Normal file
@@ -0,0 +1,27 @@
|
||||
.ant-segmented-item-selected {
|
||||
background-color: #fff;
|
||||
border-radius: 4px;
|
||||
box-shadow:
|
||||
0 2px 8px -2px #0000000d,
|
||||
0 1px 4px -1px #00000012,
|
||||
0 0 1px #00000014;
|
||||
color: #262626;
|
||||
}
|
||||
|
||||
:where(.css-dev-only-do-not-override-c1iapc).ant-space-compact-block {
|
||||
width: unset !important;
|
||||
}
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.project-card.ant-card .ant-card-body {
|
||||
padding: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* remove the border after the tabs in project view */
|
||||
:where(.css-dev-only-do-not-override-17sis5b).ant-tabs-top > .ant-tabs-nav::before,
|
||||
:where(.css-dev-only-do-not-override-17sis5b).ant-tabs-bottom > .ant-tabs-nav::before,
|
||||
:where(.css-dev-only-do-not-override-17sis5b).ant-tabs-top > div > .ant-tabs-nav::before,
|
||||
:where(.css-dev-only-do-not-override-17sis5b).ant-tabs-bottom > div > .ant-tabs-nav::before {
|
||||
border: none;
|
||||
}
|
||||
267
worklenz-frontend/src/pages/projects/project-list.tsx
Normal file
267
worklenz-frontend/src/pages/projects/project-list.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Empty,
|
||||
Flex,
|
||||
Input,
|
||||
Segmented,
|
||||
Skeleton,
|
||||
Table,
|
||||
TablePaginationConfig,
|
||||
Tooltip,
|
||||
} from 'antd';
|
||||
import { PageHeader } from '@ant-design/pro-components';
|
||||
import { SearchOutlined, SyncOutlined } from '@ant-design/icons';
|
||||
import type { FilterValue, SorterResult } from 'antd/es/table/interface';
|
||||
|
||||
import ProjectDrawer from '@/components/projects/project-drawer/project-drawer';
|
||||
import CreateProjectButton from '@/components/projects/project-create-button/project-create-button';
|
||||
import TableColumns from '@/components/project-list/TableColumns';
|
||||
|
||||
import { useGetProjectsQuery } from '@/api/projects/projects.v1.api.service';
|
||||
|
||||
import {
|
||||
DEFAULT_PAGE_SIZE,
|
||||
FILTER_INDEX_KEY,
|
||||
PAGE_SIZE_OPTIONS,
|
||||
PROJECT_SORT_FIELD,
|
||||
PROJECT_SORT_ORDER,
|
||||
} from '@/shared/constants';
|
||||
import { IProjectFilter } from '@/types/project/project.types';
|
||||
import { IProjectViewModel } from '@/types/project/projectViewModel.types';
|
||||
|
||||
import { useDocumentTitle } from '@/hooks/useDoumentTItle';
|
||||
import './project-list.css';
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import {
|
||||
setFilteredCategories,
|
||||
setFilteredStatuses,
|
||||
setRequestParams,
|
||||
} from '@/features/projects/projectsSlice';
|
||||
import { fetchProjectStatuses } from '@/features/projects/lookups/projectStatuses/projectStatusesSlice';
|
||||
import { fetchProjectCategories } from '@/features/projects/lookups/projectCategories/projectCategoriesSlice';
|
||||
import { fetchProjectHealth } from '@/features/projects/lookups/projectHealth/projectHealthSlice';
|
||||
import { setProjectId, setStatuses } from '@/features/project/project.slice';
|
||||
import { setProject } from '@/features/project/project.slice';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { evt_projects_page_visit, evt_projects_refresh_click, evt_projects_search } from '@/shared/worklenz-analytics-events';
|
||||
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
|
||||
|
||||
const ProjectList: React.FC = () => {
|
||||
const [filteredInfo, setFilteredInfo] = useState<Record<string, FilterValue | null>>({});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { t } = useTranslation('all-project-list');
|
||||
const dispatch = useAppDispatch();
|
||||
const navigate = useNavigate();
|
||||
useDocumentTitle('Projects');
|
||||
const isOwnerOrAdmin = useAuthService().isOwnerOrAdmin();
|
||||
const { trackMixpanelEvent } = useMixpanelTracking();
|
||||
|
||||
const getFilterIndex = useCallback(() => {
|
||||
return +(localStorage.getItem(FILTER_INDEX_KEY) || 0);
|
||||
}, []);
|
||||
|
||||
const setFilterIndex = useCallback((index: number) => {
|
||||
localStorage.setItem(FILTER_INDEX_KEY, index.toString());
|
||||
}, []);
|
||||
|
||||
const setSortingValues = useCallback((field: string, order: string) => {
|
||||
localStorage.setItem(PROJECT_SORT_FIELD, field);
|
||||
localStorage.setItem(PROJECT_SORT_ORDER, order);
|
||||
}, []);
|
||||
|
||||
const { requestParams } = useAppSelector(state => state.projectsReducer);
|
||||
|
||||
const { projectStatuses } = useAppSelector(state => state.projectStatusesReducer);
|
||||
const { projectHealths } = useAppSelector(state => state.projectHealthReducer);
|
||||
const { projectCategories } = useAppSelector(state => state.projectCategoriesReducer);
|
||||
|
||||
const {
|
||||
data: projectsData,
|
||||
isLoading: loadingProjects,
|
||||
isFetching: isFetchingProjects,
|
||||
refetch: refetchProjects,
|
||||
} = useGetProjectsQuery(requestParams);
|
||||
|
||||
const filters = useMemo(() => Object.values(IProjectFilter), []);
|
||||
|
||||
// Create translated segment options for the filters
|
||||
const segmentOptions = useMemo(() => {
|
||||
return filters.map(filter => ({
|
||||
value: filter,
|
||||
label: t(filter.toLowerCase())
|
||||
}));
|
||||
}, [filters, t]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(loadingProjects || isFetchingProjects);
|
||||
}, [loadingProjects, isFetchingProjects]);
|
||||
|
||||
useEffect(() => {
|
||||
const filterIndex = getFilterIndex();
|
||||
dispatch(setRequestParams({ filter: filterIndex }));
|
||||
}, [dispatch, getFilterIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
trackMixpanelEvent(evt_projects_page_visit);
|
||||
refetchProjects();
|
||||
}, [requestParams, refetchProjects]);
|
||||
|
||||
const handleTableChange = useCallback(
|
||||
(
|
||||
newPagination: TablePaginationConfig,
|
||||
filters: Record<string, FilterValue | null>,
|
||||
sorter: SorterResult<IProjectViewModel> | SorterResult<IProjectViewModel>[]
|
||||
) => {
|
||||
const newParams: Partial<typeof requestParams> = {};
|
||||
if (!filters?.status_id) {
|
||||
newParams.statuses = null;
|
||||
dispatch(setFilteredStatuses([]));
|
||||
} else {
|
||||
// dispatch(setFilteredStatuses(filters.status_id as Array<string>));
|
||||
newParams.statuses = filters.status_id.join(' ');
|
||||
}
|
||||
|
||||
if (!filters?.category_id) {
|
||||
newParams.categories = null;
|
||||
dispatch(setFilteredCategories([]));
|
||||
} else {
|
||||
// dispatch(setFilteredCategories(filters.category_id as Array<string>));
|
||||
newParams.categories = filters.category_id.join(' ');
|
||||
}
|
||||
|
||||
const newOrder = Array.isArray(sorter) ? sorter[0].order : sorter.order;
|
||||
const newField = (Array.isArray(sorter) ? sorter[0].columnKey : sorter.columnKey) as string;
|
||||
|
||||
if (newOrder && newField) {
|
||||
newParams.order = newOrder ?? 'ascend';
|
||||
newParams.field = newField ?? 'name';
|
||||
setSortingValues(newParams.field, newParams.order);
|
||||
}
|
||||
|
||||
newParams.index = newPagination.current || 1;
|
||||
newParams.size = newPagination.pageSize || DEFAULT_PAGE_SIZE;
|
||||
|
||||
dispatch(setRequestParams(newParams));
|
||||
setFilteredInfo(filters);
|
||||
},
|
||||
[setSortingValues]
|
||||
);
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
trackMixpanelEvent(evt_projects_refresh_click);
|
||||
refetchProjects();
|
||||
}, [refetchProjects, requestParams]);
|
||||
|
||||
const handleSegmentChange = useCallback(
|
||||
(value: IProjectFilter) => {
|
||||
const newFilterIndex = filters.indexOf(value);
|
||||
setFilterIndex(newFilterIndex);
|
||||
dispatch(setRequestParams({ filter: newFilterIndex }));
|
||||
refetchProjects();
|
||||
},
|
||||
[filters, setFilterIndex, refetchProjects]
|
||||
);
|
||||
|
||||
const handleSearchChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
trackMixpanelEvent(evt_projects_search);
|
||||
const value = e.target.value;
|
||||
dispatch(setRequestParams({ search: value }));
|
||||
}, []);
|
||||
|
||||
const paginationConfig = useMemo(
|
||||
() => ({
|
||||
current: requestParams.index,
|
||||
pageSize: requestParams.size,
|
||||
showSizeChanger: true,
|
||||
defaultPageSize: DEFAULT_PAGE_SIZE,
|
||||
pageSizeOptions: PAGE_SIZE_OPTIONS,
|
||||
size: 'small' as const,
|
||||
total: projectsData?.body?.total,
|
||||
}),
|
||||
[requestParams.index, requestParams.size, projectsData?.body?.total]
|
||||
);
|
||||
|
||||
const handleDrawerClose = () => {
|
||||
dispatch(setProject({} as IProjectViewModel));
|
||||
dispatch(setProjectId(null));
|
||||
};
|
||||
const navigateToProject = (project_id: string | undefined, default_view: string | undefined) => {
|
||||
if (project_id) {
|
||||
navigate(`/worklenz/projects/${project_id}?tab=${default_view === 'BOARD' ? 'board' : 'tasks-list'}&pinned_tab=${default_view === 'BOARD' ? 'board' : 'tasks-list'}`); // Update the route as per your project structure
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (projectStatuses.length === 0) dispatch(fetchProjectStatuses());
|
||||
if (projectCategories.length === 0) dispatch(fetchProjectCategories());
|
||||
if (projectHealths.length === 0) dispatch(fetchProjectHealth());
|
||||
}, [requestParams]);
|
||||
|
||||
return (
|
||||
<div style={{ marginBlock: 65, minHeight: '90vh' }}>
|
||||
<PageHeader
|
||||
className="site-page-header"
|
||||
title={`${projectsData?.body?.total || 0} ${t('projects')}`}
|
||||
style={{ padding: '16px 0' }}
|
||||
extra={
|
||||
<Flex gap={8} align="center">
|
||||
<Tooltip title={t('refreshProjects')}>
|
||||
<Button
|
||||
shape="circle"
|
||||
icon={<SyncOutlined spin={isFetchingProjects} />}
|
||||
onClick={handleRefresh}
|
||||
aria-label="Refresh projects"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Segmented<IProjectFilter>
|
||||
options={segmentOptions}
|
||||
defaultValue={filters[getFilterIndex()] ?? filters[0]}
|
||||
onChange={handleSegmentChange}
|
||||
/>
|
||||
<Input
|
||||
placeholder={t('placeholder')}
|
||||
suffix={<SearchOutlined />}
|
||||
type="text"
|
||||
value={requestParams.search}
|
||||
onChange={handleSearchChange}
|
||||
aria-label="Search projects"
|
||||
/>
|
||||
{isOwnerOrAdmin && <CreateProjectButton />}
|
||||
</Flex>
|
||||
}
|
||||
/>
|
||||
<Card className="project-card">
|
||||
<Skeleton active loading={isLoading} className='mt-4 p-4'>
|
||||
<Table<IProjectViewModel>
|
||||
columns={TableColumns({
|
||||
navigate,
|
||||
filteredInfo,
|
||||
})}
|
||||
dataSource={projectsData?.body?.data || []}
|
||||
rowKey={record => record.id || ''}
|
||||
loading={loadingProjects}
|
||||
size="small"
|
||||
onChange={handleTableChange}
|
||||
pagination={paginationConfig}
|
||||
locale={{ emptyText: <Empty description={t('noProjects')} /> }}
|
||||
onRow={record => ({
|
||||
onClick: () => navigateToProject(record.id, record.team_member_default_view), // Navigate to project on row click
|
||||
})}
|
||||
/>
|
||||
</Skeleton>
|
||||
|
||||
</Card>
|
||||
|
||||
{createPortal(<ProjectDrawer onClose={handleDrawerClose} />, document.body, 'project-drawer')}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectList;
|
||||
@@ -0,0 +1,35 @@
|
||||
import { FC } from 'react';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
export type CardType = {
|
||||
id: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
const Card: FC<CardType> = ({ id, title }) => {
|
||||
// useSortableに指定するidは一意になるよう設定する必要があります。s
|
||||
const { attributes, listeners, setNodeRef, transform } = useSortable({
|
||||
id: id,
|
||||
});
|
||||
|
||||
const style = {
|
||||
margin: '10px',
|
||||
opacity: 1,
|
||||
color: '#333',
|
||||
background: 'white',
|
||||
padding: '10px',
|
||||
transform: CSS.Transform.toString(transform),
|
||||
};
|
||||
|
||||
return (
|
||||
// attributes、listenersはDOMイベントを検知するために利用します。
|
||||
// listenersを任意の領域に付与することで、ドラッグするためのハンドルを作ることもできます。
|
||||
<div ref={setNodeRef} {...attributes} {...listeners} style={style}>
|
||||
<div id={id}>
|
||||
<p>{title}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Card;
|
||||
@@ -0,0 +1,45 @@
|
||||
import { FC } from 'react';
|
||||
import { SortableContext, rectSortingStrategy } from '@dnd-kit/sortable';
|
||||
import { useDroppable } from '@dnd-kit/core';
|
||||
import Card, { CardType } from './card';
|
||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||
|
||||
export type ColumnType = {
|
||||
id: string;
|
||||
title: string;
|
||||
cards: IProjectTask[];
|
||||
};
|
||||
|
||||
const Column: FC<ColumnType> = ({ id, title, cards }) => {
|
||||
const { setNodeRef } = useDroppable({ id: id });
|
||||
return (
|
||||
// ソートを行うためのContextです。
|
||||
// strategyは4つほど存在しますが、今回は縦・横移動可能なリストを作るためrectSortingStrategyを採用
|
||||
<SortableContext id={id} items={cards} strategy={rectSortingStrategy}>
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={{
|
||||
width: '200px',
|
||||
background: 'rgba(245,247,249,1.00)',
|
||||
marginRight: '10px',
|
||||
}}
|
||||
>
|
||||
<p
|
||||
style={{
|
||||
padding: '5px 20px',
|
||||
textAlign: 'left',
|
||||
fontWeight: '500',
|
||||
color: '#575757',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</p>
|
||||
{cards.map(card => (
|
||||
<Card key={card.id} id={card.id} title={card.title}></Card>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
);
|
||||
};
|
||||
|
||||
export default Column;
|
||||
@@ -0,0 +1,141 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import {
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
DragOverEvent,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import TaskListFilters from '../taskList/taskListFilters/TaskListFilters';
|
||||
import { Button, Skeleton } from 'antd';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { toggleDrawer } from '@/features/projects/status/StatusSlice';
|
||||
import KanbanGroup from '@/components/board/kanban-group/kanban-group';
|
||||
|
||||
const ProjectViewBoard: React.FC = () => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const { taskGroups, loadingGroups } = useAppSelector(state => state.taskReducer);
|
||||
const { statusCategories } = useAppSelector(state => state.taskStatusReducer);
|
||||
const groupBy = useAppSelector(state => state.groupByFilterDropdownReducer.groupBy);
|
||||
const projectId = useAppSelector(state => state.projectReducer.projectId);
|
||||
|
||||
useEffect(() => {
|
||||
console.log('projectId', projectId);
|
||||
// if (projectId) {
|
||||
// const config: ITaskListConfigV2 = {
|
||||
// id: projectId,
|
||||
// field: 'id',
|
||||
// order: 'desc',
|
||||
// search: '',
|
||||
// statuses: '',
|
||||
// members: '',
|
||||
// projects: '',
|
||||
// isSubtasksInclude: false,
|
||||
// };
|
||||
// dispatch(fetchTaskGroups(config) as any);
|
||||
// }
|
||||
// if (!statusCategories.length) {
|
||||
// dispatch(fetchStatusesCategories() as any);
|
||||
// }
|
||||
}, [dispatch, projectId, groupBy]);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const handleDragOver = (event: DragOverEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over) return;
|
||||
|
||||
const activeTask = active.data.current?.task;
|
||||
const overId = over.id;
|
||||
|
||||
// Find which group the task is being dragged over
|
||||
const targetGroup = taskGroups.find(
|
||||
group => group.id === overId || group.tasks.some(task => task.id === overId)
|
||||
);
|
||||
|
||||
if (targetGroup && activeTask) {
|
||||
// Here you would dispatch an action to update the task's status
|
||||
// For example:
|
||||
// dispatch(updateTaskStatus({ taskId: activeTask.id, newStatus: targetGroup.id }));
|
||||
console.log('Moving task', activeTask.id, 'to group', targetGroup.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over) return;
|
||||
|
||||
const activeTask = active.data.current?.task;
|
||||
const overId = over.id;
|
||||
|
||||
// Similar to handleDragOver, but this is where you'd make the final update
|
||||
const targetGroup = taskGroups.find(
|
||||
group => group.id === overId || group.tasks.some(task => task.id === overId)
|
||||
);
|
||||
|
||||
if (targetGroup && activeTask) {
|
||||
// Make the final update to your backend/state
|
||||
console.log('Final move of task', activeTask.id, 'to group', targetGroup.id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ width: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
<TaskListFilters position={'board'} />
|
||||
|
||||
<Skeleton active loading={loadingGroups}>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '0 12px',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'column',
|
||||
marginTop: '14px',
|
||||
}}
|
||||
>
|
||||
<DndContext sensors={sensors} onDragOver={handleDragOver} onDragEnd={handleDragEnd}>
|
||||
<div
|
||||
style={{
|
||||
paddingTop: '6px',
|
||||
display: 'flex',
|
||||
gap: '10px',
|
||||
overflowX: 'scroll',
|
||||
paddingBottom: '10px',
|
||||
}}
|
||||
>
|
||||
{taskGroups.map(group => (
|
||||
<KanbanGroup
|
||||
key={group.id}
|
||||
title={group.name}
|
||||
tasks={group.tasks}
|
||||
id={group.id}
|
||||
color={group.color_code}
|
||||
/>
|
||||
))}
|
||||
|
||||
<Button
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => dispatch(toggleDrawer())}
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
</div>
|
||||
</DndContext>
|
||||
</div>
|
||||
</Skeleton>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectViewBoard;
|
||||
@@ -0,0 +1,116 @@
|
||||
:root {
|
||||
--odd-row-color: #fff;
|
||||
--even-row-color: #4e4e4e10;
|
||||
--text-color: #181818;
|
||||
--border: 1px solid #e0e0e0;
|
||||
--stroke: #e0e0e0;
|
||||
|
||||
--calender-header-bg: #fafafa;
|
||||
}
|
||||
|
||||
.dark-theme {
|
||||
--odd-row-color: #141414;
|
||||
--even-row-color: #4e4e4e10;
|
||||
--text-color: #fff;
|
||||
--border: 1px solid #505050;
|
||||
--stroke: #505050;
|
||||
|
||||
--calender-header-bg: #1d1d1d;
|
||||
}
|
||||
|
||||
/* scroll bar size override */
|
||||
._2k9Ys {
|
||||
scrollbar-width: unset;
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------------- */
|
||||
/* task details side even rows */
|
||||
._34SS0:nth-of-type(even) {
|
||||
background-color: var(--even-row-color);
|
||||
}
|
||||
|
||||
/* task details side header and body */
|
||||
._3_ygE {
|
||||
border-top: var(--border);
|
||||
border-left: var(--border);
|
||||
position: relative;
|
||||
}
|
||||
._2B2zv {
|
||||
border-bottom: var(--border);
|
||||
border-left: var(--border);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
._3ZbQT {
|
||||
border: none;
|
||||
}
|
||||
|
||||
._3_ygE::after,
|
||||
._2B2zv::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: -25px;
|
||||
width: 30px;
|
||||
height: 100%;
|
||||
box-shadow: inset 10px 0 8px -8px #00000026;
|
||||
}
|
||||
|
||||
/* ._3lLk3:nth-child(1),
|
||||
._WuQ0f:nth-child(1) {
|
||||
min-width: 300px !important;
|
||||
max-width: 300px !important;
|
||||
}
|
||||
|
||||
._2eZzQ,
|
||||
._WuQ0f:nth-child(3),
|
||||
._WuQ0f:last-child,
|
||||
._3lLk3:nth-child(2),
|
||||
._3lLk3:nth-child(3) {
|
||||
display: none;
|
||||
} */
|
||||
|
||||
/* ----------------------------------------------------------------------- */
|
||||
/* calender side header */
|
||||
._35nLX {
|
||||
fill: var(--calender-header-bg);
|
||||
stroke: var(--stroke);
|
||||
stroke-width: 1px;
|
||||
}
|
||||
|
||||
/* calender side header texts */
|
||||
._9w8d5,
|
||||
._2q1Kt {
|
||||
fill: var(--text-color);
|
||||
}
|
||||
|
||||
/* calender side odd rows */
|
||||
._2dZTy:nth-child(odd) {
|
||||
fill: var(--odd-row-color);
|
||||
}
|
||||
/* calender side even rows */
|
||||
._2dZTy:nth-child(even) {
|
||||
fill: var(--even-row-color);
|
||||
}
|
||||
|
||||
/* calender side body row lines */
|
||||
._3rUKi {
|
||||
stroke: var(--stroke);
|
||||
stroke-width: 0.3px;
|
||||
}
|
||||
|
||||
/* calender side body ticks */
|
||||
._RuwuK {
|
||||
stroke: var(--stroke);
|
||||
stroke-width: 0.3px;
|
||||
}
|
||||
|
||||
/* calender side header ticks */
|
||||
._1rLuZ {
|
||||
stroke: var(--stroke);
|
||||
stroke-width: 1px;
|
||||
}
|
||||
|
||||
.roadmap-table .ant-table-thead .ant-table-cell {
|
||||
height: 50px;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useState } from 'react';
|
||||
import { ViewMode } from 'gantt-task-react';
|
||||
import 'gantt-task-react/dist/index.css';
|
||||
import './project-view-roadmap.css';
|
||||
import { Flex } from 'antd';
|
||||
import { useAppSelector } from '../../../../hooks/useAppSelector';
|
||||
import { TimeFilter } from './time-filter';
|
||||
import RoadmapTable from './roadmap-table/roadmap-table';
|
||||
import RoadmapGrantChart from './roadmap-grant-chart';
|
||||
|
||||
const ProjectViewRoadmap = () => {
|
||||
const [view, setView] = useState<ViewMode>(ViewMode.Day);
|
||||
|
||||
// get theme details
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
|
||||
return (
|
||||
<Flex vertical className={`${themeMode === 'dark' ? 'dark-theme' : ''}`}>
|
||||
{/* time filter */}
|
||||
<TimeFilter onViewModeChange={viewMode => setView(viewMode)} />
|
||||
|
||||
<Flex>
|
||||
{/* table */}
|
||||
<div className="after:content relative h-fit w-full max-w-[500px] after:absolute after:-right-3 after:top-0 after:z-10 after:min-h-full after:w-3 after:bg-gradient-to-r after:from-[rgba(0,0,0,0.12)] after:to-transparent">
|
||||
<RoadmapTable />
|
||||
</div>
|
||||
|
||||
{/* gantt Chart */}
|
||||
<RoadmapGrantChart view={view} />
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectViewRoadmap;
|
||||
@@ -0,0 +1,91 @@
|
||||
import { Gantt, Task, ViewMode } from 'gantt-task-react';
|
||||
import React from 'react';
|
||||
import { colors } from '../../../../styles/colors';
|
||||
import {
|
||||
NewTaskType,
|
||||
updateTaskDate,
|
||||
updateTaskProgress,
|
||||
} from '../../../../features/roadmap/roadmap-slice';
|
||||
import { useAppSelector } from '../../../../hooks/useAppSelector';
|
||||
import { useAppDispatch } from '../../../../hooks/useAppDispatch';
|
||||
import { toggleTaskDrawer } from '../../../../features/tasks/tasks.slice';
|
||||
|
||||
type RoadmapGrantChartProps = {
|
||||
view: ViewMode;
|
||||
};
|
||||
|
||||
const RoadmapGrantChart = ({ view }: RoadmapGrantChartProps) => {
|
||||
// get task list from roadmap slice
|
||||
const tasks = useAppSelector(state => state.roadmapReducer.tasksList);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
// column widths for each view mods
|
||||
let columnWidth = 60;
|
||||
if (view === ViewMode.Year) {
|
||||
columnWidth = 350;
|
||||
} else if (view === ViewMode.Month) {
|
||||
columnWidth = 300;
|
||||
} else if (view === ViewMode.Week) {
|
||||
columnWidth = 250;
|
||||
}
|
||||
|
||||
// function to handle double click
|
||||
const handleDoubleClick = () => {
|
||||
dispatch(toggleTaskDrawer());
|
||||
};
|
||||
|
||||
// function to handle date change
|
||||
const handleTaskDateChange = (task: Task) => {
|
||||
dispatch(updateTaskDate({ taskId: task.id, start: task.start, end: task.end }));
|
||||
};
|
||||
|
||||
// function to handle progress change
|
||||
const handleTaskProgressChange = (task: Task) => {
|
||||
dispatch(updateTaskProgress({ taskId: task.id, progress: task.progress }));
|
||||
};
|
||||
|
||||
// function to convert the tasklist comming form roadmap slice which has NewTaskType converted to Task type which is the default type of the tasks list in the grant chart
|
||||
const flattenTasks = (tasks: NewTaskType[]): Task[] => {
|
||||
const flattened: Task[] = [];
|
||||
|
||||
const addTaskAndSubTasks = (task: NewTaskType, parentExpanded: boolean) => {
|
||||
// add the task to the flattened list if its parent is expanded or it is a top-level task
|
||||
if (parentExpanded) {
|
||||
const { subTasks, isExpanded, ...rest } = task; // destructure to exclude properties not in Task type
|
||||
flattened.push(rest as Task);
|
||||
|
||||
// recursively add subtasks if this task is expanded
|
||||
if (subTasks && isExpanded) {
|
||||
subTasks.forEach(subTask => addTaskAndSubTasks(subTask as NewTaskType, true));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// top-level tasks are always visible, start with parentExpanded = true
|
||||
tasks.forEach(task => addTaskAndSubTasks(task, true));
|
||||
|
||||
return flattened;
|
||||
};
|
||||
|
||||
const flattenedTasksList = flattenTasks(tasks);
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-[900px] overflow-x-auto">
|
||||
<Gantt
|
||||
tasks={flattenedTasksList}
|
||||
viewMode={view}
|
||||
onDateChange={handleTaskDateChange}
|
||||
onProgressChange={handleTaskProgressChange}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
listCellWidth={''}
|
||||
columnWidth={columnWidth}
|
||||
todayColor={`rgba(64, 150, 255, 0.2)`}
|
||||
projectProgressColor={colors.limeGreen}
|
||||
projectBackgroundColor={colors.lightGreen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RoadmapGrantChart;
|
||||
@@ -0,0 +1,189 @@
|
||||
import React from 'react';
|
||||
import { DatePicker, Typography } from 'antd';
|
||||
import dayjs, { Dayjs } from 'dayjs';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { NewTaskType, updateTaskDate } from '@features/roadmap/roadmap-slice';
|
||||
import { colors } from '@/styles/colors';
|
||||
import RoadmapTaskCell from './roadmap-task-cell';
|
||||
|
||||
const RoadmapTable = () => {
|
||||
// Get task list and expanded tasks from roadmap slice
|
||||
const tasks = useAppSelector(state => state.roadmapReducer.tasksList);
|
||||
|
||||
// Get theme data from theme slice
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
// function to handle date changes
|
||||
const handleDateChange = (taskId: string, dateType: 'start' | 'end', date: Dayjs) => {
|
||||
const updatedDate = date.toDate();
|
||||
|
||||
dispatch(
|
||||
updateTaskDate({
|
||||
taskId,
|
||||
start: dateType === 'start' ? updatedDate : new Date(),
|
||||
end: dateType === 'end' ? updatedDate : new Date(),
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
// Adjusted column type with a string or ReactNode for the title
|
||||
const columns: { key: string; title: React.ReactNode; width: number }[] = [
|
||||
{
|
||||
key: 'name',
|
||||
title: 'Task Name',
|
||||
width: 240,
|
||||
},
|
||||
{
|
||||
key: 'start',
|
||||
title: 'Start Date',
|
||||
width: 130,
|
||||
},
|
||||
{
|
||||
key: 'end',
|
||||
title: 'End Date',
|
||||
width: 130,
|
||||
},
|
||||
];
|
||||
|
||||
// Function to render the column content based on column key
|
||||
const renderColumnContent = (
|
||||
columnKey: string,
|
||||
task: NewTaskType,
|
||||
isSubtask: boolean = false
|
||||
) => {
|
||||
switch (columnKey) {
|
||||
case 'name':
|
||||
return <RoadmapTaskCell task={task} isSubtask={isSubtask} />;
|
||||
case 'start':
|
||||
const startDayjs = task.start ? dayjs(task.start) : null;
|
||||
return (
|
||||
<DatePicker
|
||||
placeholder="Set Start Date"
|
||||
defaultValue={startDayjs}
|
||||
format={'MMM DD, YYYY'}
|
||||
suffixIcon={null}
|
||||
disabled={task.type === 'project'}
|
||||
onChange={date => handleDateChange(task.id, 'end', date)}
|
||||
style={{
|
||||
backgroundColor: colors.transparent,
|
||||
border: 'none',
|
||||
boxShadow: 'none',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
case 'end':
|
||||
const endDayjs = task.end ? dayjs(task.end) : null;
|
||||
return (
|
||||
<DatePicker
|
||||
placeholder="Set End Date"
|
||||
defaultValue={endDayjs}
|
||||
format={'MMM DD, YYYY'}
|
||||
suffixIcon={null}
|
||||
disabled={task.type === 'project'}
|
||||
onChange={date => handleDateChange(task.id, 'end', date)}
|
||||
style={{
|
||||
backgroundColor: colors.transparent,
|
||||
border: 'none',
|
||||
boxShadow: 'none',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const dataSource = tasks.map(task => ({
|
||||
id: task.id,
|
||||
name: task.name,
|
||||
start: task.start,
|
||||
end: task.end,
|
||||
type: task.type,
|
||||
progress: task.progress,
|
||||
subTasks: task.subTasks,
|
||||
isExpanded: task.isExpanded,
|
||||
}));
|
||||
|
||||
// Layout styles for table and columns
|
||||
const customHeaderColumnStyles = `border px-2 h-[50px] text-left z-10 after:content after:absolute after:top-0 after:-right-1 after:-z-10 after:h-[42px] after:w-1.5 after:bg-transparent after:bg-gradient-to-r after:from-[rgba(0,0,0,0.12)] after:to-transparent ${themeMode === 'dark' ? 'bg-[#1d1d1d] border-[#303030]' : 'bg-[#fafafa]'}`;
|
||||
|
||||
const customBodyColumnStyles = `border px-2 h-[50px] z-10 after:content after:absolute after:top-0 after:-right-1 after:-z-10 after:min-h-[40px] after:w-1.5 after:bg-transparent after:bg-gradient-to-r after:from-[rgba(0,0,0,0.12)] after:to-transparent ${themeMode === 'dark' ? 'bg-transparent border-[#303030]' : 'bg-transparent'}`;
|
||||
|
||||
const rowBackgroundStyles =
|
||||
themeMode === 'dark' ? 'even:bg-[#1b1b1b] odd:bg-[#141414]' : 'even:bg-[#f4f4f4] odd:bg-white';
|
||||
|
||||
return (
|
||||
<div className="relative w-full max-w-[1000px]">
|
||||
<table className={`rounded-2 w-full min-w-max border-collapse`}>
|
||||
<thead className="h-[50px]">
|
||||
<tr>
|
||||
{/* table header */}
|
||||
{columns.map(column => (
|
||||
<th
|
||||
key={column.key}
|
||||
className={`${customHeaderColumnStyles}`}
|
||||
style={{ width: column.width, fontWeight: 500 }}
|
||||
>
|
||||
<Typography.Text style={{ fontWeight: 500 }}>{column.title}</Typography.Text>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{dataSource.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={columns.length} className="text-center">
|
||||
No tasks available
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
dataSource.map(task => (
|
||||
<React.Fragment key={task.id}>
|
||||
<tr
|
||||
key={task.id}
|
||||
className={`group cursor-pointer ${dataSource.length === 0 ? 'h-0' : 'h-[50px]'} ${rowBackgroundStyles}`}
|
||||
>
|
||||
{columns.map(column => (
|
||||
<td
|
||||
key={column.key}
|
||||
className={`${customBodyColumnStyles}`}
|
||||
style={{
|
||||
width: column.width,
|
||||
}}
|
||||
>
|
||||
{renderColumnContent(column.key, task)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
|
||||
{/* subtasks */}
|
||||
{task.isExpanded &&
|
||||
task?.subTasks?.map(subtask => (
|
||||
<tr key={`subtask-${subtask.id}`} className={`h-[50px] ${rowBackgroundStyles}`}>
|
||||
{columns.map(column => (
|
||||
<td
|
||||
key={column.key}
|
||||
className={`${customBodyColumnStyles}`}
|
||||
style={{
|
||||
width: column.width,
|
||||
}}
|
||||
>
|
||||
{renderColumnContent(column.key, subtask, true)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</React.Fragment>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RoadmapTable;
|
||||
@@ -0,0 +1,112 @@
|
||||
import { Flex, Typography, Button, Tooltip } from 'antd';
|
||||
import {
|
||||
DoubleRightOutlined,
|
||||
DownOutlined,
|
||||
RightOutlined,
|
||||
ExpandAltOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { NewTaskType, toggleTaskExpansion } from '@features/roadmap/roadmap-slice';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { toggleTaskDrawer } from '@features/tasks/taskSlice';
|
||||
import { colors } from '@/styles/colors';
|
||||
|
||||
type RoadmapTaskCellProps = {
|
||||
task: NewTaskType;
|
||||
isSubtask?: boolean;
|
||||
};
|
||||
|
||||
const RoadmapTaskCell = ({ task, isSubtask = false }: RoadmapTaskCellProps) => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
// render the toggle arrow icon for tasks with subtasks
|
||||
const renderToggleButtonForHasSubTasks = (id: string, hasSubtasks: boolean) => {
|
||||
if (!hasSubtasks) return null;
|
||||
return (
|
||||
<button
|
||||
onClick={() => dispatch(toggleTaskExpansion(id))}
|
||||
className="hover flex h-4 w-4 items-center justify-center rounded text-[12px] hover:border hover:border-[#5587f5] hover:bg-[#d0eefa54]"
|
||||
>
|
||||
{task.isExpanded ? <DownOutlined /> : <RightOutlined />}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
// show expand button on hover for tasks without subtasks
|
||||
const renderToggleButtonForNonSubtasks = (id: string, isSubtask: boolean) => {
|
||||
return !isSubtask ? (
|
||||
<button
|
||||
onClick={() => dispatch(toggleTaskExpansion(id))}
|
||||
className="hover flex h-4 w-4 items-center justify-center rounded text-[12px] hover:border hover:border-[#5587f5] hover:bg-[#d0eefa54]"
|
||||
>
|
||||
{task.isExpanded ? <DownOutlined /> : <RightOutlined />}
|
||||
</button>
|
||||
) : (
|
||||
<div className="h-4 w-4"></div>
|
||||
);
|
||||
};
|
||||
|
||||
// render the double arrow icon and count label for tasks with subtasks
|
||||
const renderSubtasksCountLabel = (id: string, isSubtask: boolean, subTasksCount: number) => {
|
||||
return (
|
||||
!isSubtask && (
|
||||
<Button
|
||||
onClick={() => dispatch(toggleTaskExpansion(id))}
|
||||
size="small"
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 2,
|
||||
paddingInline: 4,
|
||||
alignItems: 'center',
|
||||
justifyItems: 'center',
|
||||
border: 'none',
|
||||
}}
|
||||
>
|
||||
<Typography.Text style={{ fontSize: 12, lineHeight: 1 }}>{subTasksCount}</Typography.Text>
|
||||
<DoubleRightOutlined style={{ fontSize: 10 }} />
|
||||
</Button>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex gap={8} align="center" justify="space-between">
|
||||
<Flex gap={8} align="center">
|
||||
{!!task?.subTasks?.length ? (
|
||||
renderToggleButtonForHasSubTasks(task.id, !!task?.subTasks?.length)
|
||||
) : (
|
||||
<div className="h-4 w-4 opacity-0 group-hover:opacity-100 group-focus:opacity-100">
|
||||
{renderToggleButtonForNonSubtasks(task.id, isSubtask)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isSubtask && <DoubleRightOutlined style={{ fontSize: 12 }} />}
|
||||
|
||||
<Tooltip title={task.name}>
|
||||
<Typography.Text ellipsis={{ expanded: false }} style={{ maxWidth: 100 }}>
|
||||
{task.name}
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
|
||||
{renderSubtasksCountLabel(task.id, isSubtask, task?.subTasks?.length || 0)}
|
||||
</Flex>
|
||||
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ExpandAltOutlined />}
|
||||
onClick={() => {
|
||||
dispatch(toggleTaskDrawer());
|
||||
}}
|
||||
style={{
|
||||
backgroundColor: colors.transparent,
|
||||
padding: 0,
|
||||
height: 'fit-content',
|
||||
}}
|
||||
className="hidden group-hover:block group-focus:block"
|
||||
>
|
||||
Open
|
||||
</Button>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default RoadmapTaskCell;
|
||||
@@ -0,0 +1,74 @@
|
||||
import React from 'react';
|
||||
import 'gantt-task-react/dist/index.css';
|
||||
import { ViewMode } from 'gantt-task-react';
|
||||
import { Flex, Select } from 'antd';
|
||||
type TimeFilterProps = {
|
||||
onViewModeChange: (viewMode: ViewMode) => void;
|
||||
};
|
||||
export const TimeFilter = ({ onViewModeChange }: TimeFilterProps) => {
|
||||
// function to handle time change
|
||||
const handleChange = (value: string) => {
|
||||
switch (value) {
|
||||
case 'hour':
|
||||
return onViewModeChange(ViewMode.Hour);
|
||||
case 'quaterDay':
|
||||
return onViewModeChange(ViewMode.QuarterDay);
|
||||
case 'halfDay':
|
||||
return onViewModeChange(ViewMode.HalfDay);
|
||||
case 'day':
|
||||
return onViewModeChange(ViewMode.Day);
|
||||
case 'week':
|
||||
return onViewModeChange(ViewMode.Week);
|
||||
case 'month':
|
||||
return onViewModeChange(ViewMode.Month);
|
||||
case 'year':
|
||||
return onViewModeChange(ViewMode.Year);
|
||||
default:
|
||||
return onViewModeChange(ViewMode.Day);
|
||||
}
|
||||
};
|
||||
|
||||
const timeFilterItems = [
|
||||
{
|
||||
value: 'hour',
|
||||
label: 'Hour',
|
||||
},
|
||||
{
|
||||
value: 'quaterDay',
|
||||
label: 'Quater Day',
|
||||
},
|
||||
{
|
||||
value: 'halfDay',
|
||||
label: 'Half Day',
|
||||
},
|
||||
{
|
||||
value: 'day',
|
||||
label: 'Day',
|
||||
},
|
||||
{
|
||||
value: 'week',
|
||||
label: 'Week',
|
||||
},
|
||||
{
|
||||
value: 'month',
|
||||
label: 'Month',
|
||||
},
|
||||
{
|
||||
value: 'year',
|
||||
label: 'Year',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Flex gap={12} align="center" justify="flex-end" style={{ marginBlockEnd: 24 }}>
|
||||
<Select
|
||||
className="ViewModeSelect"
|
||||
style={{ minWidth: 120 }}
|
||||
placeholder="Select View Mode"
|
||||
onChange={handleChange}
|
||||
options={timeFilterItems}
|
||||
defaultValue={'day'}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,142 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { Checkbox, Flex, Tag, Tooltip } from 'antd';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { ITaskListGroup } from '@/types/tasks/taskList.types';
|
||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||
|
||||
const TaskListTable = ({
|
||||
taskListGroup,
|
||||
visibleColumns,
|
||||
onTaskSelect,
|
||||
onTaskExpand,
|
||||
}: {
|
||||
taskListGroup: ITaskListGroup;
|
||||
tableId: string;
|
||||
visibleColumns: Array<{ key: string; width: number }>;
|
||||
onTaskSelect?: (taskId: string) => void;
|
||||
onTaskExpand?: (taskId: string) => void;
|
||||
}) => {
|
||||
const [hoverRow, setHoverRow] = useState<string | null>(null);
|
||||
const tableRef = useRef<HTMLDivElement | null>(null);
|
||||
const parentRef = useRef<HTMLDivElement | null>(null);
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
|
||||
// Memoize all tasks including subtasks for virtualization
|
||||
const flattenedTasks = useMemo(() => {
|
||||
return taskListGroup.tasks.reduce((acc: IProjectTask[], task: IProjectTask) => {
|
||||
acc.push(task);
|
||||
if (task.sub_tasks?.length) {
|
||||
acc.push(...task.sub_tasks.map((st: any) => ({ ...st, isSubtask: true })));
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
}, [taskListGroup.tasks]);
|
||||
|
||||
// Virtual row renderer
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: flattenedTasks.length,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => 42, // row height
|
||||
overscan: 5,
|
||||
});
|
||||
|
||||
// Memoize cell render functions
|
||||
const renderCell = useCallback(
|
||||
(columnKey: string | number, task: IProjectTask, isSubtask = false) => {
|
||||
const cellContent = {
|
||||
taskId: () => {
|
||||
const key = task.task_key?.toString() || '';
|
||||
return (
|
||||
<Tooltip title={key}>
|
||||
<Tag>{key}</Tag>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
task: () => (
|
||||
<Flex align="center" className="pl-2">
|
||||
{task.name}
|
||||
</Flex>
|
||||
),
|
||||
// Add other cell renderers as needed...
|
||||
}[columnKey];
|
||||
|
||||
return cellContent ? cellContent() : null;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// Memoize header rendering
|
||||
const TableHeader = useMemo(
|
||||
() => (
|
||||
<div className="sticky top-0 z-20 flex border-b" style={{ height: 42 }}>
|
||||
<div className="sticky left-0 z-30 w-8 bg-white dark:bg-gray-900 flex items-center justify-center">
|
||||
<Checkbox />
|
||||
</div>
|
||||
{visibleColumns.map(column => (
|
||||
<div
|
||||
key={column.key}
|
||||
className="flex items-center px-3 border-r"
|
||||
style={{ width: column.width }}
|
||||
>
|
||||
{column.key}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
[visibleColumns]
|
||||
);
|
||||
|
||||
// Handle scroll shadows
|
||||
const handleScroll = useCallback((e: { target: any }) => {
|
||||
const target = e.target;
|
||||
const hasHorizontalShadow = target.scrollLeft > 0;
|
||||
target.classList.toggle('show-shadow', hasHorizontalShadow);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={parentRef} className="h-[400px] overflow-auto" onScroll={handleScroll}>
|
||||
{TableHeader}
|
||||
|
||||
<div
|
||||
ref={tableRef}
|
||||
style={{
|
||||
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{rowVirtualizer.getVirtualItems().map(virtualRow => {
|
||||
const task = flattenedTasks[virtualRow.index];
|
||||
return (
|
||||
<div
|
||||
key={task.id}
|
||||
className="absolute top-0 left-0 flex w-full border-b hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||
style={{
|
||||
height: 42,
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
>
|
||||
<div className="sticky left-0 z-10 w-8 flex items-center justify-center">
|
||||
{/* <Checkbox checked={task.selected} /> */}
|
||||
</div>
|
||||
{visibleColumns.map(column => (
|
||||
<div
|
||||
key={column.key}
|
||||
className={`flex items-center px-3 border-r ${
|
||||
hoverRow === task.id ? 'bg-gray-50 dark:bg-gray-800' : ''
|
||||
}`}
|
||||
style={{ width: column.width }}
|
||||
>
|
||||
{renderCell(column.key, task, task.is_sub_task)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskListTable;
|
||||
@@ -0,0 +1,239 @@
|
||||
import { Avatar, Checkbox, DatePicker, Flex, Select, Tag } from 'antd';
|
||||
import { createColumnHelper, ColumnDef } from '@tanstack/react-table';
|
||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||
import { HolderOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import StatusDropdown from '@/components/task-list-common/status-dropdown/status-dropdown';
|
||||
import Avatars from '@/components/avatars/avatars';
|
||||
import LabelsSelector from '@/components/task-list-common/labelsSelector/labels-selector';
|
||||
import CustomColorLabel from '@/components/task-list-common/labelsSelector/custom-color-label';
|
||||
import TaskRowName from '@/components/task-list-common/task-row/task-row-name/task-row-name';
|
||||
import TaskRowDescription from '@/components/task-list-common/task-row/task-row-description/task-row-description';
|
||||
import TaskRowProgress from '@/components/task-list-common/task-row/task-row-progress/task-row-progress';
|
||||
import TaskRowDueTime from '@/components/task-list-common/task-row/task-list-due-time-cell/task-row-due-time';
|
||||
import { COLUMN_KEYS } from '@/features/tasks/tasks.slice';
|
||||
|
||||
interface CreateColumnsProps {
|
||||
expandedRows: Record<string, boolean>;
|
||||
statuses: any[];
|
||||
handleTaskSelect: (taskId: string) => void;
|
||||
getCurrentSession: () => any;
|
||||
}
|
||||
|
||||
export const createColumns = ({
|
||||
expandedRows,
|
||||
statuses,
|
||||
handleTaskSelect,
|
||||
getCurrentSession,
|
||||
}: CreateColumnsProps): ColumnDef<IProjectTask, any>[] => {
|
||||
const columnHelper = createColumnHelper<IProjectTask>();
|
||||
|
||||
return [
|
||||
columnHelper.display({
|
||||
id: 'select',
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
checked={table.getIsAllRowsSelected()}
|
||||
indeterminate={table.getIsSomeRowsSelected()}
|
||||
onChange={table.getToggleAllRowsSelectedHandler()}
|
||||
style={{ padding: '8px 6px 8px 0!important' }}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Flex align="center" gap={4}>
|
||||
<HolderOutlined style={{ cursor: 'move' }} />
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
disabled={!row.getCanSelect()}
|
||||
indeterminate={row.getIsSomeSelected()}
|
||||
onChange={row.getToggleSelectedHandler()}
|
||||
/>
|
||||
</Flex>
|
||||
),
|
||||
size: 47,
|
||||
minSize: 47,
|
||||
maxSize: 47,
|
||||
enablePinning: true,
|
||||
meta: {
|
||||
style: { position: 'sticky', left: 0, zIndex: 1 },
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor('task_key', {
|
||||
header: 'Key',
|
||||
id: COLUMN_KEYS.KEY,
|
||||
size: 85,
|
||||
minSize: 85,
|
||||
maxSize: 85,
|
||||
enablePinning: false,
|
||||
cell: ({ row }) => (
|
||||
<Tag onClick={() => handleTaskSelect(row.original.id || '')} style={{ cursor: 'pointer' }}>
|
||||
{row.original.task_key}
|
||||
</Tag>
|
||||
),
|
||||
}),
|
||||
|
||||
columnHelper.accessor('name', {
|
||||
header: 'Task',
|
||||
id: COLUMN_KEYS.NAME,
|
||||
size: 450,
|
||||
enablePinning: true,
|
||||
meta: {
|
||||
style: { position: 'sticky', left: '47px', zIndex: 1 },
|
||||
},
|
||||
cell: ({ row }) => (
|
||||
<TaskRowName
|
||||
task={row.original}
|
||||
isSubTask={false}
|
||||
expandedTasks={Object.keys(expandedRows)}
|
||||
setSelectedTaskId={() => {}}
|
||||
toggleTaskExpansion={() => {}}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
|
||||
columnHelper.accessor('description', {
|
||||
header: 'Description',
|
||||
id: COLUMN_KEYS.DESCRIPTION,
|
||||
size: 225,
|
||||
enablePinning: false,
|
||||
cell: ({ row }) => <TaskRowDescription description={row.original.description || ''} />,
|
||||
}),
|
||||
|
||||
columnHelper.accessor('progress', {
|
||||
header: 'Progress',
|
||||
id: COLUMN_KEYS.PROGRESS,
|
||||
size: 80,
|
||||
enablePinning: false,
|
||||
cell: ({ row }) => (
|
||||
<TaskRowProgress
|
||||
progress={row.original.progress || 0}
|
||||
numberOfSubTasks={row.original.sub_tasks_count || 0}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
|
||||
columnHelper.accessor('names', {
|
||||
header: 'Assignees',
|
||||
id: COLUMN_KEYS.ASSIGNEES,
|
||||
size: 159,
|
||||
enablePinning: false,
|
||||
cell: ({ row }) => (
|
||||
<Flex align="center" gap={8}>
|
||||
<Avatars
|
||||
key={`${row.original.id}-assignees`}
|
||||
members={row.original.names || []}
|
||||
maxCount={3}
|
||||
/>
|
||||
<Avatar
|
||||
size={28}
|
||||
icon={<PlusOutlined />}
|
||||
className="avatar-add"
|
||||
style={{
|
||||
backgroundColor: '#ffffff',
|
||||
border: '1px dashed #c4c4c4',
|
||||
color: '#000000D9',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
),
|
||||
}),
|
||||
|
||||
columnHelper.accessor('end_date', {
|
||||
header: 'Due Date',
|
||||
id: COLUMN_KEYS.DUE_DATE,
|
||||
size: 149,
|
||||
enablePinning: false,
|
||||
cell: ({ row }) => (
|
||||
<span>
|
||||
<DatePicker
|
||||
key={`${row.original.id}-end-date`}
|
||||
placeholder="Set a due date"
|
||||
suffixIcon={null}
|
||||
variant="borderless"
|
||||
/>
|
||||
</span>
|
||||
),
|
||||
}),
|
||||
|
||||
columnHelper.accessor('due_time', {
|
||||
header: 'Due Time',
|
||||
id: COLUMN_KEYS.DUE_TIME,
|
||||
size: 120,
|
||||
enablePinning: false,
|
||||
cell: ({ row }) => <TaskRowDueTime dueTime={row.original.due_time || ''} />,
|
||||
}),
|
||||
|
||||
columnHelper.accessor('status', {
|
||||
header: 'Status',
|
||||
id: COLUMN_KEYS.STATUS,
|
||||
size: 120,
|
||||
enablePinning: false,
|
||||
cell: ({ row }) => (
|
||||
<StatusDropdown
|
||||
key={`${row.original.id}-status`}
|
||||
statusList={statuses}
|
||||
task={row.original}
|
||||
teamId={getCurrentSession()?.team_id || ''}
|
||||
onChange={statusId => {
|
||||
console.log('Status changed:', statusId);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
|
||||
columnHelper.accessor('labels', {
|
||||
header: 'Labels',
|
||||
id: COLUMN_KEYS.LABELS,
|
||||
size: 225,
|
||||
enablePinning: false,
|
||||
cell: ({ row }) => (
|
||||
<Flex>
|
||||
{row.original.labels?.map(label => (
|
||||
<CustomColorLabel key={`${row.original.id}-${label.id}`} label={label} />
|
||||
))}
|
||||
<LabelsSelector taskId={row.original.id} />
|
||||
</Flex>
|
||||
),
|
||||
}),
|
||||
|
||||
columnHelper.accessor('start_date', {
|
||||
header: 'Start Date',
|
||||
id: COLUMN_KEYS.START_DATE,
|
||||
size: 149,
|
||||
enablePinning: false,
|
||||
cell: ({ row }) => (
|
||||
<span>
|
||||
<DatePicker placeholder="Set a start date" suffixIcon={null} variant="borderless" />
|
||||
</span>
|
||||
),
|
||||
}),
|
||||
|
||||
columnHelper.accessor('priority', {
|
||||
header: 'Priority',
|
||||
id: COLUMN_KEYS.PRIORITY,
|
||||
size: 120,
|
||||
enablePinning: false,
|
||||
cell: ({ row }) => (
|
||||
<span>
|
||||
<Select
|
||||
variant="borderless"
|
||||
options={[
|
||||
{ value: 'high', label: 'High' },
|
||||
{ value: 'medium', label: 'Medium' },
|
||||
{ value: 'low', label: 'Low' },
|
||||
]}
|
||||
/>
|
||||
</span>
|
||||
),
|
||||
}),
|
||||
|
||||
// columnHelper.accessor('time_tracking', {
|
||||
// header: 'Time Tracking',
|
||||
// size: 120,
|
||||
// enablePinning: false,
|
||||
// cell: ({ row }) => (
|
||||
// <TaskRowTimeTracking taskId={row.original.id || null} />
|
||||
// )
|
||||
// })
|
||||
];
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
.table-header {
|
||||
border-bottom: 1px solid #d9d9d9;
|
||||
/* Border below header */
|
||||
}
|
||||
|
||||
.table-body {
|
||||
background-color: #ffffff;
|
||||
/* White background for body */
|
||||
}
|
||||
|
||||
.table-row {
|
||||
display: flex;
|
||||
/* Use flexbox for row layout */
|
||||
align-items: center;
|
||||
/* Center items vertically */
|
||||
transition: background-color 0.2s;
|
||||
/* Smooth background transition */
|
||||
}
|
||||
|
||||
.table-row:hover {
|
||||
background-color: #f5f5f5;
|
||||
/* Light gray background on hover */
|
||||
}
|
||||
|
||||
/* Optional: Add styles for sticky headers */
|
||||
.table-header > div {
|
||||
position: sticky;
|
||||
/* Make header cells sticky */
|
||||
top: 0;
|
||||
/* Stick to the top */
|
||||
z-index: 1;
|
||||
/* Ensure it stays above other content */
|
||||
}
|
||||
|
||||
/* Optional: Add styles for cell borders */
|
||||
.table-row > div {
|
||||
border-right: 1px solid #d9d9d9;
|
||||
/* Right border for cells */
|
||||
}
|
||||
|
||||
.table-row > div:last-child {
|
||||
border-right: none;
|
||||
/* Remove right border for last cell */
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { Checkbox, theme } from 'antd';
|
||||
import {
|
||||
useReactTable,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
flexRender,
|
||||
VisibilityState,
|
||||
Row,
|
||||
Column,
|
||||
} from '@tanstack/react-table';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import React from 'react';
|
||||
import './task-list-custom.css';
|
||||
import TaskListInstantTaskInput from './task-list-instant-task-input/task-list-instant-task-input';
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
import { createColumns } from './task-list-columns/task-list-columns';
|
||||
|
||||
interface TaskListCustomProps {
|
||||
tasks: IProjectTask[];
|
||||
color: string;
|
||||
groupId?: string | null;
|
||||
onTaskSelect?: (taskId: string) => void;
|
||||
}
|
||||
|
||||
const TaskListCustom: React.FC<TaskListCustomProps> = ({ tasks, color, groupId, onTaskSelect }) => {
|
||||
const [rowSelection, setRowSelection] = useState({});
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
||||
const [expandedRows, setExpandedRows] = useState<Record<string, boolean>>({});
|
||||
|
||||
const statuses = useAppSelector(state => state.taskStatusReducer.status);
|
||||
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||
const { token } = theme.useToken();
|
||||
const { getCurrentSession } = useAuthService();
|
||||
|
||||
const handleExpandClick = useCallback((rowId: string) => {
|
||||
setExpandedRows(prev => ({
|
||||
...prev,
|
||||
[rowId]: !prev[rowId],
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleTaskSelect = useCallback(
|
||||
(taskId: string) => {
|
||||
onTaskSelect?.(taskId);
|
||||
},
|
||||
[onTaskSelect]
|
||||
);
|
||||
|
||||
const columns = useMemo(
|
||||
() =>
|
||||
createColumns({
|
||||
expandedRows,
|
||||
statuses,
|
||||
handleTaskSelect,
|
||||
getCurrentSession,
|
||||
}),
|
||||
[expandedRows, statuses, handleTaskSelect, getCurrentSession]
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
data: tasks,
|
||||
columns,
|
||||
state: {
|
||||
rowSelection,
|
||||
columnVisibility,
|
||||
},
|
||||
enableRowSelection: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
});
|
||||
|
||||
const { rows } = table.getRowModel();
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: rows.length,
|
||||
getScrollElement: () => tableContainerRef.current,
|
||||
estimateSize: () => 50,
|
||||
overscan: 20,
|
||||
});
|
||||
|
||||
const virtualRows = rowVirtualizer.getVirtualItems();
|
||||
const totalSize = rowVirtualizer.getTotalSize();
|
||||
const paddingTop = virtualRows.length > 0 ? virtualRows?.[0]?.start || 0 : 0;
|
||||
const paddingBottom =
|
||||
virtualRows.length > 0 ? totalSize - (virtualRows?.[virtualRows.length - 1]?.end || 0) : 0;
|
||||
|
||||
const columnToggleItems = columns.map(column => ({
|
||||
key: column.id as string,
|
||||
label: (
|
||||
<span>
|
||||
<Checkbox checked={table.getColumn(column.id as string)?.getIsVisible()}>
|
||||
{typeof column.header === 'string' ? column.header : column.id}
|
||||
</Checkbox>
|
||||
</span>
|
||||
),
|
||||
onClick: () => {
|
||||
const columnData = table.getColumn(column.id as string);
|
||||
if (columnData) {
|
||||
columnData.toggleVisibility();
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
return (
|
||||
<div
|
||||
className="task-list-custom"
|
||||
style={{
|
||||
maxHeight: '80vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
borderLeft: `4px solid ${color}`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={tableContainerRef}
|
||||
style={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflowX: 'auto',
|
||||
maxHeight: '100%',
|
||||
}}
|
||||
>
|
||||
<div style={{ width: 'fit-content', borderCollapse: 'collapse' }}>
|
||||
<div className="table-header">
|
||||
{table.getHeaderGroups().map(headerGroup => (
|
||||
<div key={headerGroup.id} className="table-row">
|
||||
{headerGroup.headers.map((header, index) => (
|
||||
<div
|
||||
key={header.id}
|
||||
className={`${header.column.getIsPinned() === 'left' ? 'sticky left-0 z-10' : ''}`}
|
||||
style={{
|
||||
width: header.getSize(),
|
||||
position: index < 2 ? 'sticky' : 'relative',
|
||||
left: index === 0 ? 0 : index === 1 ? '47px' : 'auto',
|
||||
background: token.colorBgElevated,
|
||||
zIndex: 1,
|
||||
color: token.colorText,
|
||||
height: '40px',
|
||||
borderTop: `1px solid ${token.colorBorderSecondary}`,
|
||||
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
||||
borderRight: `1px solid ${token.colorBorderSecondary}`,
|
||||
textAlign: index === 0 ? 'right' : 'left',
|
||||
fontWeight: 'normal',
|
||||
padding: '8px 0px 8px 8px',
|
||||
}}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="table-body">
|
||||
{paddingTop > 0 && <div style={{ height: `${paddingTop}px` }} />}
|
||||
{virtualRows.map(virtualRow => {
|
||||
const row = rows[virtualRow.index];
|
||||
return (
|
||||
<React.Fragment key={row.id}>
|
||||
<div
|
||||
className="table-row"
|
||||
style={{
|
||||
'&:hover div': {
|
||||
background: `${token.colorFillAlter} !important`,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{row.getVisibleCells().map((cell, index) => (
|
||||
<div
|
||||
key={cell.id}
|
||||
className={`${cell.column.getIsPinned() === 'left' ? 'sticky left-0 z-10' : ''}`}
|
||||
style={{
|
||||
width: cell.column.getSize(),
|
||||
position: index < 2 ? 'sticky' : 'relative',
|
||||
left: 'auto',
|
||||
background: token.colorBgContainer,
|
||||
color: token.colorText,
|
||||
height: '42px',
|
||||
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
||||
borderRight: `1px solid ${token.colorBorderSecondary}`,
|
||||
padding: '8px 0px 8px 8px',
|
||||
}}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{expandedRows[row.id] &&
|
||||
row.original.sub_tasks?.map(subTask => (
|
||||
<div
|
||||
key={subTask.task_key}
|
||||
className="table-row"
|
||||
style={{
|
||||
'&:hover div': {
|
||||
background: `${token.colorFillAlter} !important`,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{columns.map((col, index) => (
|
||||
<div
|
||||
key={`${subTask.task_key}-${col.id}`}
|
||||
style={{
|
||||
width: col.getSize(),
|
||||
position: index < 2 ? 'sticky' : 'relative',
|
||||
left: index < 2 ? `${index * col.getSize()}px` : 'auto',
|
||||
background: token.colorBgContainer,
|
||||
color: token.colorText,
|
||||
height: '42px',
|
||||
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
||||
borderRight: `1px solid ${token.colorBorderSecondary}`,
|
||||
paddingLeft: index === 3 ? '32px' : '8px',
|
||||
paddingRight: '8px',
|
||||
}}
|
||||
>
|
||||
{flexRender(col.cell, {
|
||||
getValue: () => subTask[col.id as keyof typeof subTask] ?? null,
|
||||
row: { original: subTask } as Row<IProjectTask>,
|
||||
column: col as Column<IProjectTask>,
|
||||
table,
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
{paddingBottom > 0 && <div style={{ height: `${paddingBottom}px` }} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<TaskListInstantTaskInput
|
||||
session={getCurrentSession() || null}
|
||||
groupId={groupId}
|
||||
parentTask={null}
|
||||
/>
|
||||
{/* {selectedCount > 0 && (
|
||||
<Flex
|
||||
justify="space-between"
|
||||
align="center"
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
background: token.colorBgElevated,
|
||||
borderTop: `1px solid ${token.colorBorderSecondary}`,
|
||||
position: 'sticky',
|
||||
bottom: 0,
|
||||
zIndex: 2,
|
||||
}}
|
||||
>
|
||||
<span>{selectedCount} tasks selected</span>
|
||||
<Flex gap={8}>
|
||||
<Button icon={<EditOutlined />}>Edit</Button>
|
||||
<Button danger icon={<DeleteOutlined />}>
|
||||
Delete
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
)} */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskListCustom;
|
||||
@@ -0,0 +1,116 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Button, Dropdown, Input, Menu, Badge, Tooltip } from 'antd';
|
||||
import {
|
||||
RightOutlined,
|
||||
LoadingOutlined,
|
||||
EllipsisOutlined,
|
||||
EditOutlined,
|
||||
RetweetOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { ITaskListGroup } from '@/types/tasks/taskList.types';
|
||||
import { ITaskStatusCategory } from '@/types/status.types';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
// import WorklenzTaskListPhaseDuration from "./WorklenzTaskListPhaseDuration";
|
||||
// import WorklenzTasksProgressBar from "./WorklenzTasksProgressBar";
|
||||
|
||||
interface Props {
|
||||
group: ITaskListGroup;
|
||||
projectId: string | null;
|
||||
categories: ITaskStatusCategory[];
|
||||
}
|
||||
|
||||
const TaskListGroupSettings: React.FC<Props> = ({ group, projectId, categories }) => {
|
||||
const [edit, setEdit] = useState(false);
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
const [isEditColProgress, setIsEditColProgress] = useState(false);
|
||||
const [isGroupByPhases, setIsGroupByPhases] = useState(false);
|
||||
const [isGroupByStatus, setIsGroupByStatus] = useState(false);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
|
||||
const menu = (
|
||||
<Menu>
|
||||
<Menu.Item key="edit">
|
||||
<EditOutlined className="me-2" />
|
||||
Rename
|
||||
</Menu.Item>
|
||||
{isGroupByStatus && (
|
||||
<Menu.SubMenu
|
||||
key="change-category"
|
||||
title={
|
||||
<>
|
||||
<RetweetOutlined className="me-2" />
|
||||
Change category
|
||||
</>
|
||||
}
|
||||
>
|
||||
{categories.map(item => (
|
||||
<Tooltip key={item.id} title={item.description || ''} placement="right">
|
||||
<Menu.Item
|
||||
style={{
|
||||
fontWeight: item.id === group.category_id ? 'bold' : undefined,
|
||||
}}
|
||||
>
|
||||
<Badge color={item.color_code} text={item.name || ''} />
|
||||
</Menu.Item>
|
||||
</Tooltip>
|
||||
))}
|
||||
</Menu.SubMenu>
|
||||
)}
|
||||
</Menu>
|
||||
);
|
||||
|
||||
const onBlurEditColumn = (group: ITaskListGroup) => {
|
||||
setEdit(false);
|
||||
};
|
||||
|
||||
const onToggleClick = () => {
|
||||
console.log('onToggleClick');
|
||||
};
|
||||
|
||||
const canDisplayActions = () => {
|
||||
return true;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="d-flex justify-content-between align-items-center position-relative">
|
||||
<div className="d-flex align-items-center">
|
||||
<Button
|
||||
className={`collapse btn border-0 ${group.tasks.length ? 'active' : ''}`}
|
||||
onClick={onToggleClick}
|
||||
style={{ backgroundColor: group.color_code }}
|
||||
>
|
||||
<RightOutlined className="collapse-icon" />
|
||||
{`${group.name} (${group.tasks.length})`}
|
||||
</Button>
|
||||
|
||||
{canDisplayActions() && (
|
||||
<Dropdown
|
||||
overlay={menu}
|
||||
trigger={['click']}
|
||||
onVisibleChange={visible => setShowMenu(visible)}
|
||||
>
|
||||
<Button className="p-0" type="text">
|
||||
<EllipsisOutlined />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* {isGroupByPhases && group.name !== "Unmapped" && (
|
||||
<div className="d-flex align-items-center me-2 ms-auto">
|
||||
<WorklenzTaskListPhaseDuration group={group} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isProgressBarAvailable() && (
|
||||
<WorklenzTasksProgressBar
|
||||
todoProgress={group.todo_progress}
|
||||
doingProgress={group.doing_progress}
|
||||
doneProgress={group.done_progress}
|
||||
/>
|
||||
)} */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskListGroupSettings;
|
||||
@@ -0,0 +1,160 @@
|
||||
import { Input, InputRef, theme } from 'antd';
|
||||
import React, { useState, useMemo, useRef } from 'react';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { colors } from '@/styles/colors';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ILocalSession } from '@/types/auth/local-session.types';
|
||||
import { ITaskCreateRequest } from '@/types/tasks/task-create-request.types';
|
||||
import {
|
||||
addTask,
|
||||
getCurrentGroup,
|
||||
GROUP_BY_PHASE_VALUE,
|
||||
GROUP_BY_PRIORITY_VALUE,
|
||||
GROUP_BY_STATUS_VALUE,
|
||||
} from '@/features/tasks/tasks.slice';
|
||||
import { useSocket } from '@/socket/socketContext';
|
||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||
import { SocketEvents } from '@/shared/socket-events';
|
||||
import { DRAWER_ANIMATION_INTERVAL } from '@/shared/constants';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
|
||||
interface ITaskListInstantTaskInputProps {
|
||||
session: ILocalSession | null;
|
||||
groupId?: string | null;
|
||||
parentTask?: string | null;
|
||||
}
|
||||
interface IAddNewTask extends IProjectTask {
|
||||
groupId: string;
|
||||
}
|
||||
|
||||
const TaskListInstantTaskInput = ({
|
||||
session,
|
||||
groupId = null,
|
||||
parentTask = null,
|
||||
}: ITaskListInstantTaskInputProps) => {
|
||||
const [isEdit, setIsEdit] = useState<boolean>(false);
|
||||
const [taskName, setTaskName] = useState<string>('');
|
||||
const [creatingTask, setCreatingTask] = useState<boolean>(false);
|
||||
const taskInputRef = useRef<InputRef>(null);
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const { socket } = useSocket();
|
||||
const { token } = theme.useToken();
|
||||
|
||||
const { t } = useTranslation('task-list-table');
|
||||
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
const customBorderColor = useMemo(() => themeMode === 'dark' && ' border-[#303030]', [themeMode]);
|
||||
const projectId = useAppSelector(state => state.projectReducer.projectId);
|
||||
|
||||
const createRequestBody = (): ITaskCreateRequest | null => {
|
||||
if (!projectId || !session) return null;
|
||||
const body: ITaskCreateRequest = {
|
||||
project_id: projectId,
|
||||
name: taskName,
|
||||
reporter_id: session.id,
|
||||
team_id: session.team_id,
|
||||
};
|
||||
|
||||
const groupBy = getCurrentGroup();
|
||||
if (groupBy.value === GROUP_BY_STATUS_VALUE) {
|
||||
body.status_id = groupId || undefined;
|
||||
} else if (groupBy.value === GROUP_BY_PRIORITY_VALUE) {
|
||||
body.priority_id = groupId || undefined;
|
||||
} else if (groupBy.value === GROUP_BY_PHASE_VALUE) {
|
||||
body.phase_id = groupId || undefined;
|
||||
}
|
||||
|
||||
if (parentTask) {
|
||||
body.parent_task_id = parentTask;
|
||||
}
|
||||
console.log('createRequestBody', body);
|
||||
|
||||
return body;
|
||||
};
|
||||
|
||||
const reset = (scroll = true) => {
|
||||
setIsEdit(false);
|
||||
|
||||
setCreatingTask(false);
|
||||
|
||||
setTaskName('');
|
||||
setIsEdit(true);
|
||||
|
||||
setTimeout(() => {
|
||||
taskInputRef.current?.focus();
|
||||
if (scroll) window.scrollTo(0, document.body.scrollHeight);
|
||||
}, DRAWER_ANIMATION_INTERVAL); // wait for the animation end
|
||||
};
|
||||
|
||||
const onNewTaskReceived = (task: IAddNewTask) => {
|
||||
if (!groupId) return;
|
||||
console.log('onNewTaskReceived', task);
|
||||
task.groupId = groupId;
|
||||
if (groupId && task.id) {
|
||||
dispatch(addTask(task));
|
||||
reset(false);
|
||||
// if (this.map.has(task.id)) return;
|
||||
|
||||
// this.service.addTask(task, this.groupId);
|
||||
// this.reset(false);
|
||||
}
|
||||
};
|
||||
|
||||
const addInstantTask = () => {
|
||||
if (creatingTask) return;
|
||||
console.log('addInstantTask', projectId, taskName.trim());
|
||||
if (!projectId || !session || taskName.trim() === '') return;
|
||||
|
||||
try {
|
||||
setCreatingTask(true);
|
||||
const body = createRequestBody();
|
||||
if (!body) return;
|
||||
socket?.emit(SocketEvents.QUICK_TASK.toString(), JSON.stringify(body));
|
||||
socket?.once(SocketEvents.QUICK_TASK.toString(), (task: IProjectTask) => {
|
||||
setCreatingTask(false);
|
||||
if (task.parent_task_id) {
|
||||
}
|
||||
onNewTaskReceived(task as IAddNewTask);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
setCreatingTask(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddTask = () => {
|
||||
setIsEdit(false);
|
||||
addInstantTask();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`border-t border-b-[1px] border-r-[1px]`}
|
||||
style={{ borderColor: token.colorBorderSecondary }}
|
||||
>
|
||||
{isEdit ? (
|
||||
<Input
|
||||
className="w-full rounded-none"
|
||||
style={{ borderColor: colors.skyBlue, height: '40px' }}
|
||||
placeholder={t('addTaskInputPlaceholder')}
|
||||
onChange={e => setTaskName(e.target.value)}
|
||||
onBlur={handleAddTask}
|
||||
onPressEnter={handleAddTask}
|
||||
ref={taskInputRef}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
onFocus={() => setIsEdit(true)}
|
||||
className="w-[300px] border-none"
|
||||
style={{ height: '34px' }}
|
||||
value={t('addTaskText')}
|
||||
ref={taskInputRef}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskListInstantTaskInput;
|
||||
@@ -0,0 +1,471 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Avatar, Checkbox, DatePicker, Flex, Tag, Tooltip, Typography } from 'antd';
|
||||
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { columnList } from '@/pages/projects/project-view-1/taskList/taskListTable/columns/columnList';
|
||||
import AddTaskListRow from '@/pages/projects/project-view-1/taskList/taskListTable/taskListTableRows/AddTaskListRow';
|
||||
|
||||
import CustomAvatar from '@/components/CustomAvatar';
|
||||
import LabelsSelector from '@components/task-list-common/labelsSelector/labels-selector';
|
||||
import { useSelectedProject } from '@/hooks/useSelectedProject';
|
||||
import StatusDropdown from '@/components/task-list-common/status-dropdown/status-dropdown';
|
||||
import PriorityDropdown from '@/components/task-list-common/priorityDropdown/priority-dropdown';
|
||||
import { simpleDateFormat } from '@/utils/simpleDateFormat';
|
||||
import { durationDateFormat } from '@/utils/durationDateFormat';
|
||||
import CustomColorLabel from '@components/task-list-common/labelsSelector/custom-color-label';
|
||||
import CustomNumberLabel from '@components/task-list-common/labelsSelector/custom-number-label';
|
||||
import PhaseDropdown from '@components/task-list-common/phaseDropdown/PhaseDropdown';
|
||||
import AssigneeSelector from '@components/task-list-common/assigneeSelector/AssigneeSelector';
|
||||
import TaskCell from '@/pages/projects/project-view-1/taskList/taskListTable/taskListTableCells/TaskCell';
|
||||
import AddSubTaskListRow from '@/pages/projects/project-view-1/taskList/taskListTable/taskListTableRows/AddSubTaskListRow';
|
||||
import { colors } from '@/styles/colors';
|
||||
import TimeTracker from '@/pages/projects/project-view-1/taskList/taskListTable/taskListTableCells/TimeTracker';
|
||||
import TaskContextMenu from '@/pages/projects/project-view-1/taskList/taskListTable/contextMenu/TaskContextMenu';
|
||||
import TaskProgress from '@/pages/projects/project-view-1/taskList/taskListTable/taskListTableCells/TaskProgress';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { deselectAll } from '@/features/projects/bulkActions/bulkActionSlice';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||
import { ITaskListGroup } from '@/types/tasks/taskList.types';
|
||||
import Avatars from '@/components/avatars/avatars';
|
||||
|
||||
const TaskListTable = ({
|
||||
taskList,
|
||||
tableId,
|
||||
}: {
|
||||
taskList: ITaskListGroup;
|
||||
tableId: string | undefined;
|
||||
}) => {
|
||||
// these states manage the necessary states
|
||||
const [hoverRow, setHoverRow] = useState<string | null>(null);
|
||||
const [selectedRows, setSelectedRows] = useState<string[]>([]);
|
||||
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
|
||||
const [expandedTasks, setExpandedTasks] = useState<string[]>([]);
|
||||
const [isSelectAll, setIsSelectAll] = useState(false);
|
||||
// context menu state
|
||||
const [contextMenuVisible, setContextMenuVisible] = useState(false);
|
||||
const [contextMenuPosition, setContextMenuPosition] = useState({
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
// state to check scroll
|
||||
const [scrollingTables, setScrollingTables] = useState<{
|
||||
[key: string]: boolean;
|
||||
}>({});
|
||||
|
||||
// localization
|
||||
const { t } = useTranslation('task-list-table');
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
// get data theme data from redux
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
|
||||
// get the selected project details
|
||||
const selectedProject = useSelectedProject();
|
||||
|
||||
// get columns list details
|
||||
const columnsVisibility = useAppSelector(
|
||||
state => state.projectViewTaskListColumnsReducer.columnsVisibility
|
||||
);
|
||||
const visibleColumns = columnList.filter(
|
||||
column => columnsVisibility[column.key as keyof typeof columnsVisibility]
|
||||
);
|
||||
|
||||
// toggle subtasks visibility
|
||||
const toggleTaskExpansion = (taskId: string) => {
|
||||
setExpandedTasks(prev =>
|
||||
prev.includes(taskId) ? prev.filter(id => id !== taskId) : [...prev, taskId]
|
||||
);
|
||||
};
|
||||
|
||||
// toggle all task select when header checkbox click
|
||||
const toggleSelectAll = () => {
|
||||
if (isSelectAll) {
|
||||
setSelectedRows([]);
|
||||
dispatch(deselectAll());
|
||||
} else {
|
||||
// const allTaskIds =
|
||||
// task-list?.flatMap((task) => [
|
||||
// task.taskId,
|
||||
// ...(task.subTasks?.map((subtask) => subtask.taskId) || []),
|
||||
// ]) || [];
|
||||
// setSelectedRows(allTaskIds);
|
||||
// dispatch(selectTaskIds(allTaskIds));
|
||||
// console.log('selected tasks and subtasks (all):', allTaskIds);
|
||||
}
|
||||
setIsSelectAll(!isSelectAll);
|
||||
};
|
||||
|
||||
// toggle selected row
|
||||
const toggleRowSelection = (task: IProjectTask) => {
|
||||
setSelectedRows(prevSelectedRows =>
|
||||
prevSelectedRows.includes(task.id || '')
|
||||
? prevSelectedRows.filter(id => id !== task.id)
|
||||
: [...prevSelectedRows, task.id || '']
|
||||
);
|
||||
};
|
||||
|
||||
// this use effect for realtime update the selected rows
|
||||
useEffect(() => {
|
||||
console.log('Selected tasks and subtasks:', selectedRows);
|
||||
}, [selectedRows]);
|
||||
|
||||
// select one row this triggers only in handle the context menu ==> righ click mouse event
|
||||
const selectOneRow = (task: IProjectTask) => {
|
||||
setSelectedRows([task.id || '']);
|
||||
|
||||
// log the task object when selected
|
||||
if (!selectedRows.includes(task.id || '')) {
|
||||
console.log('Selected task:', task);
|
||||
}
|
||||
};
|
||||
|
||||
// handle custom task context menu
|
||||
const handleContextMenu = (e: React.MouseEvent, task: IProjectTask) => {
|
||||
e.preventDefault();
|
||||
setSelectedTaskId(task.id || '');
|
||||
selectOneRow(task);
|
||||
setContextMenuPosition({ x: e.clientX, y: e.clientY });
|
||||
setContextMenuVisible(true);
|
||||
};
|
||||
|
||||
// trigger the table scrolling
|
||||
useEffect(() => {
|
||||
const tableContainer = document.querySelector(`.tasklist-container-${tableId}`);
|
||||
const handleScroll = () => {
|
||||
if (tableContainer) {
|
||||
setScrollingTables(prev => ({
|
||||
...prev,
|
||||
[tableId]: tableContainer.scrollLeft > 0,
|
||||
}));
|
||||
}
|
||||
};
|
||||
tableContainer?.addEventListener('scroll', handleScroll);
|
||||
return () => tableContainer?.removeEventListener('scroll', handleScroll);
|
||||
}, [tableId]);
|
||||
|
||||
// layout styles for table and the columns
|
||||
const customBorderColor = themeMode === 'dark' && ' border-[#303030]';
|
||||
|
||||
const customHeaderColumnStyles = (key: string) =>
|
||||
`border px-2 text-left ${key === 'selector' && 'sticky left-0 z-10'} ${key === 'task' && `sticky left-[33px] z-10 after:content after:absolute after:top-0 after:-right-1 after:-z-10 after:h-[42px] after:w-1.5 after:bg-transparent ${scrollingTables[tableId] ? 'after:bg-gradient-to-r after:from-[rgba(0,0,0,0.12)] after:to-transparent' : ''}`} ${themeMode === 'dark' ? 'bg-[#1d1d1d] border-[#303030]' : 'bg-[#fafafa]'}`;
|
||||
|
||||
const customBodyColumnStyles = (key: string) =>
|
||||
`border px-2 ${key === 'selector' && 'sticky left-0 z-10'} ${key === 'task' && `sticky left-[33px] z-10 after:content after:absolute after:top-0 after:-right-1 after:-z-10 after:min-h-[40px] after:w-1.5 after:bg-transparent ${scrollingTables[tableId] ? 'after:bg-gradient-to-r after:from-[rgba(0,0,0,0.12)] after:to-transparent' : ''}`} ${themeMode === 'dark' ? 'bg-[#141414] border-[#303030]' : 'bg-white'}`;
|
||||
|
||||
// function to render the column content based on column key
|
||||
const renderColumnContent = (
|
||||
columnKey: string,
|
||||
task: IProjectTask,
|
||||
isSubtask: boolean = false
|
||||
) => {
|
||||
switch (columnKey) {
|
||||
// task ID column
|
||||
case 'taskId':
|
||||
return (
|
||||
<Tooltip title={task.task_key || ''} className="flex justify-center">
|
||||
<Tag>{task.task_key || ''}</Tag>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
// task name column
|
||||
case 'task':
|
||||
return (
|
||||
// custom task cell component
|
||||
<TaskCell
|
||||
task={task}
|
||||
isSubTask={isSubtask}
|
||||
expandedTasks={expandedTasks}
|
||||
hoverRow={hoverRow}
|
||||
setSelectedTaskId={setSelectedTaskId}
|
||||
toggleTaskExpansion={toggleTaskExpansion}
|
||||
/>
|
||||
);
|
||||
|
||||
// description column
|
||||
case 'description':
|
||||
return <Typography.Text style={{ width: 200 }}></Typography.Text>;
|
||||
|
||||
// progress column
|
||||
case 'progress': {
|
||||
return task?.progress || task?.progress === 0 ? (
|
||||
<TaskProgress progress={task?.progress} numberOfSubTasks={task?.sub_tasks?.length || 0} />
|
||||
) : (
|
||||
<div></div>
|
||||
);
|
||||
}
|
||||
|
||||
// members column
|
||||
case 'members':
|
||||
return (
|
||||
<Flex gap={4} align="center">
|
||||
<Avatars members={task.names || []} />
|
||||
{/* <Avatar.Group>
|
||||
{task.assignees?.map(member => (
|
||||
<CustomAvatar key={member.id} avatarName={member.name} size={26} />
|
||||
))}
|
||||
</Avatar.Group> */}
|
||||
<AssigneeSelector taskId={selectedTaskId || '0'} />
|
||||
</Flex>
|
||||
);
|
||||
|
||||
// labels column
|
||||
case 'labels':
|
||||
return (
|
||||
<Flex>
|
||||
{task?.labels && task?.labels?.length <= 2 ? (
|
||||
task?.labels?.map(label => <CustomColorLabel label={label} />)
|
||||
) : (
|
||||
<Flex>
|
||||
<CustomColorLabel label={task?.labels ? task.labels[0] : null} />
|
||||
<CustomColorLabel label={task?.labels ? task.labels[1] : null} />
|
||||
{/* this component show other label names */}
|
||||
<CustomNumberLabel
|
||||
// this label list get the labels without 1, 2 elements
|
||||
labelList={task?.labels ? task.labels : null}
|
||||
/>
|
||||
</Flex>
|
||||
)}
|
||||
<LabelsSelector taskId={task.id} />
|
||||
</Flex>
|
||||
);
|
||||
|
||||
// phase column
|
||||
case 'phases':
|
||||
return <PhaseDropdown projectId={selectedProject?.id || ''} />;
|
||||
|
||||
// status column
|
||||
case 'status':
|
||||
return <StatusDropdown currentStatus={task.status || ''} />;
|
||||
|
||||
// priority column
|
||||
case 'priority':
|
||||
return <PriorityDropdown currentPriority={task.priority || ''} />;
|
||||
|
||||
// time tracking column
|
||||
case 'timeTracking':
|
||||
return <TimeTracker taskId={task.id} initialTime={task.timer_start_time || 0} />;
|
||||
|
||||
// estimation column
|
||||
case 'estimation':
|
||||
return <Typography.Text>0h 0m</Typography.Text>;
|
||||
|
||||
// start date column
|
||||
case 'startDate':
|
||||
return task.start_date ? (
|
||||
<Typography.Text>{simpleDateFormat(task.start_date)}</Typography.Text>
|
||||
) : (
|
||||
<DatePicker
|
||||
placeholder="Set a start date"
|
||||
suffixIcon={null}
|
||||
style={{ border: 'none', width: '100%', height: '100%' }}
|
||||
/>
|
||||
);
|
||||
|
||||
// due date column
|
||||
case 'dueDate':
|
||||
return task.end_date ? (
|
||||
<Typography.Text>{simpleDateFormat(task.end_date)}</Typography.Text>
|
||||
) : (
|
||||
<DatePicker
|
||||
placeholder="Set a due date"
|
||||
suffixIcon={null}
|
||||
style={{ border: 'none', width: '100%', height: '100%' }}
|
||||
/>
|
||||
);
|
||||
|
||||
// completed date column
|
||||
case 'completedDate':
|
||||
return <Typography.Text>{durationDateFormat(task.completed_at || null)}</Typography.Text>;
|
||||
|
||||
// created date column
|
||||
case 'createdDate':
|
||||
return <Typography.Text>{durationDateFormat(task.created_at || null)}</Typography.Text>;
|
||||
|
||||
// last updated column
|
||||
case 'lastUpdated':
|
||||
return <Typography.Text>{durationDateFormat(task.updated_at || null)}</Typography.Text>;
|
||||
|
||||
// recorder column
|
||||
case 'reporter':
|
||||
return <Typography.Text>{task.reporter}</Typography.Text>;
|
||||
|
||||
// default case for unsupported columns
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`border-x border-b ${customBorderColor}`}>
|
||||
<div className={`tasklist-container-${tableId} min-h-0 max-w-full overflow-x-auto`}>
|
||||
<table className={`rounded-2 w-full min-w-max border-collapse`}>
|
||||
<thead className="h-[42px]">
|
||||
<tr>
|
||||
{/* this cell render the select all task checkbox */}
|
||||
<th
|
||||
key={'selector'}
|
||||
className={`${customHeaderColumnStyles('selector')}`}
|
||||
style={{ width: 20, fontWeight: 500 }}
|
||||
>
|
||||
<Checkbox checked={isSelectAll} onChange={toggleSelectAll} />
|
||||
</th>
|
||||
{/* other header cells */}
|
||||
{visibleColumns.map(column => (
|
||||
<th
|
||||
key={column.key}
|
||||
className={`${customHeaderColumnStyles(column.key)}`}
|
||||
style={{ width: column.width, fontWeight: 500 }}
|
||||
>
|
||||
{column.key === 'phases'
|
||||
? column.columnHeader
|
||||
: t(`${column.columnHeader}Column`)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{taskList?.tasks?.map(task => (
|
||||
<React.Fragment key={task.id}>
|
||||
<tr
|
||||
key={task.id}
|
||||
onContextMenu={e => handleContextMenu(e, task)}
|
||||
className={`${taskList.tasks.length === 0 ? 'h-0' : 'h-[42px]'}`}
|
||||
>
|
||||
{/* this cell render the select the related task checkbox */}
|
||||
<td
|
||||
key={'selector'}
|
||||
className={customBodyColumnStyles('selector')}
|
||||
style={{
|
||||
width: 20,
|
||||
backgroundColor: selectedRows.includes(task.id || '')
|
||||
? themeMode === 'dark'
|
||||
? colors.skyBlue
|
||||
: '#dceeff'
|
||||
: hoverRow === task.id
|
||||
? themeMode === 'dark'
|
||||
? '#000'
|
||||
: '#f8f7f9'
|
||||
: themeMode === 'dark'
|
||||
? '#181818'
|
||||
: '#fff',
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedRows.includes(task.id || '')}
|
||||
onChange={() => toggleRowSelection(task)}
|
||||
/>
|
||||
</td>
|
||||
{/* other cells */}
|
||||
{visibleColumns.map(column => (
|
||||
<td
|
||||
key={column.key}
|
||||
className={customBodyColumnStyles(column.key)}
|
||||
style={{
|
||||
width: column.width,
|
||||
backgroundColor: selectedRows.includes(task.id || '')
|
||||
? themeMode === 'dark'
|
||||
? '#000'
|
||||
: '#dceeff'
|
||||
: hoverRow === task.id
|
||||
? themeMode === 'dark'
|
||||
? '#000'
|
||||
: '#f8f7f9'
|
||||
: themeMode === 'dark'
|
||||
? '#181818'
|
||||
: '#fff',
|
||||
}}
|
||||
>
|
||||
{renderColumnContent(column.key, task)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
|
||||
{/* this is for sub tasks */}
|
||||
{expandedTasks.includes(task.id || '') &&
|
||||
task?.sub_tasks?.map(subtask => (
|
||||
<tr
|
||||
key={subtask.id}
|
||||
onContextMenu={e => handleContextMenu(e, subtask)}
|
||||
className={`${taskList.tasks.length === 0 ? 'h-0' : 'h-[42px]'}`}
|
||||
>
|
||||
{/* this cell render the select the related task checkbox */}
|
||||
<td
|
||||
key={'selector'}
|
||||
className={customBodyColumnStyles('selector')}
|
||||
style={{
|
||||
width: 20,
|
||||
backgroundColor: selectedRows.includes(subtask.id || '')
|
||||
? themeMode === 'dark'
|
||||
? colors.skyBlue
|
||||
: '#dceeff'
|
||||
: hoverRow === subtask.id
|
||||
? themeMode === 'dark'
|
||||
? '#000'
|
||||
: '#f8f7f9'
|
||||
: themeMode === 'dark'
|
||||
? '#181818'
|
||||
: '#fff',
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedRows.includes(subtask.id || '')}
|
||||
onChange={() => toggleRowSelection(subtask)}
|
||||
/>
|
||||
</td>
|
||||
|
||||
{/* other sub tasks cells */}
|
||||
{visibleColumns.map(column => (
|
||||
<td
|
||||
key={column.key}
|
||||
className={customBodyColumnStyles(column.key)}
|
||||
style={{
|
||||
width: column.width,
|
||||
backgroundColor: selectedRows.includes(subtask.id || '')
|
||||
? themeMode === 'dark'
|
||||
? '#000'
|
||||
: '#dceeff'
|
||||
: hoverRow === subtask.id || ''
|
||||
? themeMode === 'dark'
|
||||
? '#000'
|
||||
: '#f8f7f9'
|
||||
: themeMode === 'dark'
|
||||
? '#181818'
|
||||
: '#fff',
|
||||
}}
|
||||
>
|
||||
{renderColumnContent(column.key, subtask, true)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
|
||||
{expandedTasks.includes(task.id || '') && (
|
||||
<tr>
|
||||
<td colSpan={visibleColumns.length}>
|
||||
<AddSubTaskListRow />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* add a main task to the table */}
|
||||
<AddTaskListRow />
|
||||
|
||||
{/* custom task context menu */}
|
||||
<TaskContextMenu
|
||||
visible={contextMenuVisible}
|
||||
position={contextMenuPosition}
|
||||
selectedTask={selectedRows[0]}
|
||||
onClose={() => setContextMenuVisible(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskListTable;
|
||||
@@ -0,0 +1,19 @@
|
||||
.tasks-table {
|
||||
width: max-content;
|
||||
margin-left: 3px;
|
||||
border-right: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.flex-table {
|
||||
display: flex;
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.position-relative {
|
||||
position: relative;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/* custom collapse styles for content box and the left border */
|
||||
.ant-collapse-header {
|
||||
margin-bottom: 6px !important;
|
||||
}
|
||||
|
||||
.custom-collapse-content-box .ant-collapse-content-box {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
:where(.css-dev-only-do-not-override-1w6wsvq).ant-collapse-ghost
|
||||
> .ant-collapse-item
|
||||
> .ant-collapse-content
|
||||
> .ant-collapse-content-box {
|
||||
padding: 0;
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { Badge, Button, Collapse, ConfigProvider, Dropdown, Flex, Input, Typography } from 'antd';
|
||||
import { useState } from 'react';
|
||||
import { TaskType } from '@/types/task.types';
|
||||
import { EditOutlined, EllipsisOutlined, RetweetOutlined, RightOutlined } from '@ant-design/icons';
|
||||
import { colors } from '@/styles/colors';
|
||||
import './task-list-table-wrapper.css';
|
||||
import TaskListTable from '../task-list-table-old/task-list-table-old';
|
||||
import { MenuProps } from 'antd/lib';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ITaskListGroup } from '@/types/tasks/taskList.types';
|
||||
import TaskListCustom from '../task-list-custom';
|
||||
|
||||
type TaskListTableWrapperProps = {
|
||||
taskList: ITaskListGroup;
|
||||
groupId: string | undefined;
|
||||
name: string | undefined;
|
||||
color: string | undefined;
|
||||
onRename?: (name: string) => void;
|
||||
onStatusCategoryChange?: (category: string) => void;
|
||||
};
|
||||
|
||||
const TaskListTableWrapper = ({
|
||||
taskList,
|
||||
groupId,
|
||||
name,
|
||||
color,
|
||||
onRename,
|
||||
onStatusCategoryChange,
|
||||
}: TaskListTableWrapperProps) => {
|
||||
const [tableName, setTableName] = useState<string>(name || '');
|
||||
const [isRenaming, setIsRenaming] = useState<boolean>(false);
|
||||
const [isExpanded, setIsExpanded] = useState<boolean>(true);
|
||||
|
||||
const type = 'status';
|
||||
|
||||
// localization
|
||||
const { t } = useTranslation('task-list-table');
|
||||
|
||||
// function to handle toggle expand
|
||||
const handlToggleExpand = () => {
|
||||
setIsExpanded(!isExpanded);
|
||||
};
|
||||
|
||||
// these codes only for status type tables
|
||||
// function to handle rename this functionality only available for status type tables
|
||||
const handleRename = () => {
|
||||
if (onRename) {
|
||||
onRename(tableName);
|
||||
}
|
||||
setIsRenaming(false);
|
||||
};
|
||||
|
||||
// function to handle category change
|
||||
const handleCategoryChange = (category: string) => {
|
||||
if (onStatusCategoryChange) {
|
||||
onStatusCategoryChange(category);
|
||||
}
|
||||
};
|
||||
|
||||
// find the available status for the currently active project
|
||||
const statusList = useAppSelector(state => state.statusReducer.status);
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'todo':
|
||||
return '#d8d7d8';
|
||||
case 'doing':
|
||||
return '#c0d5f6';
|
||||
case 'done':
|
||||
return '#c2e4d0';
|
||||
default:
|
||||
return '#d8d7d8';
|
||||
}
|
||||
};
|
||||
|
||||
// dropdown options
|
||||
const items: MenuProps['items'] = [
|
||||
{
|
||||
key: '1',
|
||||
icon: <EditOutlined />,
|
||||
label: 'Rename',
|
||||
onClick: () => setIsRenaming(true),
|
||||
},
|
||||
{
|
||||
key: '2',
|
||||
icon: <RetweetOutlined />,
|
||||
label: 'Change category',
|
||||
children: statusList?.map(status => ({
|
||||
key: status.id,
|
||||
label: (
|
||||
<Flex gap={8} onClick={() => handleCategoryChange(status.category)}>
|
||||
<Badge color={getStatusColor(status.category)} />
|
||||
{status.name}
|
||||
</Flex>
|
||||
),
|
||||
})),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<ConfigProvider
|
||||
wave={{ disabled: true }}
|
||||
theme={{
|
||||
components: {
|
||||
Collapse: {
|
||||
headerPadding: 0,
|
||||
contentPadding: 0,
|
||||
},
|
||||
|
||||
Select: {
|
||||
colorBorder: colors.transparent,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Flex vertical>
|
||||
<Flex style={{ transform: 'translateY(6px)' }}>
|
||||
<Button
|
||||
className="custom-collapse-button"
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
border: 'none',
|
||||
borderBottomLeftRadius: isExpanded ? 0 : 4,
|
||||
borderBottomRightRadius: isExpanded ? 0 : 4,
|
||||
color: colors.darkGray,
|
||||
}}
|
||||
icon={<RightOutlined rotate={isExpanded ? 90 : 0} />}
|
||||
onClick={handlToggleExpand}
|
||||
>
|
||||
{isRenaming ? (
|
||||
<Input
|
||||
size="small"
|
||||
value={tableName}
|
||||
onChange={e => setTableName(e.target.value)}
|
||||
onBlur={handleRename}
|
||||
onPressEnter={handleRename}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<Typography.Text
|
||||
style={{
|
||||
fontSize: 14,
|
||||
color: colors.darkGray,
|
||||
}}
|
||||
>
|
||||
{['todo', 'doing', 'done', 'low', 'medium', 'high'].includes(
|
||||
tableName.replace(/\s+/g, '').toLowerCase()
|
||||
)
|
||||
? t(`${tableName.replace(/\s+/g, '').toLowerCase()}SelectorText`)
|
||||
: tableName}{' '}
|
||||
({taskList.tasks.length})
|
||||
</Typography.Text>
|
||||
)}
|
||||
</Button>
|
||||
{type === 'status' && !isRenaming && (
|
||||
<Dropdown menu={{ items }}>
|
||||
<Button icon={<EllipsisOutlined />} className="borderless-icon-btn" />
|
||||
</Dropdown>
|
||||
)}
|
||||
</Flex>
|
||||
<Collapse
|
||||
collapsible="header"
|
||||
className="border-l-[4px]"
|
||||
bordered={false}
|
||||
ghost={true}
|
||||
expandIcon={() => null}
|
||||
activeKey={isExpanded ? groupId || '1' : undefined}
|
||||
onChange={handlToggleExpand}
|
||||
items={[
|
||||
{
|
||||
key: groupId || '1',
|
||||
className: `custom-collapse-content-box relative after:content after:absolute after:h-full after:w-1 ${color} after:z-10 after:top-0 after:left-0`,
|
||||
children: (
|
||||
<TaskListCustom
|
||||
key={groupId}
|
||||
groupId={groupId}
|
||||
tasks={taskList.tasks}
|
||||
color={color || ''}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Flex>
|
||||
</ConfigProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskListTableWrapper;
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Flex, Skeleton } from 'antd';
|
||||
import TaskListFilters from '@/pages/projects/project-view-1/taskList/taskListFilters/TaskListFilters';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { ITaskListConfigV2, ITaskListGroup } from '@/types/tasks/taskList.types';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { fetchTaskGroups } from '@/features/tasks/taskSlice';
|
||||
import { fetchStatusesCategories } from '@/features/taskAttributes/taskStatusSlice';
|
||||
|
||||
import { columnList } from '@/pages/projects/project-view-1/taskList/taskListTable/columns/columnList';
|
||||
import StatusGroupTables from '../taskList/statusTables/StatusGroupTables';
|
||||
|
||||
const TaskList = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const { taskGroups, loadingGroups } = useAppSelector(state => state.taskReducer);
|
||||
const { statusCategories } = useAppSelector(state => state.taskStatusReducer);
|
||||
const projectId = useAppSelector(state => state.projectReducer.projectId);
|
||||
|
||||
const columnsVisibility = useAppSelector(
|
||||
state => state.projectViewTaskListColumnsReducer.columnsVisibility
|
||||
);
|
||||
|
||||
const visibleColumns = columnList.filter(
|
||||
column => columnsVisibility[column.key as keyof typeof columnsVisibility]
|
||||
);
|
||||
|
||||
const onTaskSelect = (taskId: string) => {
|
||||
console.log('taskId:', taskId);
|
||||
};
|
||||
|
||||
const onTaskExpand = (taskId: string) => {
|
||||
console.log('taskId:', taskId);
|
||||
};
|
||||
useEffect(() => {
|
||||
if (projectId) {
|
||||
const config: ITaskListConfigV2 = {
|
||||
id: projectId,
|
||||
field: 'id',
|
||||
order: 'desc',
|
||||
search: '',
|
||||
statuses: '',
|
||||
members: '',
|
||||
projects: '',
|
||||
isSubtasksInclude: true,
|
||||
};
|
||||
dispatch(fetchTaskGroups(config));
|
||||
}
|
||||
if (!statusCategories.length) {
|
||||
dispatch(fetchStatusesCategories());
|
||||
}
|
||||
}, [dispatch, projectId]);
|
||||
|
||||
return (
|
||||
<Flex vertical gap={16}>
|
||||
<TaskListFilters position="list" />
|
||||
<Skeleton active loading={loadingGroups}>
|
||||
{/* {taskGroups.map((group: ITaskListGroup) => (
|
||||
|
||||
))} */}
|
||||
</Skeleton>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskList;
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Flex } from 'antd';
|
||||
import TaskListFilters from './taskListFilters/TaskListFilters';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { fetchStatusesCategories } from '@/features/taskAttributes/taskStatusSlice';
|
||||
import { fetchTaskGroups } from '@/features/tasks/tasks.slice';
|
||||
import { ITaskListConfigV2 } from '@/types/tasks/taskList.types';
|
||||
import TanStackTable from '../task-list/task-list-custom';
|
||||
import TaskListCustom from '../task-list/task-list-custom';
|
||||
import TaskListTableWrapper from '../task-list/task-list-table-wrapper/task-list-table-wrapper';
|
||||
|
||||
const ProjectViewTaskList = () => {
|
||||
// sample data from task reducer
|
||||
const dispatch = useAppDispatch();
|
||||
const { taskGroups, loadingGroups } = useAppSelector(state => state.taskReducer);
|
||||
const { statusCategories } = useAppSelector(state => state.taskStatusReducer);
|
||||
const projectId = useAppSelector(state => state.projectReducer.projectId);
|
||||
|
||||
useEffect(() => {
|
||||
if (projectId) {
|
||||
const config: ITaskListConfigV2 = {
|
||||
id: projectId,
|
||||
field: 'id',
|
||||
order: 'desc',
|
||||
search: '',
|
||||
statuses: '',
|
||||
members: '',
|
||||
projects: '',
|
||||
isSubtasksInclude: true,
|
||||
};
|
||||
dispatch(fetchTaskGroups(config));
|
||||
}
|
||||
if (!statusCategories.length) {
|
||||
dispatch(fetchStatusesCategories());
|
||||
}
|
||||
}, [dispatch, projectId]);
|
||||
|
||||
return (
|
||||
<Flex vertical gap={16} style={{ overflowX: 'hidden' }}>
|
||||
<TaskListFilters position="list" />
|
||||
|
||||
{taskGroups.map(group => (
|
||||
<TaskListTableWrapper
|
||||
key={group.id}
|
||||
taskList={group}
|
||||
name={group.name || ''}
|
||||
color={group.color_code || ''}
|
||||
groupId={group.id || ''}
|
||||
/>
|
||||
))}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectViewTaskList;
|
||||
@@ -0,0 +1,67 @@
|
||||
import { TaskType } from '@/types/task.types';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { Flex } from 'antd';
|
||||
import TaskListTableWrapper from '@/pages/projects/project-view-1/taskList/taskListTable/TaskListTableWrapper';
|
||||
import { createPortal } from 'react-dom';
|
||||
import BulkTasksActionContainer from '@/features/projects/bulkActions/BulkTasksActionContainer';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { deselectAll } from '@/features/projects/bulkActions/bulkActionSlice';
|
||||
import { ITaskListGroup } from '@/types/tasks/taskList.types';
|
||||
|
||||
const StatusGroupTables = ({ group }: { group: ITaskListGroup }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
// get bulk action detatils
|
||||
const selectedTaskIdsList = useAppSelector(state => state.bulkActionReducer.selectedTaskIdsList);
|
||||
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
|
||||
// fuction for get a color regariding the status
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'todo':
|
||||
return themeMode === 'dark' ? '#3a3a3a' : '#d8d7d8';
|
||||
case 'doing':
|
||||
return themeMode === 'dark' ? '#3d506e' : '#c0d5f6';
|
||||
case 'done':
|
||||
return themeMode === 'dark' ? '#3b6149' : '#c2e4d0';
|
||||
default:
|
||||
return themeMode === 'dark' ? '#3a3a3a' : '#d8d7d8';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex gap={24} vertical>
|
||||
{group?.tasks?.map(status => (
|
||||
<TaskListTableWrapper
|
||||
key={status.id}
|
||||
taskList={group.tasks}
|
||||
tableId={status.id || ''}
|
||||
name={status.name || ''}
|
||||
type="status"
|
||||
statusCategory={status.status || ''}
|
||||
color={getStatusColor(status.status || '')}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* bulk action container ==> used tailwind to recreate the animation */}
|
||||
{createPortal(
|
||||
<div
|
||||
className={`absolute bottom-0 left-1/2 z-20 -translate-x-1/2 ${selectedTaskIdsList.length > 0 ? 'overflow-visible' : 'h-[1px] overflow-hidden'}`}
|
||||
>
|
||||
<div
|
||||
className={`${selectedTaskIdsList.length > 0 ? 'bottom-4' : 'bottom-0'} absolute left-1/2 z-[999] -translate-x-1/2 transition-all duration-300`}
|
||||
>
|
||||
<BulkTasksActionContainer
|
||||
selectedTaskIds={selectedTaskIdsList}
|
||||
closeContainer={() => dispatch(deselectAll())}
|
||||
/>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default StatusGroupTables;
|
||||
@@ -0,0 +1,69 @@
|
||||
import { CaretDownFilled } from '@ant-design/icons';
|
||||
import { ConfigProvider, Flex, Select } from 'antd';
|
||||
import React, { useState } from 'react';
|
||||
import { colors } from '@/styles/colors';
|
||||
import ConfigPhaseButton from '@features/projects/singleProject/phase/ConfigPhaseButton';
|
||||
import { useSelectedProject } from '@/hooks/useSelectedProject';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import CreateStatusButton from '@/components/project-task-filters/create-status-button/create-status-button';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { setGroupBy } from '@features/group-by-filter-dropdown/group-by-filter-dropdown-slice';
|
||||
|
||||
const GroupByFilterDropdown = ({ position }: { position: 'list' | 'board' }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
type GroupTypes = 'status' | 'priority' | 'phase' | 'members' | 'list';
|
||||
|
||||
const [activeGroup, setActiveGroup] = useState<GroupTypes>('status');
|
||||
|
||||
// localization
|
||||
const { t } = useTranslation('task-list-filters');
|
||||
|
||||
const handleChange = (value: string) => {
|
||||
setActiveGroup(value as GroupTypes);
|
||||
dispatch(setGroupBy(value as GroupTypes));
|
||||
};
|
||||
|
||||
// get selected project from useSelectedPro
|
||||
const selectedProject = useSelectedProject();
|
||||
|
||||
//get phases details from phases slice
|
||||
const phase =
|
||||
useAppSelector(state => state.phaseReducer.phaseList).find(
|
||||
phase => phase.projectId === selectedProject?.id
|
||||
) || null;
|
||||
|
||||
const groupDropdownMenuItems = [
|
||||
{ key: 'status', value: 'status', label: t('statusText') },
|
||||
{ key: 'priority', value: 'priority', label: t('priorityText') },
|
||||
{
|
||||
key: 'phase',
|
||||
value: 'phase',
|
||||
label: phase ? phase?.phase : t('phaseText'),
|
||||
},
|
||||
{ key: 'members', value: 'members', label: t('memberText') },
|
||||
{ key: 'list', value: 'list', label: t('listText') },
|
||||
];
|
||||
|
||||
return (
|
||||
<Flex align="center" gap={4} style={{ marginInlineStart: 12 }}>
|
||||
{t('groupByText')}:
|
||||
<Select
|
||||
defaultValue={'status'}
|
||||
options={groupDropdownMenuItems}
|
||||
onChange={handleChange}
|
||||
suffixIcon={<CaretDownFilled />}
|
||||
dropdownStyle={{ width: 'wrap-content' }}
|
||||
/>
|
||||
{(activeGroup === 'status' || activeGroup === 'phase') && (
|
||||
<ConfigProvider wave={{ disabled: true }}>
|
||||
{activeGroup === 'phase' && <ConfigPhaseButton color={colors.skyBlue} />}
|
||||
{activeGroup === 'status' && <CreateStatusButton />}
|
||||
</ConfigProvider>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupByFilterDropdown;
|
||||
@@ -0,0 +1,134 @@
|
||||
import { CaretDownFilled } from '@ant-design/icons';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Dropdown,
|
||||
Empty,
|
||||
Flex,
|
||||
Input,
|
||||
InputRef,
|
||||
List,
|
||||
Space,
|
||||
} from 'antd';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { colors } from '@/styles/colors';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ITaskLabel } from '@/types/tasks/taskLabel.types';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
|
||||
const LabelsFilterDropdown = (props: { labels: ITaskLabel[] }) => {
|
||||
const { t } = useTranslation('task-list-filters');
|
||||
const labelInputRef = useRef<InputRef>(null);
|
||||
const [selectedCount, setSelectedCount] = useState<number>(0);
|
||||
const [filteredLabelList, setFilteredLabelList] = useState<ITaskLabel[]>(props.labels);
|
||||
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
setFilteredLabelList(props.labels);
|
||||
}, [props.labels]);
|
||||
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
|
||||
// handle selected filters count
|
||||
const handleSelectedFiltersCount = (checked: boolean) => {
|
||||
setSelectedCount(prev => (checked ? prev + 1 : prev - 1));
|
||||
};
|
||||
|
||||
// function to focus labels input
|
||||
const handleLabelsDropdownOpen = (open: boolean) => {
|
||||
if (open) {
|
||||
setTimeout(() => {
|
||||
labelInputRef.current?.focus();
|
||||
}, 0);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearchQuery = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const searchText = e.currentTarget.value;
|
||||
setSearchQuery(searchText);
|
||||
if (searchText.length === 0) {
|
||||
setFilteredLabelList(props.labels);
|
||||
return;
|
||||
}
|
||||
setFilteredLabelList(
|
||||
props.labels.filter(label => label.name?.toLowerCase().includes(searchText.toLowerCase()))
|
||||
);
|
||||
};
|
||||
|
||||
// custom dropdown content
|
||||
const labelsDropdownContent = (
|
||||
<Card
|
||||
className="custom-card"
|
||||
styles={{
|
||||
body: { padding: 8, width: 260, maxHeight: 250, overflow: 'hidden', overflowY: 'auto' },
|
||||
}}
|
||||
>
|
||||
<Flex vertical gap={8}>
|
||||
<Input
|
||||
ref={labelInputRef}
|
||||
value={searchQuery}
|
||||
onChange={e => handleSearchQuery(e)}
|
||||
placeholder={t('searchInputPlaceholder')}
|
||||
/>
|
||||
|
||||
<List style={{ padding: 0 }}>
|
||||
{filteredLabelList.length ? (
|
||||
filteredLabelList.map(label => (
|
||||
<List.Item
|
||||
className={`custom-list-item ${themeMode === 'dark' ? 'dark' : ''}`}
|
||||
key={label.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-start',
|
||||
gap: 8,
|
||||
padding: '4px 8px',
|
||||
border: 'none',
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
id={label.id}
|
||||
onChange={e => handleSelectedFiltersCount(e.target.checked)}
|
||||
/>
|
||||
|
||||
<Flex gap={8}>
|
||||
<Badge color={label.color_code} />
|
||||
{label.name}
|
||||
</Flex>
|
||||
</List.Item>
|
||||
))
|
||||
) : (
|
||||
<Empty description={t('noLabelsFound')} />
|
||||
)}
|
||||
</List>
|
||||
</Flex>
|
||||
</Card>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
overlayClassName="custom-dropdown"
|
||||
trigger={['click']}
|
||||
dropdownRender={() => labelsDropdownContent}
|
||||
onOpenChange={handleLabelsDropdownOpen}
|
||||
>
|
||||
<Button
|
||||
icon={<CaretDownFilled />}
|
||||
iconPosition="end"
|
||||
style={{
|
||||
backgroundColor: selectedCount > 0 ? colors.paleBlue : colors.transparent,
|
||||
|
||||
color: selectedCount > 0 ? colors.darkGray : 'inherit',
|
||||
}}
|
||||
>
|
||||
<Space>
|
||||
{t('labelsText')}
|
||||
{selectedCount > 0 && <Badge size="small" count={selectedCount} color={colors.skyBlue} />}
|
||||
</Space>
|
||||
</Button>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default LabelsFilterDropdown;
|
||||
@@ -0,0 +1,140 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import { CaretDownFilled } from '@ant-design/icons';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Dropdown,
|
||||
Empty,
|
||||
Flex,
|
||||
Input,
|
||||
InputRef,
|
||||
List,
|
||||
Space,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { colors } from '@/styles/colors';
|
||||
import CustomAvatar from '@components/CustomAvatar';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const MembersFilterDropdown = () => {
|
||||
const [selectedCount, setSelectedCount] = useState<number>(0);
|
||||
const membersInputRef = useRef<InputRef>(null);
|
||||
|
||||
const members = useAppSelector(state => state.memberReducer.membersList);
|
||||
|
||||
const { t } = useTranslation('task-list-filters');
|
||||
|
||||
const membersList = [
|
||||
...members,
|
||||
useAppSelector(state => state.memberReducer.owner),
|
||||
];
|
||||
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
|
||||
// this is for get the current string that type on search bar
|
||||
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||
|
||||
// used useMemo hook for re render the list when searching
|
||||
const filteredMembersData = useMemo(() => {
|
||||
return membersList.filter(member =>
|
||||
member.memberName.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
}, [membersList, searchQuery]);
|
||||
|
||||
// handle selected filters count
|
||||
const handleSelectedFiltersCount = (checked: boolean) => {
|
||||
setSelectedCount(prev => (checked ? prev + 1 : prev - 1));
|
||||
};
|
||||
|
||||
// custom dropdown content
|
||||
const membersDropdownContent = (
|
||||
<Card className="custom-card" styles={{ body: { padding: 8 } }}>
|
||||
<Flex vertical gap={8}>
|
||||
<Input
|
||||
ref={membersInputRef}
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.currentTarget.value)}
|
||||
placeholder={t('searchInputPlaceholder')}
|
||||
/>
|
||||
|
||||
<List style={{ padding: 0 }}>
|
||||
{filteredMembersData.length ? (
|
||||
filteredMembersData.map(member => (
|
||||
<List.Item
|
||||
className={`custom-list-item ${themeMode === 'dark' ? 'dark' : ''}`}
|
||||
key={member.memberId}
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
padding: '4px 8px',
|
||||
border: 'none',
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
id={member.memberId}
|
||||
onChange={e => handleSelectedFiltersCount(e.target.checked)}
|
||||
/>
|
||||
<div>
|
||||
<CustomAvatar avatarName={member.memberName} />
|
||||
</div>
|
||||
<Flex vertical>
|
||||
{member.memberName}
|
||||
|
||||
<Typography.Text
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: colors.lightGray,
|
||||
}}
|
||||
>
|
||||
{member.memberEmail}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
</List.Item>
|
||||
))
|
||||
) : (
|
||||
<Empty />
|
||||
)}
|
||||
</List>
|
||||
</Flex>
|
||||
</Card>
|
||||
);
|
||||
|
||||
// function to focus members input
|
||||
const handleMembersDropdownOpen = (open: boolean) => {
|
||||
if (open) {
|
||||
setTimeout(() => {
|
||||
membersInputRef.current?.focus();
|
||||
}, 0);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
overlayClassName="custom-dropdown"
|
||||
trigger={['click']}
|
||||
dropdownRender={() => membersDropdownContent}
|
||||
onOpenChange={handleMembersDropdownOpen}
|
||||
>
|
||||
<Button
|
||||
icon={<CaretDownFilled />}
|
||||
iconPosition="end"
|
||||
style={{
|
||||
backgroundColor: selectedCount > 0 ? colors.paleBlue : colors.transparent,
|
||||
|
||||
color: selectedCount > 0 ? colors.darkGray : 'inherit',
|
||||
}}
|
||||
>
|
||||
<Space>
|
||||
{t('membersText')}
|
||||
{selectedCount > 0 && <Badge size="small" count={selectedCount} color={colors.skyBlue} />}
|
||||
</Space>
|
||||
</Button>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default MembersFilterDropdown;
|
||||
@@ -0,0 +1,73 @@
|
||||
import { CaretDownFilled } from '@ant-design/icons';
|
||||
import { Badge, Button, Card, Checkbox, Dropdown, List, Space } from 'antd';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { colors } from '@/styles/colors';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ITaskPriority } from '@/types/tasks/taskPriority.types';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
|
||||
const PriorityFilterDropdown = (props: { priorities: ITaskPriority[] }) => {
|
||||
const [selectedCount, setSelectedCount] = useState<number>(0);
|
||||
|
||||
// localization
|
||||
const { t } = useTranslation('task-list-filters');
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
|
||||
// handle selected filters count
|
||||
const handleSelectedFiltersCount = (checked: boolean) => {
|
||||
setSelectedCount(prev => (checked ? prev + 1 : prev - 1));
|
||||
};
|
||||
|
||||
// custom dropdown content
|
||||
const priorityDropdownContent = (
|
||||
<Card className="custom-card" style={{ width: 120 }} styles={{ body: { padding: 0 } }}>
|
||||
<List style={{ padding: 0 }}>
|
||||
{props.priorities?.map(item => (
|
||||
<List.Item
|
||||
className={`custom-list-item ${themeMode === 'dark' ? 'dark' : ''}`}
|
||||
key={item.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
padding: '4px 8px',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<Space>
|
||||
<Checkbox id={item.id} onChange={e => handleSelectedFiltersCount(e.target.checked)} />
|
||||
<Badge color={item.color_code} />
|
||||
{item.name}
|
||||
</Space>
|
||||
</List.Item>
|
||||
))}
|
||||
</List>
|
||||
</Card>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
overlayClassName="custom-dropdown"
|
||||
trigger={['click']}
|
||||
dropdownRender={() => priorityDropdownContent}
|
||||
>
|
||||
<Button
|
||||
icon={<CaretDownFilled />}
|
||||
iconPosition="end"
|
||||
style={{
|
||||
backgroundColor: selectedCount > 0 ? colors.paleBlue : colors.transparent,
|
||||
|
||||
color: selectedCount > 0 ? colors.darkGray : 'inherit',
|
||||
}}
|
||||
>
|
||||
<Space>
|
||||
{t('priorityText')}
|
||||
{selectedCount > 0 && <Badge size="small" count={selectedCount} color={colors.skyBlue} />}
|
||||
</Space>
|
||||
</Button>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default PriorityFilterDropdown;
|
||||
@@ -0,0 +1,54 @@
|
||||
import { SearchOutlined } from '@ant-design/icons';
|
||||
import { Button, Card, Dropdown, Flex, Input, InputRef, Space } from 'antd';
|
||||
import { useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const SearchDropdown = () => {
|
||||
// localization
|
||||
const { t } = useTranslation('task-list-filters');
|
||||
|
||||
const searchInputRef = useRef<InputRef>(null);
|
||||
|
||||
const handleSearchInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
};
|
||||
|
||||
// custom dropdown content
|
||||
const searchDropdownContent = (
|
||||
<Card className="custom-card" styles={{ body: { padding: 8, width: 360 } }}>
|
||||
<Flex vertical gap={8}>
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
placeholder={t('searchInputPlaceholder')}
|
||||
onChange={handleSearchInputChange}
|
||||
/>
|
||||
<Space>
|
||||
<Button type="primary">{t('searchButton')}</Button>
|
||||
<Button>{t('resetButton')}</Button>
|
||||
</Space>
|
||||
</Flex>
|
||||
</Card>
|
||||
);
|
||||
|
||||
// function to focus search input
|
||||
const handleSearchDropdownOpen = (open: boolean) => {
|
||||
if (open) {
|
||||
setTimeout(() => {
|
||||
searchInputRef.current?.focus();
|
||||
}, 0);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
overlayClassName="custom-dropdown"
|
||||
trigger={['click']}
|
||||
dropdownRender={() => searchDropdownContent}
|
||||
onOpenChange={handleSearchDropdownOpen}
|
||||
>
|
||||
<Button icon={<SearchOutlined />} />
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchDropdown;
|
||||
@@ -0,0 +1,96 @@
|
||||
import { MoreOutlined } from '@ant-design/icons';
|
||||
import { Button, Card, Checkbox, Dropdown, List, Space } from 'antd';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import {
|
||||
projectViewTaskListColumnsState,
|
||||
toggleColumnVisibility,
|
||||
} from '@features/projects/singleProject/taskListColumns/taskColumnsSlice';
|
||||
import { columnList } from '../taskListTable/columns/columnList';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
|
||||
|
||||
const ShowFieldsFilterDropdown = () => {
|
||||
const { t } = useTranslation('task-list-filters');
|
||||
const dispatch = useAppDispatch();
|
||||
const { trackMixpanelEvent } = useMixpanelTracking();
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
|
||||
const customColumns = useAppSelector(state => state.taskReducer.customColumns);
|
||||
|
||||
const changableColumnList = [
|
||||
...columnList.filter(column => !['selector', 'task'].includes(column.key)),
|
||||
...(customColumns || []).map(col => ({
|
||||
key: col.key,
|
||||
columnHeader: col.custom_column_obj.columnHeader,
|
||||
isCustomColumn: col.custom_column,
|
||||
}))
|
||||
];
|
||||
|
||||
const columnsVisibility = useAppSelector(
|
||||
state => state.projectViewTaskListColumnsReducer.columnsVisibility
|
||||
);
|
||||
|
||||
const handleColumnToggle = (columnKey: string, isCustomColumn: boolean = false) => {
|
||||
if (isCustomColumn) {
|
||||
// dispatch(toggleCustomColumnVisibility(columnKey));
|
||||
} else {
|
||||
dispatch(toggleColumnVisibility(columnKey));
|
||||
}
|
||||
trackMixpanelEvent('task_list_column_visibility_changed', {
|
||||
column: columnKey,
|
||||
isCustomColumn,
|
||||
visible: !columnsVisibility[columnKey as keyof typeof columnsVisibility],
|
||||
});
|
||||
};
|
||||
|
||||
const showFieldsDropdownContent = (
|
||||
<Card
|
||||
className="custom-card"
|
||||
style={{
|
||||
height: 300,
|
||||
overflowY: 'auto',
|
||||
minWidth: 130,
|
||||
}}
|
||||
styles={{ body: { padding: 0 } }}
|
||||
>
|
||||
<List style={{ padding: 0 }}>
|
||||
{changableColumnList.map(col => (
|
||||
<List.Item
|
||||
key={col.key}
|
||||
className={`custom-list-item ${themeMode === 'dark' ? 'dark' : ''}`}
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
padding: '4px 8px',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={() => handleColumnToggle(col.key, col.custom_column)}
|
||||
>
|
||||
<Space>
|
||||
<Checkbox
|
||||
checked={
|
||||
columnsVisibility[
|
||||
col.key as keyof projectViewTaskListColumnsState['columnsVisibility']
|
||||
]
|
||||
}
|
||||
/>
|
||||
{col.custom_column
|
||||
? col.columnHeader
|
||||
: t(col.key === 'phases' ? 'phasesText' : `${col.columnHeader}Text`)}
|
||||
</Space>
|
||||
</List.Item>
|
||||
))}
|
||||
</List>
|
||||
</Card>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dropdown overlay={showFieldsDropdownContent} trigger={['click']} placement="bottomRight">
|
||||
<Button icon={<MoreOutlined />}>{t('showFieldsText')}</Button>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShowFieldsFilterDropdown;
|
||||
@@ -0,0 +1,110 @@
|
||||
import { CaretDownFilled, SortAscendingOutlined, SortDescendingOutlined } from '@ant-design/icons';
|
||||
import { Badge, Button, Card, Checkbox, Dropdown, List, Space } from 'antd';
|
||||
import React, { useState } from 'react';
|
||||
import { colors } from '../../../../../styles/colors';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
|
||||
const SortFilterDropdown = () => {
|
||||
const [selectedCount, setSelectedCount] = useState<number>(0);
|
||||
const [sortState, setSortState] = useState<Record<string, 'ascending' | 'descending'>>({});
|
||||
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
|
||||
// localization
|
||||
const { t } = useTranslation('task-list-filters');
|
||||
|
||||
// handle selected filters count
|
||||
const handleSelectedFiltersCount = (checked: boolean) => {
|
||||
setSelectedCount(prev => (checked ? prev + 1 : prev - 1));
|
||||
};
|
||||
|
||||
// fuction for handle sort
|
||||
const handleSort = (key: string) => {
|
||||
setSortState(prev => ({
|
||||
...prev,
|
||||
[key]: prev[key] === 'ascending' ? 'descending' : 'ascending',
|
||||
}));
|
||||
};
|
||||
|
||||
// sort dropdown items
|
||||
type SortFieldsType = {
|
||||
key: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
const sortFieldsList: SortFieldsType[] = [
|
||||
{ key: 'task', label: t('taskText') },
|
||||
{ key: 'status', label: t('statusText') },
|
||||
{ key: 'priority', label: t('priorityText') },
|
||||
{ key: 'startDate', label: t('startDateText') },
|
||||
{ key: 'endDate', label: t('endDateText') },
|
||||
{ key: 'completedDate', label: t('completedDateText') },
|
||||
{ key: 'createdDate', label: t('createdDateText') },
|
||||
{ key: 'lastUpdated', label: t('lastUpdatedText') },
|
||||
];
|
||||
|
||||
// custom dropdown content
|
||||
const sortDropdownContent = (
|
||||
<Card className="custom-card" styles={{ body: { padding: 0 } }}>
|
||||
<List style={{ padding: 0 }}>
|
||||
{sortFieldsList.map(item => (
|
||||
<List.Item
|
||||
className={`custom-list-item ${themeMode === 'dark' ? 'dark' : ''}`}
|
||||
key={item.key}
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
padding: '4px 8px',
|
||||
border: 'none',
|
||||
}}
|
||||
>
|
||||
<Space>
|
||||
<Checkbox
|
||||
id={item.key}
|
||||
onChange={e => handleSelectedFiltersCount(e.target.checked)}
|
||||
/>
|
||||
{item.label}
|
||||
</Space>
|
||||
<Button
|
||||
onClick={() => handleSort(item.key)}
|
||||
icon={
|
||||
sortState[item.key] === 'ascending' ? (
|
||||
<SortAscendingOutlined />
|
||||
) : (
|
||||
<SortDescendingOutlined />
|
||||
)
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
))}
|
||||
</List>
|
||||
</Card>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
overlayClassName="custom-dropdown"
|
||||
trigger={['click']}
|
||||
dropdownRender={() => sortDropdownContent}
|
||||
>
|
||||
<Button
|
||||
icon={<CaretDownFilled />}
|
||||
iconPosition="end"
|
||||
style={{
|
||||
backgroundColor: selectedCount > 0 ? colors.paleBlue : colors.transparent,
|
||||
|
||||
color: selectedCount > 0 ? colors.darkGray : 'inherit',
|
||||
}}
|
||||
>
|
||||
<Space>
|
||||
<SortAscendingOutlined />
|
||||
{t('sortText')}
|
||||
{selectedCount > 0 && <Badge size="small" count={selectedCount} color={colors.skyBlue} />}
|
||||
</Space>
|
||||
</Button>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default SortFilterDropdown;
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Checkbox, Flex, Typography } from 'antd';
|
||||
import SearchDropdown from './SearchDropdown';
|
||||
import SortFilterDropdown from './SortFilterDropdown';
|
||||
import LabelsFilterDropdown from './LabelsFilterDropdown';
|
||||
import MembersFilterDropdown from './MembersFilterDropdown';
|
||||
import GroupByFilterDropdown from './GroupByFilterDropdown';
|
||||
import ShowFieldsFilterDropdown from './ShowFieldsFilterDropdown';
|
||||
import PriorityFilterDropdown from './PriorityFilterDropdown';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useEffect } from 'react';
|
||||
import { fetchPriorities } from '@/features/taskAttributes/taskPrioritySlice';
|
||||
import { fetchLabels } from '@/features/taskAttributes/taskLabelSlice';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
|
||||
interface TaskListFiltersProps {
|
||||
position: 'board' | 'list';
|
||||
}
|
||||
|
||||
const TaskListFilters: React.FC<TaskListFiltersProps> = ({ position }) => {
|
||||
const { t } = useTranslation('task-list-filters');
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
// Selectors
|
||||
const priorities = useAppSelector(state => state.priorityReducer.priorities);
|
||||
const labels = useAppSelector(state => state.taskLabelsReducer.labels);
|
||||
|
||||
// Fetch initial data
|
||||
useEffect(() => {
|
||||
const fetchInitialData = async () => {
|
||||
if (!priorities.length) {
|
||||
await dispatch(fetchPriorities());
|
||||
}
|
||||
if (!labels.length) {
|
||||
await dispatch(fetchLabels());
|
||||
}
|
||||
};
|
||||
|
||||
fetchInitialData();
|
||||
}, [dispatch, priorities.length, labels.length]);
|
||||
|
||||
return (
|
||||
<Flex gap={8} align="center" justify="space-between">
|
||||
<Flex gap={8} wrap={'wrap'}>
|
||||
{/* search dropdown */}
|
||||
<SearchDropdown />
|
||||
{/* sort dropdown */}
|
||||
<SortFilterDropdown />
|
||||
{/* prioriy dropdown */}
|
||||
<PriorityFilterDropdown priorities={priorities} />
|
||||
{/* labels dropdown */}
|
||||
<LabelsFilterDropdown labels={labels} />
|
||||
{/* members dropdown */}
|
||||
<MembersFilterDropdown />
|
||||
{/* group by dropdown */}
|
||||
{<GroupByFilterDropdown position={position} />}
|
||||
</Flex>
|
||||
|
||||
{position === 'list' && (
|
||||
<Flex gap={12} wrap={'wrap'}>
|
||||
<Flex gap={4} align="center">
|
||||
<Checkbox />
|
||||
<Typography.Text>{t('showArchivedText')}</Typography.Text>
|
||||
</Flex>
|
||||
{/* show fields dropdown */}
|
||||
<ShowFieldsFilterDropdown />
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskListFilters;
|
||||
@@ -0,0 +1,425 @@
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { columnList } from './columns/columnList';
|
||||
import AddTaskListRow from './taskListTableRows/AddTaskListRow';
|
||||
import { Checkbox, Flex, Tag, Tooltip } from 'antd';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useSelectedProject } from '@/hooks/useSelectedProject';
|
||||
import TaskCell from './taskListTableCells/TaskCell';
|
||||
import AddSubTaskListRow from './taskListTableRows/AddSubTaskListRow';
|
||||
import { colors } from '@/styles/colors';
|
||||
import TaskContextMenu from './contextMenu/TaskContextMenu';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { deselectAll } from '@features/projects/bulkActions/bulkActionSlice';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||
import { HolderOutlined } from '@ant-design/icons';
|
||||
|
||||
const TaskListTable = ({
|
||||
taskList,
|
||||
tableId,
|
||||
}: {
|
||||
taskList: IProjectTask[] | null;
|
||||
tableId: string;
|
||||
}) => {
|
||||
// these states manage the necessary states
|
||||
const [hoverRow, setHoverRow] = useState<string | null>(null);
|
||||
const [selectedRows, setSelectedRows] = useState<string[]>([]);
|
||||
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
|
||||
const [expandedTasks, setExpandedTasks] = useState<string[]>([]);
|
||||
const [isSelectAll, setIsSelectAll] = useState(false);
|
||||
// context menu state
|
||||
const [contextMenuVisible, setContextMenuVisible] = useState(false);
|
||||
const [contextMenuPosition, setContextMenuPosition] = useState({
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
// state to check scroll
|
||||
const [scrollingTables, setScrollingTables] = useState<{
|
||||
[key: string]: boolean;
|
||||
}>({});
|
||||
|
||||
// localization
|
||||
const { t } = useTranslation('task-list-table');
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
// get data theme data from redux
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
|
||||
// get the selected project details
|
||||
const selectedProject = useSelectedProject();
|
||||
|
||||
// get columns list details
|
||||
const columnsVisibility = useAppSelector( state => state.projectViewTaskListColumnsReducer.columnList );
|
||||
const visibleColumns = columnList.filter(
|
||||
column => columnsVisibility[column.key as keyof typeof columnsVisibility]
|
||||
);
|
||||
|
||||
// toggle subtasks visibility
|
||||
const toggleTaskExpansion = (taskId: string) => {
|
||||
setExpandedTasks(prev =>
|
||||
prev.includes(taskId) ? prev.filter(id => id !== taskId) : [...prev, taskId]
|
||||
);
|
||||
};
|
||||
|
||||
// toggle all task select when header checkbox click
|
||||
const toggleSelectAll = () => {
|
||||
if (isSelectAll) {
|
||||
setSelectedRows([]);
|
||||
dispatch(deselectAll());
|
||||
} else {
|
||||
const allTaskIds =
|
||||
taskList?.flatMap(task => [
|
||||
task.id,
|
||||
...(task.sub_tasks?.map(subtask => subtask.id) || []),
|
||||
]) || [];
|
||||
|
||||
// setSelectedRows(allTaskIds);
|
||||
// dispatch(selectTaskIds(allTaskIds));
|
||||
// console.log('selected tasks and subtasks (all):', allTaskIds);
|
||||
}
|
||||
setIsSelectAll(!isSelectAll);
|
||||
};
|
||||
|
||||
// toggle selected row
|
||||
const toggleRowSelection = (task: IProjectTask) => {
|
||||
setSelectedRows(prevSelectedRows =>
|
||||
prevSelectedRows.includes(task.id || '')
|
||||
? prevSelectedRows.filter(id => id !== task.id || '')
|
||||
: [...prevSelectedRows, task.id || '']
|
||||
);
|
||||
};
|
||||
|
||||
// this use effect for realtime update the selected rows
|
||||
useEffect(() => {
|
||||
console.log('Selected tasks and subtasks:', selectedRows);
|
||||
}, [selectedRows]);
|
||||
|
||||
// select one row this triggers only in handle the context menu ==> righ click mouse event
|
||||
const selectOneRow = (task: IProjectTask) => {
|
||||
setSelectedRows([task.id || '']);
|
||||
|
||||
// log the task object when selected
|
||||
if (!selectedRows.includes(task.id || '')) {
|
||||
console.log('Selected task:', task);
|
||||
}
|
||||
};
|
||||
|
||||
// handle custom task context menu
|
||||
const handleContextMenu = (e: React.MouseEvent, task: IProjectTask) => {
|
||||
e.preventDefault();
|
||||
setSelectedTaskId(task.id || '');
|
||||
selectOneRow(task);
|
||||
setContextMenuPosition({ x: e.clientX, y: e.clientY });
|
||||
setContextMenuVisible(true);
|
||||
};
|
||||
|
||||
// trigger the table scrolling
|
||||
useEffect(() => {
|
||||
const tableContainer = document.querySelector(`.tasklist-container-${tableId}`);
|
||||
const handleScroll = () => {
|
||||
if (tableContainer) {
|
||||
setScrollingTables(prev => ({
|
||||
...prev,
|
||||
[tableId]: tableContainer.scrollLeft > 0,
|
||||
}));
|
||||
}
|
||||
};
|
||||
tableContainer?.addEventListener('scroll', handleScroll);
|
||||
return () => tableContainer?.removeEventListener('scroll', handleScroll);
|
||||
}, [tableId]);
|
||||
|
||||
// layout styles for table and the columns
|
||||
const customBorderColor = themeMode === 'dark' && ' border-[#303030]';
|
||||
|
||||
const customHeaderColumnStyles = (key: string) =>
|
||||
`border px-2 text-left ${key === 'selector' && 'sticky left-0 z-10'} ${key === 'task' && `sticky left-[33px] z-10 after:content after:absolute after:top-0 after:-right-1 after:-z-10 after:h-[42px] after:w-1.5 after:bg-transparent ${scrollingTables[tableId] ? 'after:bg-gradient-to-r after:from-[rgba(0,0,0,0.12)] after:to-transparent' : ''}`} ${themeMode === 'dark' ? 'bg-[#1d1d1d] border-[#303030]' : 'bg-[#fafafa]'}`;
|
||||
|
||||
const customBodyColumnStyles = (key: string) =>
|
||||
`border px-2 ${key === 'selector' && 'sticky left-0 z-10'} ${key === 'task' && `sticky left-[33px] z-10 after:content after:absolute after:top-0 after:-right-1 after:-z-10 after:min-h-[40px] after:w-1.5 after:bg-transparent ${scrollingTables[tableId] ? 'after:bg-gradient-to-r after:from-[rgba(0,0,0,0.12)] after:to-transparent' : ''}`} ${themeMode === 'dark' ? 'bg-[#141414] border-[#303030]' : 'bg-white'}`;
|
||||
|
||||
// function to render the column content based on column key
|
||||
const renderColumnContent = (
|
||||
columnKey: string,
|
||||
task: IProjectTask,
|
||||
isSubtask: boolean = false
|
||||
) => {
|
||||
switch (columnKey) {
|
||||
// task ID column
|
||||
case 'taskId':
|
||||
return (
|
||||
<Tooltip title={task.id} className="flex justify-center">
|
||||
<Tag>{task.task_key}</Tag>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
// task column
|
||||
case 'task':
|
||||
return (
|
||||
// custom task cell component
|
||||
<TaskCell
|
||||
task={task}
|
||||
isSubTask={isSubtask}
|
||||
expandedTasks={expandedTasks}
|
||||
setSelectedTaskId={setSelectedTaskId}
|
||||
toggleTaskExpansion={toggleTaskExpansion}
|
||||
/>
|
||||
);
|
||||
|
||||
// description column
|
||||
case 'description':
|
||||
return (
|
||||
<div style={{ width: 260 }}>
|
||||
{/* <Typography.Paragraph ellipsis={{ expandable: false }} style={{ marginBlockEnd: 0 }} >
|
||||
{task.description || ''}
|
||||
</Typography.Paragraph> */}
|
||||
</div>
|
||||
);
|
||||
|
||||
// progress column
|
||||
case 'progress': {
|
||||
return <div></div>;
|
||||
}
|
||||
|
||||
// members column
|
||||
case 'members':
|
||||
return <div></div>;
|
||||
|
||||
// labels column
|
||||
case 'labels':
|
||||
return <div></div>;
|
||||
|
||||
// phase column
|
||||
case 'phases':
|
||||
return <div></div>;
|
||||
|
||||
// status column
|
||||
case 'status':
|
||||
return <div></div>;
|
||||
|
||||
// priority column
|
||||
case 'priority':
|
||||
return <div></div>;
|
||||
|
||||
// // time tracking column
|
||||
// case 'timeTracking':
|
||||
// return (
|
||||
// <TimeTracker
|
||||
// taskId={task.id}
|
||||
// initialTime={task.timer_start_time || 0}
|
||||
// />
|
||||
// );
|
||||
|
||||
// estimation column
|
||||
case 'estimation':
|
||||
return <div></div>;
|
||||
|
||||
// start date column
|
||||
case 'startDate':
|
||||
return <div></div>;
|
||||
|
||||
// due date column
|
||||
case 'dueDate':
|
||||
return <div></div>;
|
||||
|
||||
// completed date column
|
||||
case 'completedDate':
|
||||
return <div></div>;
|
||||
|
||||
// created date column
|
||||
case 'createdDate':
|
||||
return <div></div>;
|
||||
|
||||
// last updated column
|
||||
case 'lastUpdated':
|
||||
return <div></div>;
|
||||
|
||||
// recorder column
|
||||
case 'reporter':
|
||||
return <div></div>;
|
||||
|
||||
// default case for unsupported columns
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`border-x border-b ${customBorderColor}`}>
|
||||
<div className={`tasklist-container-${tableId} min-h-0 max-w-full overflow-x-auto`}>
|
||||
<table className={`rounded-2 w-full min-w-max border-collapse`}>
|
||||
<thead className="h-[42px]">
|
||||
<tr>
|
||||
{/* this cell render the select all task checkbox */}
|
||||
<th
|
||||
key={'selector'}
|
||||
className={`${customHeaderColumnStyles('selector')}`}
|
||||
style={{ width: 56, fontWeight: 500 }}
|
||||
>
|
||||
<Flex justify="flex-end">
|
||||
<Checkbox checked={isSelectAll} onChange={toggleSelectAll} />
|
||||
</Flex>
|
||||
</th>
|
||||
{/* other header cells */}
|
||||
{visibleColumns.map(column => (
|
||||
<th
|
||||
key={column.key}
|
||||
className={`${customHeaderColumnStyles(column.key)}`}
|
||||
style={{ width: column.width, fontWeight: 500 }}
|
||||
>
|
||||
{column.key === 'phases'
|
||||
? column.columnHeader
|
||||
: t(`${column.columnHeader}Column`)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{taskList?.map(task => (
|
||||
<React.Fragment key={task.id}>
|
||||
<tr
|
||||
key={task.id}
|
||||
onContextMenu={e => handleContextMenu(e, task)}
|
||||
className={`${taskList.length === 0 ? 'h-0' : 'h-[42px]'}`}
|
||||
>
|
||||
{/* this cell render the select the related task checkbox */}
|
||||
<td
|
||||
key={'selector'}
|
||||
className={customBodyColumnStyles('selector')}
|
||||
style={{
|
||||
width: 56,
|
||||
backgroundColor: selectedRows.includes(task.id || '')
|
||||
? themeMode === 'dark'
|
||||
? colors.skyBlue
|
||||
: '#dceeff'
|
||||
: hoverRow === task.id
|
||||
? themeMode === 'dark'
|
||||
? '#000'
|
||||
: '#f8f7f9'
|
||||
: themeMode === 'dark'
|
||||
? '#181818'
|
||||
: '#fff',
|
||||
}}
|
||||
>
|
||||
<Flex gap={8} align="center">
|
||||
<HolderOutlined />
|
||||
<Checkbox
|
||||
checked={selectedRows.includes(task.id || '')}
|
||||
onChange={() => toggleRowSelection(task)}
|
||||
/>
|
||||
</Flex>
|
||||
</td>
|
||||
{/* other cells */}
|
||||
{visibleColumns.map(column => (
|
||||
<td
|
||||
key={column.key}
|
||||
className={customBodyColumnStyles(column.key)}
|
||||
style={{
|
||||
width: column.width,
|
||||
backgroundColor: selectedRows.includes(task.id || '')
|
||||
? themeMode === 'dark'
|
||||
? '#000'
|
||||
: '#dceeff'
|
||||
: hoverRow === task.id
|
||||
? themeMode === 'dark'
|
||||
? '#000'
|
||||
: '#f8f7f9'
|
||||
: themeMode === 'dark'
|
||||
? '#181818'
|
||||
: '#fff',
|
||||
}}
|
||||
>
|
||||
{renderColumnContent(column.key, task)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
|
||||
{/* this is for sub tasks */}
|
||||
{expandedTasks.includes(task.id || '') &&
|
||||
task?.sub_tasks?.map(subtask => (
|
||||
<tr
|
||||
key={subtask.id}
|
||||
onContextMenu={e => handleContextMenu(e, subtask)}
|
||||
onMouseEnter={() => setHoverRow(subtask.id || '')}
|
||||
onMouseLeave={() => setHoverRow(null)}
|
||||
className={`${taskList.length === 0 ? 'h-0' : 'h-[42px]'}`}
|
||||
>
|
||||
{/* this cell render the select the related task checkbox */}
|
||||
<td
|
||||
key={'selector'}
|
||||
className={customBodyColumnStyles('selector')}
|
||||
style={{
|
||||
width: 20,
|
||||
backgroundColor: selectedRows.includes(subtask.id || '')
|
||||
? themeMode === 'dark'
|
||||
? colors.skyBlue
|
||||
: '#dceeff'
|
||||
: hoverRow === subtask.id
|
||||
? themeMode === 'dark'
|
||||
? '#000'
|
||||
: '#f8f7f9'
|
||||
: themeMode === 'dark'
|
||||
? '#181818'
|
||||
: '#fff',
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedRows.includes(subtask.id || '')}
|
||||
onChange={() => toggleRowSelection(subtask)}
|
||||
/>
|
||||
</td>
|
||||
|
||||
{/* other sub tasks cells */}
|
||||
{visibleColumns.map(column => (
|
||||
<td
|
||||
key={column.key}
|
||||
className={customBodyColumnStyles(column.key)}
|
||||
style={{
|
||||
width: column.width,
|
||||
backgroundColor: selectedRows.includes(subtask.id || '')
|
||||
? themeMode === 'dark'
|
||||
? '#000'
|
||||
: '#dceeff'
|
||||
: hoverRow === subtask.id
|
||||
? themeMode === 'dark'
|
||||
? '#000'
|
||||
: '#f8f7f9'
|
||||
: themeMode === 'dark'
|
||||
? '#181818'
|
||||
: '#fff',
|
||||
}}
|
||||
>
|
||||
{renderColumnContent(column.key, subtask, true)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
|
||||
{expandedTasks.includes(task.id || '') && (
|
||||
<tr>
|
||||
<td colSpan={visibleColumns.length}>
|
||||
<AddSubTaskListRow />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* add a main task to the table */}
|
||||
<AddTaskListRow />
|
||||
|
||||
{/* custom task context menu */}
|
||||
<TaskContextMenu
|
||||
visible={contextMenuVisible}
|
||||
position={contextMenuPosition}
|
||||
selectedTask={selectedRows[0]}
|
||||
onClose={() => setContextMenuVisible(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskListTable;
|
||||
@@ -0,0 +1,216 @@
|
||||
import { Badge, Button, Collapse, ConfigProvider, Dropdown, Flex, Input, Typography } from 'antd';
|
||||
import { useState } from 'react';
|
||||
import { TaskType } from '../../../../../types/task.types';
|
||||
import { EditOutlined, EllipsisOutlined, RetweetOutlined, RightOutlined } from '@ant-design/icons';
|
||||
import { colors } from '../../../../../styles/colors';
|
||||
import './taskListTableWrapper.css';
|
||||
import TaskListTable from './TaskListTable';
|
||||
import { MenuProps } from 'antd/lib';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||
|
||||
type TaskListTableWrapperProps = {
|
||||
taskList: IProjectTask[];
|
||||
tableId: string;
|
||||
type: string;
|
||||
name: string;
|
||||
color: string;
|
||||
statusCategory?: string | null;
|
||||
priorityCategory?: string | null;
|
||||
onRename?: (name: string) => void;
|
||||
onStatusCategoryChange?: (category: string) => void;
|
||||
};
|
||||
|
||||
const TaskListTableWrapper = ({
|
||||
taskList,
|
||||
tableId,
|
||||
name,
|
||||
type,
|
||||
color,
|
||||
statusCategory = null,
|
||||
priorityCategory = null,
|
||||
onRename,
|
||||
onStatusCategoryChange,
|
||||
}: TaskListTableWrapperProps) => {
|
||||
const [tableName, setTableName] = useState<string>(name);
|
||||
const [isRenaming, setIsRenaming] = useState<boolean>(false);
|
||||
const [isExpanded, setIsExpanded] = useState<boolean>(true);
|
||||
const [currentCategory, setCurrentCategory] = useState<string | null>(statusCategory);
|
||||
|
||||
// localization
|
||||
const { t } = useTranslation('task-list-table');
|
||||
|
||||
// function to handle toggle expand
|
||||
const handlToggleExpand = () => {
|
||||
setIsExpanded(!isExpanded);
|
||||
};
|
||||
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
|
||||
// this is for get the color for every typed tables
|
||||
const getBgColorClassName = (type: string) => {
|
||||
switch (type) {
|
||||
case 'status':
|
||||
if (currentCategory === 'todo')
|
||||
return themeMode === 'dark' ? 'after:bg-[#3a3a3a]' : 'after:bg-[#d8d7d8]';
|
||||
else if (currentCategory === 'doing')
|
||||
return themeMode === 'dark' ? 'after:bg-[#3d506e]' : 'after:bg-[#c0d5f6]';
|
||||
else if (currentCategory === 'done')
|
||||
return themeMode === 'dark' ? 'after:bg-[#3b6149]' : 'after:bg-[#c2e4d0]';
|
||||
else return themeMode === 'dark' ? 'after:bg-[#3a3a3a]' : 'after:bg-[#d8d7d8]';
|
||||
|
||||
case 'priority':
|
||||
if (priorityCategory === 'low')
|
||||
return themeMode === 'dark' ? 'after:bg-[#3b6149]' : 'after:bg-[#c2e4d0]';
|
||||
else if (priorityCategory === 'medium')
|
||||
return themeMode === 'dark' ? 'after:bg-[#916c33]' : 'after:bg-[#f9e3b1]';
|
||||
else if (priorityCategory === 'high')
|
||||
return themeMode === 'dark' ? 'after:bg-[#8b3a3b]' : 'after:bg-[#f6bfc0]';
|
||||
else return themeMode === 'dark' ? 'after:bg-[#916c33]' : 'after:bg-[#f9e3b1]';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
// these codes only for status type tables
|
||||
// function to handle rename this functionality only available for status type tables
|
||||
const handleRename = () => {
|
||||
if (onRename) {
|
||||
onRename(tableName);
|
||||
}
|
||||
setIsRenaming(false);
|
||||
};
|
||||
|
||||
// function to handle category change
|
||||
const handleCategoryChange = (category: string) => {
|
||||
setCurrentCategory(category);
|
||||
if (onStatusCategoryChange) {
|
||||
onStatusCategoryChange(category);
|
||||
}
|
||||
};
|
||||
|
||||
// find the available status for the currently active project
|
||||
const statusList = useAppSelector(state => state.statusReducer.status);
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'todo':
|
||||
return '#d8d7d8';
|
||||
case 'doing':
|
||||
return '#c0d5f6';
|
||||
case 'done':
|
||||
return '#c2e4d0';
|
||||
default:
|
||||
return '#d8d7d8';
|
||||
}
|
||||
};
|
||||
|
||||
// dropdown options
|
||||
const items: MenuProps['items'] = [
|
||||
{
|
||||
key: '1',
|
||||
icon: <EditOutlined />,
|
||||
label: 'Rename',
|
||||
onClick: () => setIsRenaming(true),
|
||||
},
|
||||
{
|
||||
key: '2',
|
||||
icon: <RetweetOutlined />,
|
||||
label: 'Change category',
|
||||
children: statusList?.map(status => ({
|
||||
key: status.id,
|
||||
label: (
|
||||
<Flex gap={8} onClick={() => handleCategoryChange(status.category)}>
|
||||
<Badge color={getStatusColor(status.category)} />
|
||||
{status.name}
|
||||
</Flex>
|
||||
),
|
||||
})),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<ConfigProvider
|
||||
wave={{ disabled: true }}
|
||||
theme={{
|
||||
components: {
|
||||
Collapse: {
|
||||
headerPadding: 0,
|
||||
contentPadding: 0,
|
||||
},
|
||||
|
||||
Select: {
|
||||
colorBorder: colors.transparent,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Flex vertical>
|
||||
<Flex style={{ transform: 'translateY(6px)' }}>
|
||||
<Button
|
||||
className="custom-collapse-button"
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
border: 'none',
|
||||
borderBottomLeftRadius: isExpanded ? 0 : 4,
|
||||
borderBottomRightRadius: isExpanded ? 0 : 4,
|
||||
color: themeMode === 'dark' ? '#ffffffd9' : colors.darkGray,
|
||||
}}
|
||||
icon={<RightOutlined rotate={isExpanded ? 90 : 0} />}
|
||||
onClick={handlToggleExpand}
|
||||
>
|
||||
{isRenaming ? (
|
||||
<Input
|
||||
size="small"
|
||||
value={tableName}
|
||||
onChange={e => setTableName(e.target.value)}
|
||||
onBlur={handleRename}
|
||||
onPressEnter={handleRename}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<Typography.Text
|
||||
style={{
|
||||
fontSize: 14,
|
||||
color: themeMode === 'dark' ? '#ffffffd9' : colors.darkGray,
|
||||
}}
|
||||
>
|
||||
{/* check the default values available in the table names ==> this check for localization */}
|
||||
{['todo', 'doing', 'done', 'low', 'medium', 'high'].includes(
|
||||
tableName.replace(/\s+/g, '').toLowerCase()
|
||||
)
|
||||
? t(`${tableName.replace(/\s+/g, '').toLowerCase()}SelectorText`)
|
||||
: tableName}{' '}
|
||||
({taskList.length})
|
||||
</Typography.Text>
|
||||
)}
|
||||
</Button>
|
||||
{type === 'status' && !isRenaming && (
|
||||
<Dropdown menu={{ items }}>
|
||||
<Button icon={<EllipsisOutlined />} className="borderless-icon-btn" />
|
||||
</Dropdown>
|
||||
)}
|
||||
</Flex>
|
||||
<Collapse
|
||||
collapsible="header"
|
||||
className="border-l-[4px]"
|
||||
bordered={false}
|
||||
ghost={true}
|
||||
expandIcon={() => null}
|
||||
activeKey={isExpanded ? '1' : undefined}
|
||||
onChange={handlToggleExpand}
|
||||
items={[
|
||||
{
|
||||
key: '1',
|
||||
className: `custom-collapse-content-box relative after:content after:absolute after:h-full after:w-1 ${getBgColorClassName(type)} after:z-10 after:top-0 after:left-0`,
|
||||
children: <TaskListTable taskList={taskList} tableId={tableId} />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Flex>
|
||||
</ConfigProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskListTableWrapper;
|
||||
@@ -0,0 +1,90 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import PhaseHeader from '../../../../../../features/projects/singleProject/phase/PhaseHeader';
|
||||
|
||||
export type CustomTableColumnsType = {
|
||||
key: string;
|
||||
columnHeader: ReactNode | null;
|
||||
width: number;
|
||||
};
|
||||
|
||||
const phaseHeader = React.createElement(PhaseHeader);
|
||||
|
||||
export const columnList: CustomTableColumnsType[] = [
|
||||
{ key: 'taskId', columnHeader: 'key', width: 20 },
|
||||
{ key: 'task', columnHeader: 'task', width: 400 },
|
||||
{
|
||||
key: 'description',
|
||||
columnHeader: 'description',
|
||||
width: 200,
|
||||
},
|
||||
{
|
||||
key: 'progress',
|
||||
columnHeader: 'progress',
|
||||
width: 60,
|
||||
},
|
||||
{
|
||||
key: 'members',
|
||||
columnHeader: 'members',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
key: 'labels',
|
||||
columnHeader: 'labels',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
key: 'phases',
|
||||
columnHeader: phaseHeader,
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
columnHeader: 'status',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
key: 'priority',
|
||||
columnHeader: 'priority',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
key: 'timeTracking',
|
||||
columnHeader: 'timeTracking',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
key: 'estimation',
|
||||
columnHeader: 'estimation',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
key: 'startDate',
|
||||
columnHeader: 'startDate',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
key: 'dueDate',
|
||||
columnHeader: 'dueDate',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
key: 'completedDate',
|
||||
columnHeader: 'completedDate',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
key: 'createdDate',
|
||||
columnHeader: 'createdDate',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
key: 'lastUpdated',
|
||||
columnHeader: 'lastUpdated',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
key: 'reporter',
|
||||
columnHeader: 'reporter',
|
||||
width: 150,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,90 @@
|
||||
import {
|
||||
DeleteOutlined,
|
||||
DoubleRightOutlined,
|
||||
InboxOutlined,
|
||||
RetweetOutlined,
|
||||
UserAddOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Badge, Dropdown, Flex, Typography } from 'antd';
|
||||
import { MenuProps } from 'antd/lib';
|
||||
import React from 'react';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
|
||||
type TaskContextMenuProps = {
|
||||
visible: boolean;
|
||||
position: { x: number; y: number };
|
||||
selectedTask: string;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const TaskContextMenu = ({ visible, position, selectedTask, onClose }: TaskContextMenuProps) => {
|
||||
// find the available status for the currently active project
|
||||
const statusList = useAppSelector(state => state.statusReducer.status);
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'todo':
|
||||
return '#d8d7d8';
|
||||
case 'doing':
|
||||
return '#c0d5f6';
|
||||
case 'done':
|
||||
return '#c2e4d0';
|
||||
default:
|
||||
return '#d8d7d8';
|
||||
}
|
||||
};
|
||||
|
||||
const items: MenuProps['items'] = [
|
||||
{
|
||||
key: '1',
|
||||
icon: <UserAddOutlined />,
|
||||
label: ' Assign to me',
|
||||
},
|
||||
{
|
||||
key: '2',
|
||||
icon: <RetweetOutlined />,
|
||||
label: 'Move to',
|
||||
children: statusList?.map(status => ({
|
||||
key: status.id,
|
||||
label: (
|
||||
<Flex gap={8}>
|
||||
<Badge color={getStatusColor(status.category)} />
|
||||
{status.name}
|
||||
</Flex>
|
||||
),
|
||||
})),
|
||||
},
|
||||
{
|
||||
key: '3',
|
||||
icon: <InboxOutlined />,
|
||||
label: 'Archive',
|
||||
},
|
||||
{
|
||||
key: '4',
|
||||
icon: <DoubleRightOutlined />,
|
||||
label: 'Convert to Sub task',
|
||||
},
|
||||
{
|
||||
key: '5',
|
||||
icon: <DeleteOutlined />,
|
||||
label: ' Delete',
|
||||
},
|
||||
];
|
||||
|
||||
return visible ? (
|
||||
<Dropdown menu={{ items }} trigger={['contextMenu']} open={visible} onOpenChange={onClose}>
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: position.y,
|
||||
left: position.x,
|
||||
zIndex: 1000,
|
||||
width: 1,
|
||||
height: 1,
|
||||
}}
|
||||
></div>
|
||||
</Dropdown>
|
||||
) : null;
|
||||
};
|
||||
|
||||
export default TaskContextMenu;
|
||||
@@ -0,0 +1,121 @@
|
||||
// TaskNameCell.tsx
|
||||
import React from 'react';
|
||||
import { Flex, Typography, Button } from 'antd';
|
||||
import {
|
||||
DoubleRightOutlined,
|
||||
DownOutlined,
|
||||
RightOutlined,
|
||||
ExpandAltOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { colors } from '@/styles/colors';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { setShowTaskDrawer } from '@/features/task-drawer/task-drawer.slice';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||
|
||||
type TaskCellProps = {
|
||||
task: IProjectTask;
|
||||
isSubTask?: boolean;
|
||||
expandedTasks: string[];
|
||||
setSelectedTaskId: (taskId: string) => void;
|
||||
toggleTaskExpansion: (taskId: string) => void;
|
||||
};
|
||||
|
||||
const TaskCell = ({
|
||||
task,
|
||||
isSubTask = false,
|
||||
expandedTasks,
|
||||
setSelectedTaskId,
|
||||
toggleTaskExpansion,
|
||||
}: TaskCellProps) => {
|
||||
// localization
|
||||
const { t } = useTranslation('task-list-table');
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
// render the toggle arrow icon for tasks with subtasks
|
||||
const renderToggleButtonForHasSubTasks = (taskId: string, hasSubtasks: boolean) => {
|
||||
if (!hasSubtasks) return null;
|
||||
return (
|
||||
<button
|
||||
onClick={() => toggleTaskExpansion(taskId)}
|
||||
className="hover flex h-4 w-4 items-center justify-center rounded text-[12px] hover:border hover:border-[#5587f5] hover:bg-[#d0eefa54]"
|
||||
>
|
||||
{expandedTasks.includes(taskId) ? <DownOutlined /> : <RightOutlined />}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
// show expand button on hover for tasks without subtasks
|
||||
const renderToggleButtonForNonSubtasks = (taskId: string, isSubTask: boolean) => {
|
||||
return !isSubTask ? (
|
||||
<button
|
||||
onClick={() => toggleTaskExpansion(taskId)}
|
||||
className="hover flex h-4 w-4 items-center justify-center rounded text-[12px] hover:border hover:border-[#5587f5] hover:bg-[#d0eefa54]"
|
||||
>
|
||||
{expandedTasks.includes(taskId) ? <DownOutlined /> : <RightOutlined />}
|
||||
</button>
|
||||
) : (
|
||||
<div className="h-4 w-4"></div>
|
||||
);
|
||||
};
|
||||
|
||||
// render the double arrow icon and count label for tasks with subtasks
|
||||
const renderSubtasksCountLabel = (taskId: string, isSubTask: boolean, subTasksCount: number) => {
|
||||
return (
|
||||
!isSubTask && (
|
||||
<Button
|
||||
onClick={() => toggleTaskExpansion(taskId)}
|
||||
size="small"
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 2,
|
||||
paddingInline: 4,
|
||||
alignItems: 'center',
|
||||
justifyItems: 'center',
|
||||
border: 'none',
|
||||
}}
|
||||
>
|
||||
<Typography.Text style={{ fontSize: 12, lineHeight: 1 }}>{subTasksCount}</Typography.Text>
|
||||
<DoubleRightOutlined style={{ fontSize: 10 }} />
|
||||
</Button>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex align="center" justify="space-between">
|
||||
<Flex gap={8} align="center">
|
||||
{!!task?.sub_tasks?.length && task.id ? (
|
||||
renderToggleButtonForHasSubTasks(task.id, !!task?.sub_tasks?.length)
|
||||
) : (
|
||||
<div className="h-4 w-4"></div>
|
||||
)}
|
||||
|
||||
{isSubTask && <DoubleRightOutlined style={{ fontSize: 12 }} />}
|
||||
|
||||
<Typography.Text ellipsis={{ expanded: false }}>{task.name}</Typography.Text>
|
||||
|
||||
{renderSubtasksCountLabel(task.id || '', isSubTask, task?.sub_tasks?.length || 0)}
|
||||
</Flex>
|
||||
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ExpandAltOutlined />}
|
||||
onClick={() => {
|
||||
setSelectedTaskId(task.id || '');
|
||||
dispatch(setShowTaskDrawer(true));
|
||||
}}
|
||||
style={{
|
||||
backgroundColor: colors.transparent,
|
||||
padding: 0,
|
||||
height: 'fit-content',
|
||||
}}
|
||||
>
|
||||
{t('openButton')}
|
||||
</Button>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskCell;
|
||||
@@ -0,0 +1,22 @@
|
||||
/* Set the stroke width to 9px for the progress circle */
|
||||
.task-progress.ant-progress-circle .ant-progress-circle-path {
|
||||
stroke-width: 9px !important; /* Adjust the stroke width */
|
||||
}
|
||||
|
||||
/* Adjust the inner check mark for better alignment and visibility */
|
||||
.task-progress.ant-progress-circle.ant-progress-status-success .ant-progress-inner .anticon-check {
|
||||
font-size: 8px; /* Adjust font size for the check mark */
|
||||
color: green; /* Optional: Set a color */
|
||||
transform: translate(-50%, -50%); /* Center align */
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
padding: 0;
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
/* Adjust the text inside the progress circle */
|
||||
.task-progress.ant-progress-circle .ant-progress-text {
|
||||
font-size: 10px; /* Ensure the text size fits well */
|
||||
line-height: 1;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Progress, Tooltip } from 'antd';
|
||||
import React from 'react';
|
||||
import './TaskProgress.css';
|
||||
|
||||
type TaskProgressProps = {
|
||||
progress: number;
|
||||
numberOfSubTasks: number;
|
||||
};
|
||||
|
||||
const TaskProgress = ({ progress = 0, numberOfSubTasks = 0 }: TaskProgressProps) => {
|
||||
const totalTasks = numberOfSubTasks + 1;
|
||||
const completedTasks = 0;
|
||||
|
||||
const size = progress === 100 ? 21 : 26;
|
||||
|
||||
return (
|
||||
<Tooltip title={`${completedTasks} / ${totalTasks}`}>
|
||||
<Progress
|
||||
percent={progress}
|
||||
type="circle"
|
||||
size={size}
|
||||
style={{ cursor: 'default' }}
|
||||
className="task-progress"
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskProgress;
|
||||
@@ -0,0 +1,66 @@
|
||||
import React from 'react';
|
||||
import { Divider, Empty, Flex, Popover, Typography } from 'antd';
|
||||
import { PlayCircleFilled } from '@ant-design/icons';
|
||||
import { colors } from '../../../../../../styles/colors';
|
||||
import CustomAvatar from '../../../../../../components/CustomAvatar';
|
||||
import { mockTimeLogs } from './mockTimeLogs';
|
||||
|
||||
type TimeTrackerProps = {
|
||||
taskId: string | null | undefined;
|
||||
initialTime?: number;
|
||||
};
|
||||
|
||||
const TimeTracker = ({ taskId, initialTime = 0 }: TimeTrackerProps) => {
|
||||
const minutes = Math.floor(initialTime / 60);
|
||||
const seconds = initialTime % 60;
|
||||
const formattedTime = `${minutes}m ${seconds}s`;
|
||||
|
||||
const timeTrackingLogCard =
|
||||
initialTime > 0 ? (
|
||||
<Flex vertical style={{ width: 400, height: 300, overflowY: 'scroll' }}>
|
||||
{mockTimeLogs.map(log => (
|
||||
<React.Fragment key={log.logId}>
|
||||
<Flex gap={8} align="center">
|
||||
<CustomAvatar avatarName={log.username} />
|
||||
|
||||
<Flex vertical>
|
||||
<Typography>
|
||||
<Typography.Text strong>{log.username}</Typography.Text>
|
||||
<Typography.Text>{` logged ${log.duration} ${
|
||||
log.via ? `via ${log.via}` : ''
|
||||
}`}</Typography.Text>
|
||||
</Typography>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{log.date}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
<Divider style={{ marginBlock: 12 }} />
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Flex>
|
||||
) : (
|
||||
<Empty style={{ width: 400 }} />
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex gap={4} align="center">
|
||||
<PlayCircleFilled style={{ color: colors.skyBlue, fontSize: 16 }} />
|
||||
<Popover
|
||||
title={
|
||||
<Typography.Text style={{ fontWeight: 500 }}>
|
||||
Time Tracking Log
|
||||
<Divider style={{ marginBlockStart: 8, marginBlockEnd: 12 }} />
|
||||
</Typography.Text>
|
||||
}
|
||||
content={timeTrackingLogCard}
|
||||
trigger="click"
|
||||
placement="bottomRight"
|
||||
>
|
||||
<Typography.Text style={{ cursor: 'pointer' }}>{formattedTime}</Typography.Text>
|
||||
</Popover>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default TimeTracker;
|
||||
@@ -0,0 +1,42 @@
|
||||
type TimeLog = {
|
||||
logId: number;
|
||||
username: string;
|
||||
duration: string;
|
||||
date: string;
|
||||
via?: string;
|
||||
};
|
||||
|
||||
export const mockTimeLogs: TimeLog[] = [
|
||||
{
|
||||
logId: 1,
|
||||
username: 'Sachintha Prasad',
|
||||
duration: '1h 0m',
|
||||
date: 'Sep 22, 2023, 10:47:02 AM',
|
||||
},
|
||||
{
|
||||
logId: 2,
|
||||
username: 'Sachintha Prasad',
|
||||
duration: '8h 0m',
|
||||
date: 'Sep 22, 2023, 10:47:00 AM',
|
||||
},
|
||||
{
|
||||
logId: 3,
|
||||
username: 'Sachintha Prasad',
|
||||
duration: '6h 0m',
|
||||
date: 'Sep 22, 2023, 10:46:58 AM',
|
||||
},
|
||||
{
|
||||
logId: 4,
|
||||
username: 'Raveesha Dilanka',
|
||||
duration: '1m 4s',
|
||||
date: 'Sep 12, 2023, 8:32:49 AM - Sep 12, 2023, 8:33:53 AM',
|
||||
via: 'Timer',
|
||||
},
|
||||
{
|
||||
logId: 5,
|
||||
username: 'Raveesha Dilanka',
|
||||
duration: '0m 30s',
|
||||
date: 'Sep 12, 2023, 8:30:19 AM - Sep 12, 2023, 8:30:49 AM',
|
||||
via: 'Timer',
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Input } from 'antd';
|
||||
import React, { useState } from 'react';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { colors } from '@/styles/colors';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const AddSubTaskListRow = () => {
|
||||
const [isEdit, setIsEdit] = useState<boolean>(false);
|
||||
|
||||
// localization
|
||||
const { t } = useTranslation('task-list-table');
|
||||
|
||||
// get data theme data from redux
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
const customBorderColor = themeMode === 'dark' && ' border-[#303030]';
|
||||
|
||||
return (
|
||||
<div className={`border-t ${customBorderColor}`}>
|
||||
{isEdit ? (
|
||||
<Input
|
||||
className="h-12 w-full rounded-none"
|
||||
style={{ borderColor: colors.skyBlue }}
|
||||
placeholder={t('addTaskInputPlaceholder')}
|
||||
onBlur={() => setIsEdit(false)}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
onFocus={() => setIsEdit(true)}
|
||||
className="w-[300px] border-none"
|
||||
value={t('addSubTaskText')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddSubTaskListRow;
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Input } from 'antd';
|
||||
import React, { useState } from 'react';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { colors } from '@/styles/colors';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const AddTaskListRow = () => {
|
||||
const [isEdit, setIsEdit] = useState<boolean>(false);
|
||||
|
||||
// localization
|
||||
const { t } = useTranslation('task-list-table');
|
||||
|
||||
// get data theme data from redux
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
const customBorderColor = themeMode === 'dark' && ' border-[#303030]';
|
||||
|
||||
return (
|
||||
<div className={`border-t ${customBorderColor}`}>
|
||||
{isEdit ? (
|
||||
<Input
|
||||
className="h-12 w-full rounded-none"
|
||||
style={{ borderColor: colors.skyBlue }}
|
||||
placeholder={t('addTaskInputPlaceholder')}
|
||||
onBlur={() => setIsEdit(false)}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
onFocus={() => setIsEdit(true)}
|
||||
className="w-[300px] border-none"
|
||||
value={t('addTaskText')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddTaskListRow;
|
||||
@@ -0,0 +1,15 @@
|
||||
/* custom collapse styles for content box and the left border */
|
||||
.ant-collapse-header {
|
||||
margin-bottom: 6px !important;
|
||||
}
|
||||
|
||||
.custom-collapse-content-box .ant-collapse-content-box {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
:where(.css-dev-only-do-not-override-1w6wsvq).ant-collapse-ghost
|
||||
> .ant-collapse-item
|
||||
> .ant-collapse-content
|
||||
> .ant-collapse-content-box {
|
||||
padding: 0;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
.mentions-light .mentions {
|
||||
background-color: #e9e2e2;
|
||||
font-weight: 500;
|
||||
border-radius: 4px;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
|
||||
.mentions-dark .mentions {
|
||||
background-color: #2c2c2c;
|
||||
font-weight: 500;
|
||||
border-radius: 4px;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
|
||||
.tooltip-comment .mentions {
|
||||
background-color: transparent;
|
||||
font-weight: 500;
|
||||
padding: 0;
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
import { Button, ConfigProvider, Flex, Form, Mentions, Skeleton, Space, Tooltip, Typography } from 'antd';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import CustomAvatar from '@components/CustomAvatar';
|
||||
import { colors } from '@/styles/colors';
|
||||
import {
|
||||
IMentionMemberSelectOption,
|
||||
IMentionMemberViewModel,
|
||||
} from '@/types/project/projectComments.types';
|
||||
import { projectsApiService } from '@/api/projects/projects.api.service';
|
||||
import { projectCommentsApiService } from '@/api/projects/comments/project-comments.api.service';
|
||||
import { IProjectUpdateCommentViewModel } from '@/types/project/project.types';
|
||||
import { calculateTimeDifference } from '@/utils/calculate-time-difference';
|
||||
import { getUserSession } from '@/utils/session-helper';
|
||||
import './project-view-updates.css';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { DeleteOutlined } from '@ant-design/icons';
|
||||
|
||||
const MAX_COMMENT_LENGTH = 2000;
|
||||
|
||||
const ProjectViewUpdates = () => {
|
||||
const { projectId } = useParams();
|
||||
const [characterLength, setCharacterLength] = useState<number>(0);
|
||||
const [isCommentBoxExpand, setIsCommentBoxExpand] = useState<boolean>(false);
|
||||
const [members, setMembers] = useState<IMentionMemberViewModel[]>([]);
|
||||
const [selectedMembers, setSelectedMembers] = useState<{ id: string; name: string }[]>([]);
|
||||
const [comments, setComments] = useState<IProjectUpdateCommentViewModel[]>([]);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [isLoadingComments, setIsLoadingComments] = useState<boolean>(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
||||
const [commentValue, setCommentValue] = useState<string>('');
|
||||
const theme = useAppSelector(state => state.themeReducer.mode);
|
||||
const { refreshTimestamp } = useAppSelector(state => state.projectReducer);
|
||||
|
||||
const { t } = useTranslation('project-view-updates');
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const getMembers = useCallback(async () => {
|
||||
if (!projectId) return;
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const res = await projectCommentsApiService.getMentionMembers(projectId, 1, 15, null, null, null);
|
||||
if (res.done) {
|
||||
setMembers(res.body as IMentionMemberViewModel[]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch members:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
const getComments = useCallback(async () => {
|
||||
if (!projectId) return;
|
||||
try {
|
||||
setIsLoadingComments(true);
|
||||
const res = await projectCommentsApiService.getByProjectId(projectId);
|
||||
if (res.done) {
|
||||
setComments(res.body);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch comments:', error);
|
||||
} finally {
|
||||
setIsLoadingComments(false);
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
const handleAddComment = async () => {
|
||||
if (!projectId || characterLength === 0) return;
|
||||
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
|
||||
if (!commentValue) {
|
||||
console.error('Comment content is empty');
|
||||
return;
|
||||
}
|
||||
|
||||
const body = {
|
||||
project_id: projectId,
|
||||
team_id: getUserSession()?.team_id,
|
||||
content: commentValue.trim(),
|
||||
mentions: selectedMembers
|
||||
};
|
||||
|
||||
const res = await projectCommentsApiService.createProjectComment(body);
|
||||
if (res.done) {
|
||||
await getComments();
|
||||
handleCancel();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to add comment:', error);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
setCommentValue('');
|
||||
|
||||
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void getMembers();
|
||||
void getComments();
|
||||
}, [getMembers, getComments,refreshTimestamp]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
form.resetFields(['comment']);
|
||||
setCharacterLength(0);
|
||||
setIsCommentBoxExpand(false);
|
||||
setSelectedMembers([]);
|
||||
}, [form]);
|
||||
|
||||
const mentionsOptions =
|
||||
members?.map(member => ({
|
||||
value: member.id,
|
||||
label: member.name,
|
||||
})) ?? [];
|
||||
|
||||
const memberSelectHandler = useCallback((member: IMentionMemberSelectOption) => {
|
||||
if (!member?.value || !member?.label) return;
|
||||
setSelectedMembers(prev =>
|
||||
prev.some(mention => mention.id === member.value)
|
||||
? prev
|
||||
: [...prev, { id: member.value, name: member.label }]
|
||||
);
|
||||
|
||||
setCommentValue(prev => {
|
||||
const parts = prev.split('@');
|
||||
const lastPart = parts[parts.length - 1];
|
||||
const mentionText = member.label;
|
||||
// Keep only the part before the @ and add the new mention
|
||||
return prev.slice(0, prev.length - lastPart.length) + mentionText;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleCommentChange = useCallback((value: string) => {
|
||||
// Only update the value without trying to replace mentions
|
||||
setCommentValue(value);
|
||||
setCharacterLength(value.trim().length);
|
||||
}, []);
|
||||
|
||||
const handleDeleteComment = useCallback(
|
||||
async (commentId: string | undefined) => {
|
||||
if (!commentId) return;
|
||||
try {
|
||||
const res = await projectCommentsApiService.deleteComment(commentId);
|
||||
if (res.done) {
|
||||
void getComments();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete comment:', error);
|
||||
}
|
||||
},
|
||||
[getComments]
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex gap={24} vertical>
|
||||
<Flex vertical gap={16}>
|
||||
{
|
||||
isLoadingComments ? (
|
||||
<Skeleton active />
|
||||
):
|
||||
comments.map(comment => (
|
||||
<Flex key={comment.id} gap={8}>
|
||||
<CustomAvatar avatarName={comment.created_by || ''} />
|
||||
<Flex vertical flex={1}>
|
||||
<Space>
|
||||
<Typography.Text strong style={{ fontSize: 13, color: colors.lightGray }}>
|
||||
{comment.created_by || ''}
|
||||
</Typography.Text>
|
||||
<Tooltip title={comment.created_at}>
|
||||
<Typography.Text style={{ fontSize: 13, color: colors.deepLightGray }}>
|
||||
{calculateTimeDifference(comment.created_at || '')}
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
<Typography.Paragraph
|
||||
style={{ margin: '8px 0' }}
|
||||
ellipsis={{ rows: 3, expandable: true }}
|
||||
>
|
||||
<div className={`mentions-${theme === 'dark' ? 'dark' : 'light'}`} dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(comment.content || '') }} />
|
||||
</Typography.Paragraph>
|
||||
<ConfigProvider
|
||||
wave={{ disabled: true }}
|
||||
|
||||
theme={{
|
||||
components: {
|
||||
Button: {
|
||||
defaultColor: colors.lightGray,
|
||||
defaultHoverColor: colors.darkGray,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
icon={<DeleteOutlined />}
|
||||
shape="circle"
|
||||
type="text"
|
||||
size='small'
|
||||
onClick={() => handleDeleteComment(comment.id)}
|
||||
/>
|
||||
</ConfigProvider>
|
||||
</Flex>
|
||||
</Flex>
|
||||
))}
|
||||
</Flex>
|
||||
|
||||
<Form onFinish={handleAddComment}>
|
||||
<Form.Item>
|
||||
<Mentions
|
||||
value={commentValue}
|
||||
placeholder={t('inputPlaceholder')}
|
||||
loading={isLoading}
|
||||
options={mentionsOptions}
|
||||
autoSize
|
||||
maxLength={MAX_COMMENT_LENGTH}
|
||||
onSelect={option => memberSelectHandler(option as IMentionMemberSelectOption)}
|
||||
onClick={() => setIsCommentBoxExpand(true)}
|
||||
onChange={handleCommentChange}
|
||||
prefix="@"
|
||||
split=""
|
||||
style={{
|
||||
minHeight: isCommentBoxExpand ? 180 : 60,
|
||||
paddingBlockEnd: 24,
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 4,
|
||||
right: 12,
|
||||
color: colors.lightGray,
|
||||
}}
|
||||
>{`${characterLength}/${MAX_COMMENT_LENGTH}`}</span>
|
||||
</Form.Item>
|
||||
|
||||
{isCommentBoxExpand && (
|
||||
<Form.Item>
|
||||
<Flex gap={8} justify="flex-end">
|
||||
<Button onClick={handleCancel} disabled={isSubmitting}>
|
||||
{t('cancelButton')}
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
loading={isSubmitting}
|
||||
disabled={characterLength === 0}
|
||||
htmlType="submit"
|
||||
>
|
||||
{t('addButton')}
|
||||
</Button>
|
||||
</Flex>
|
||||
</Form.Item>
|
||||
)}
|
||||
</Form>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectViewUpdates;
|
||||
@@ -0,0 +1,7 @@
|
||||
import React from 'react';
|
||||
|
||||
const ProjectViewWorkload = () => {
|
||||
return <div>ProjectViewWorkload</div>;
|
||||
};
|
||||
|
||||
export default ProjectViewWorkload;
|
||||
@@ -0,0 +1,160 @@
|
||||
import { Button, Flex } from 'antd';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { nanoid } from '@reduxjs/toolkit';
|
||||
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { themeWiseColor } from '@/utils/themeWiseColor';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { addBoardSectionCard, fetchBoardTaskGroups, IGroupBy } from '@features/board/board-slice';
|
||||
import { statusApiService } from '@/api/taskAttributes/status/status.api.service';
|
||||
import { ITaskStatusCreateRequest } from '@/types/tasks/task-status-create-request';
|
||||
import { createStatus, fetchStatuses } from '@/features/taskAttributes/taskStatusSlice';
|
||||
import { ALPHA_CHANNEL } from '@/shared/constants';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { phasesApiService } from '@/api/taskAttributes/phases/phases.api.service';
|
||||
|
||||
const BoardCreateSectionCard = () => {
|
||||
const { t } = useTranslation('kanban-board');
|
||||
|
||||
const themeMode = useAppSelector((state) => state.themeReducer.mode);
|
||||
const { projectId } = useAppSelector((state) => state.projectReducer);
|
||||
const groupBy = useAppSelector((state) => state.boardReducer.groupBy);
|
||||
const { statusCategories, status: existingStatuses } = useAppSelector((state) => state.taskStatusReducer);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const getUniqueSectionName = (baseName: string): string => {
|
||||
// Check if the base name already exists
|
||||
const existingNames = existingStatuses.map(status => status.name?.toLowerCase());
|
||||
|
||||
if (!existingNames.includes(baseName.toLowerCase())) {
|
||||
return baseName;
|
||||
}
|
||||
|
||||
// If the base name exists, add a number suffix
|
||||
let counter = 1;
|
||||
let newName = `${baseName.trim()} (${counter})`;
|
||||
|
||||
while (existingNames.includes(newName.toLowerCase())) {
|
||||
counter++;
|
||||
newName = `${baseName.trim()} (${counter})`;
|
||||
}
|
||||
|
||||
return newName;
|
||||
};
|
||||
|
||||
const handleAddSection = async () => {
|
||||
const sectionId = nanoid();
|
||||
const baseNameSection = 'Untitled section';
|
||||
const sectionName = getUniqueSectionName(baseNameSection);
|
||||
|
||||
if (groupBy === IGroupBy.STATUS && projectId) {
|
||||
// Find the "To do" category
|
||||
const todoCategory = statusCategories.find(category =>
|
||||
category.name?.toLowerCase() === 'to do' ||
|
||||
category.name?.toLowerCase() === 'todo'
|
||||
);
|
||||
|
||||
if (todoCategory && todoCategory.id) {
|
||||
// Create a new status
|
||||
const body = {
|
||||
name: sectionName,
|
||||
project_id: projectId,
|
||||
category_id: todoCategory.id,
|
||||
};
|
||||
|
||||
try {
|
||||
// Create the status
|
||||
const response = await dispatch(createStatus({ body, currentProjectId: projectId })).unwrap();
|
||||
|
||||
if (response.done && response.body) {
|
||||
dispatch(
|
||||
addBoardSectionCard({
|
||||
id: response.body.id as string,
|
||||
name: sectionName,
|
||||
colorCode: (response.body.color_code || todoCategory.color_code || '#d8d7d8') + ALPHA_CHANNEL,
|
||||
colorCodeDark: '#989898',
|
||||
})
|
||||
);
|
||||
|
||||
// Refresh the board to show the new section
|
||||
dispatch(fetchBoardTaskGroups(projectId));
|
||||
// Refresh statuses
|
||||
dispatch(fetchStatuses(projectId));
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to create status:', error);
|
||||
}
|
||||
} else {
|
||||
// Fallback if "To do" category not found
|
||||
dispatch(
|
||||
addBoardSectionCard({
|
||||
id: sectionId,
|
||||
name: sectionName,
|
||||
colorCode: '#d8d7d8',
|
||||
colorCodeDark: '#989898',
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (groupBy === IGroupBy.PHASE && projectId) {
|
||||
const body = {
|
||||
name: sectionName,
|
||||
project_id: projectId,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await phasesApiService.addPhaseOption(projectId);
|
||||
if (response.done && response.body) {
|
||||
dispatch(fetchBoardTaskGroups(projectId));
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to create phase:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex
|
||||
vertical
|
||||
gap={16}
|
||||
style={{
|
||||
minWidth: 375,
|
||||
padding: 8,
|
||||
borderRadius: 12,
|
||||
}}
|
||||
className="h-[600px] max-h-[600px] overflow-y-scroll"
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
borderRadius: 6,
|
||||
padding: 8,
|
||||
height: 640,
|
||||
background: themeWiseColor(
|
||||
'linear-gradient( 180deg, #fafafa, rgba(245, 243, 243, 0))',
|
||||
'linear-gradient( 180deg, #2a2b2d, rgba(42, 43, 45, 0))',
|
||||
themeMode
|
||||
),
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
style={{
|
||||
height: '38px',
|
||||
width: '100%',
|
||||
borderRadius: 6,
|
||||
boxShadow: 'none',
|
||||
}}
|
||||
icon={<PlusOutlined />}
|
||||
onClick={handleAddSection}
|
||||
>
|
||||
{t('addSectionButton')}
|
||||
</Button>
|
||||
</div>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default BoardCreateSectionCard;
|
||||
@@ -0,0 +1,377 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Dropdown,
|
||||
Flex,
|
||||
Input,
|
||||
InputRef,
|
||||
Popconfirm,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import {
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
ExclamationCircleFilled,
|
||||
LoadingOutlined,
|
||||
MoreOutlined,
|
||||
PlusOutlined,
|
||||
RetweetOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { MenuProps } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import ChangeCategoryDropdown from '@/components/board/changeCategoryDropdown/ChangeCategoryDropdown';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { colors } from '@/styles/colors';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import {
|
||||
deleteSection,
|
||||
IGroupBy,
|
||||
setBoardGroupName,
|
||||
setEditableSection,
|
||||
} from '@features/board/board-slice';
|
||||
import { themeWiseColor } from '@/utils/themeWiseColor';
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
import useIsProjectManager from '@/hooks/useIsProjectManager';
|
||||
import { useSocket } from '@/socket/socketContext';
|
||||
import { SocketEvents } from '@/shared/socket-events';
|
||||
import { phasesApiService } from '@/api/taskAttributes/phases/phases.api.service';
|
||||
import { fetchPhasesByProjectId } from '@/features/projects/singleProject/phase/phases.slice';
|
||||
import { evt_project_board_column_setting_click } from '@/shared/worklenz-analytics-events';
|
||||
import { ITaskPhase } from '@/types/tasks/taskPhase.types';
|
||||
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
|
||||
import { statusApiService } from '@/api/taskAttributes/status/status.api.service';
|
||||
import { fetchStatuses } from '@/features/taskAttributes/taskStatusSlice';
|
||||
import { updateTaskGroupColor } from '@/features/tasks/tasks.slice';
|
||||
import { ALPHA_CHANNEL } from '@/shared/constants';
|
||||
import { ITaskStatusUpdateModel } from '@/types/tasks/task-status-update-model.types';
|
||||
import { update } from 'lodash';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { toggleDrawer } from '@/features/projects/status/StatusSlice';
|
||||
import { deleteStatusToggleDrawer, seletedStatusCategory } from '@/features/projects/status/DeleteStatusSlice';
|
||||
|
||||
interface BoardSectionCardHeaderProps {
|
||||
groupId: string;
|
||||
name: string;
|
||||
tasksCount: number;
|
||||
isLoading: boolean;
|
||||
setName: (newName: string) => void;
|
||||
colorCode: string;
|
||||
onHoverChange: (hovered: boolean) => void;
|
||||
setShowNewCard: (x: boolean) => void;
|
||||
categoryId: string | null;
|
||||
}
|
||||
|
||||
const BoardSectionCardHeader: React.FC<BoardSectionCardHeaderProps> = ({
|
||||
groupId,
|
||||
name,
|
||||
tasksCount,
|
||||
isLoading,
|
||||
setName,
|
||||
colorCode,
|
||||
onHoverChange,
|
||||
setShowNewCard,
|
||||
categoryId = null,
|
||||
}) => {
|
||||
const { trackMixpanelEvent } = useMixpanelTracking();
|
||||
const isOwnerOrAdmin = useAuthService().isOwnerOrAdmin();
|
||||
const isProjectManager = useIsProjectManager();
|
||||
const [isEditable, setIsEditable] = useState(false);
|
||||
const [editName, setEdit] = useState(name);
|
||||
const [isEllipsisActive, setIsEllipsisActive] = useState(false);
|
||||
const inputRef = useRef<InputRef>(null);
|
||||
|
||||
const { editableSectionId, groupBy } = useAppSelector(state => state.boardReducer);
|
||||
const { projectId } = useAppSelector(state => state.projectReducer);
|
||||
const { statusCategories, status } = useAppSelector(state => state.taskStatusReducer);
|
||||
|
||||
const { t } = useTranslation('kanban-board');
|
||||
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditable && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
inputRef.current.select();
|
||||
}
|
||||
}, [isEditable]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editableSectionId === groupId && (isProjectManager || isOwnerOrAdmin)) {
|
||||
setIsEditable(true);
|
||||
dispatch(setEditableSection(null));
|
||||
}
|
||||
}, [editableSectionId, groupId, dispatch]);
|
||||
|
||||
const getUniqueSectionName = (baseName: string): string => {
|
||||
// Check if the base name already exists
|
||||
const existingNames = status.map(status => status.name?.toLowerCase());
|
||||
|
||||
if (!existingNames.includes(baseName.toLowerCase())) {
|
||||
return baseName;
|
||||
}
|
||||
|
||||
// If the base name exists, add a number suffix
|
||||
let counter = 1;
|
||||
let newName = `${baseName.trim()} (${counter})`;
|
||||
|
||||
while (existingNames.includes(newName.toLowerCase())) {
|
||||
counter++;
|
||||
newName = `${baseName.trim()} (${counter})`;
|
||||
}
|
||||
|
||||
return newName;
|
||||
};
|
||||
|
||||
const updateStatus = async (category = categoryId) => {
|
||||
if (!category || !projectId || !groupId) return;
|
||||
const sectionName = getUniqueSectionName(name);
|
||||
const body: ITaskStatusUpdateModel = {
|
||||
name: sectionName,
|
||||
project_id: projectId,
|
||||
category_id: category,
|
||||
};
|
||||
const res = await statusApiService.updateStatus(groupId, body, projectId);
|
||||
if (res.done) {
|
||||
dispatch(
|
||||
setBoardGroupName({
|
||||
groupId,
|
||||
name: sectionName ?? '',
|
||||
colorCode: res.body.color_code ?? '',
|
||||
colorCodeDark: res.body.color_code_dark ?? '',
|
||||
categoryId: category,
|
||||
})
|
||||
);
|
||||
dispatch(fetchStatuses(projectId));
|
||||
setName(sectionName);
|
||||
} else {
|
||||
setName(editName);
|
||||
logger.error('Error updating status', res.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const taskName = e.target.value;
|
||||
setName(taskName);
|
||||
};
|
||||
|
||||
const handleBlur = async () => {
|
||||
if (name === 'Untitled section') {
|
||||
dispatch(deleteSection({ sectionId: groupId }));
|
||||
}
|
||||
setIsEditable(false);
|
||||
|
||||
if (!projectId || !groupId) return;
|
||||
|
||||
if (groupBy === IGroupBy.STATUS) {
|
||||
await updateStatus();
|
||||
}
|
||||
|
||||
if (groupBy === IGroupBy.PHASE) {
|
||||
const body = {
|
||||
id: groupId,
|
||||
name: name,
|
||||
};
|
||||
|
||||
const res = await phasesApiService.updateNameOfPhase(groupId, body as ITaskPhase, projectId);
|
||||
if (res.done) {
|
||||
trackMixpanelEvent(evt_project_board_column_setting_click, { Rename: 'Phase' });
|
||||
// dispatch(fetchPhasesByProjectId(projectId));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handlePressEnter = () => {
|
||||
setShowNewCard(true);
|
||||
setIsEditable(false);
|
||||
handleBlur();
|
||||
};
|
||||
|
||||
const handleDeleteSection = async () => {
|
||||
if (!projectId || !groupId) return;
|
||||
|
||||
try {
|
||||
if (groupBy === IGroupBy.STATUS) {
|
||||
const replacingStatusId = '';
|
||||
const res = await statusApiService.deleteStatus(groupId, projectId, replacingStatusId);
|
||||
if (res.message === 'At least one status should exists under each category.') return
|
||||
if (res.done) {
|
||||
dispatch(deleteSection({ sectionId: groupId }));
|
||||
} else {
|
||||
dispatch(seletedStatusCategory({ id: groupId, name: name, category_id: categoryId ?? '', message: res.message ?? '' }));
|
||||
dispatch(deleteStatusToggleDrawer());
|
||||
}
|
||||
} else if (groupBy === IGroupBy.PHASE) {
|
||||
const res = await phasesApiService.deletePhaseOption(groupId, projectId);
|
||||
if (res.done) {
|
||||
dispatch(deleteSection({ sectionId: groupId }));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error deleting section', error);
|
||||
}
|
||||
};
|
||||
|
||||
const items: MenuProps['items'] = [
|
||||
{
|
||||
key: '1',
|
||||
label: (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-start',
|
||||
width: '100%',
|
||||
gap: '8px',
|
||||
}}
|
||||
onClick={() => setIsEditable(true)}
|
||||
>
|
||||
<EditOutlined /> <span>{t('rename')}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
groupBy === IGroupBy.STATUS && {
|
||||
key: '2',
|
||||
icon: <RetweetOutlined />,
|
||||
label: 'Change category',
|
||||
children: statusCategories?.map(status => ({
|
||||
key: status.id,
|
||||
label: (
|
||||
<Flex
|
||||
gap={8}
|
||||
onClick={() => status.id && updateStatus(status.id)}
|
||||
style={categoryId === status.id ? { fontWeight: 700 } : {}}
|
||||
>
|
||||
<Badge color={status.color_code} />
|
||||
{status.name}
|
||||
</Flex>
|
||||
),
|
||||
})),
|
||||
},
|
||||
groupBy !== IGroupBy.PRIORITY && {
|
||||
key: '3',
|
||||
label: (
|
||||
<Popconfirm
|
||||
title={t('deleteConfirmationTitle')}
|
||||
icon={<ExclamationCircleFilled style={{ color: colors.vibrantOrange }} />}
|
||||
okText={t('deleteConfirmationOk')}
|
||||
cancelText={t('deleteConfirmationCancel')}
|
||||
onConfirm={handleDeleteSection}
|
||||
>
|
||||
<Flex gap={8} align="center" style={{ width: '100%' }}>
|
||||
<DeleteOutlined />
|
||||
{t('delete')}
|
||||
</Flex>
|
||||
</Popconfirm>
|
||||
),
|
||||
},
|
||||
].filter(Boolean) as MenuProps['items'];
|
||||
|
||||
return (
|
||||
<Flex
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
padding: '8px',
|
||||
backgroundColor: colorCode,
|
||||
borderRadius: 6,
|
||||
}}
|
||||
onMouseEnter={() => onHoverChange(true)}
|
||||
onMouseLeave={() => onHoverChange(false)}
|
||||
>
|
||||
<Flex
|
||||
gap={8}
|
||||
align="center"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
if ((isProjectManager || isOwnerOrAdmin) && name !== 'Unmapped') setIsEditable(true);
|
||||
}}
|
||||
>
|
||||
<Flex
|
||||
align="center"
|
||||
justify="center"
|
||||
style={{
|
||||
minWidth: 26,
|
||||
height: 26,
|
||||
borderRadius: 120,
|
||||
backgroundColor: themeWiseColor('white', '#1e1e1e', themeMode),
|
||||
}}
|
||||
>
|
||||
{tasksCount}
|
||||
</Flex>
|
||||
|
||||
{isLoading && <LoadingOutlined style={{ color: colors.darkGray }} />}
|
||||
{isEditable ? (
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={name}
|
||||
variant="borderless"
|
||||
style={{
|
||||
backgroundColor: themeWiseColor('white', '#1e1e1e', themeMode),
|
||||
}}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
onPressEnter={handlePressEnter}
|
||||
/>
|
||||
) : (
|
||||
<Tooltip title={isEllipsisActive ? name : null}>
|
||||
<Typography.Text
|
||||
ellipsis={{
|
||||
tooltip: false,
|
||||
onEllipsis: ellipsed => setIsEllipsisActive(ellipsed),
|
||||
}}
|
||||
style={{
|
||||
minWidth: 200,
|
||||
textTransform: 'capitalize',
|
||||
color: themeMode === 'dark' ? '#383838' : '',
|
||||
display: 'inline-block',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
<div style={{ display: 'flex' }}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
shape="circle"
|
||||
style={{ color: themeMode === 'dark' ? '#383838' : '' }}
|
||||
onClick={() => setShowNewCard(true)}
|
||||
>
|
||||
<PlusOutlined />
|
||||
</Button>
|
||||
|
||||
{(isOwnerOrAdmin || isProjectManager) && name !== 'Unmapped' && (
|
||||
<Dropdown
|
||||
overlayClassName="todo-threedot-dropdown"
|
||||
trigger={['click']}
|
||||
menu={{ items }}
|
||||
placement="bottomLeft"
|
||||
>
|
||||
<Button type="text" size="small" shape="circle">
|
||||
<MoreOutlined
|
||||
style={{
|
||||
rotate: '90deg',
|
||||
fontSize: '25px',
|
||||
color: themeMode === 'dark' ? '#383838' : '',
|
||||
}}
|
||||
/>
|
||||
</Button>
|
||||
</Dropdown>
|
||||
)}
|
||||
</div>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default BoardSectionCardHeader;
|
||||
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import { Button, Flex } from 'antd';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDroppable } from '@dnd-kit/core';
|
||||
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { themeWiseColor } from '@/utils/themeWiseColor';
|
||||
import BoardSectionCardHeader from './board-section-card-header';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import BoardViewTaskCard from '../board-task-card/board-view-task-card';
|
||||
import BoardViewCreateTaskCard from '../board-task-card/board-view-create-task-card';
|
||||
import { ITaskListGroup } from '@/types/tasks/taskList.types';
|
||||
import { DEFAULT_TASK_NAME } from '@/shared/constants';
|
||||
import { ITaskCreateRequest } from '@/types/tasks/task-create-request.types';
|
||||
import { useSocket } from '@/socket/socketContext';
|
||||
import { SocketEvents } from '@/shared/socket-events';
|
||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||
import logger from '@/utils/errorLogger';
|
||||
|
||||
interface IBoardSectionCardProps {
|
||||
taskGroup: ITaskListGroup;
|
||||
}
|
||||
|
||||
const BoardSectionCard = ({ taskGroup }: IBoardSectionCardProps) => {
|
||||
const { t } = useTranslation('kanban-board');
|
||||
const scrollContainerRef = useRef<any>(null);
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
const { projectId } = useAppSelector(state => state.projectReducer);
|
||||
const { team_id: teamId, id: reporterId } = useAppSelector(state => state.userReducer);
|
||||
const { socket } = useSocket();
|
||||
|
||||
const [name, setName] = useState<string>(taskGroup.name);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [isHover, setIsHover] = useState<boolean>(false);
|
||||
const [showNewCardTop, setShowNewCardTop] = useState<boolean>(false);
|
||||
const [showNewCardBottom, setShowNewCardBottom] = useState<boolean>(false);
|
||||
const [creatingTempTask, setCreatingTempTask] = useState<boolean>(false);
|
||||
|
||||
const { setNodeRef: setDroppableRef } = useDroppable({
|
||||
id: taskGroup.id,
|
||||
data: {
|
||||
type: 'section',
|
||||
section: taskGroup,
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef: setSortableRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({
|
||||
id: taskGroup.id,
|
||||
data: {
|
||||
type: 'section',
|
||||
section: taskGroup,
|
||||
},
|
||||
});
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
};
|
||||
|
||||
const setRefs = (el: HTMLElement | null) => {
|
||||
setSortableRef(el);
|
||||
setDroppableRef(el);
|
||||
};
|
||||
|
||||
const getInstantTask = async ({
|
||||
task_id,
|
||||
group_id,
|
||||
task,
|
||||
}: {
|
||||
task_id: string;
|
||||
group_id: string;
|
||||
task: IProjectTask;
|
||||
}) => {
|
||||
try {
|
||||
} catch (error) {
|
||||
logger.error('Error creating instant task', error);
|
||||
}
|
||||
};
|
||||
|
||||
const createTempTask = async () => {
|
||||
if (creatingTempTask || !projectId) return;
|
||||
setCreatingTempTask(true);
|
||||
|
||||
const body: ITaskCreateRequest = {
|
||||
name: DEFAULT_TASK_NAME,
|
||||
project_id: projectId,
|
||||
team_id: teamId,
|
||||
reporter_id: reporterId,
|
||||
status_id: taskGroup.id,
|
||||
};
|
||||
|
||||
socket?.emit(SocketEvents.QUICK_TASK.toString(), JSON.stringify(body));
|
||||
};
|
||||
|
||||
const handleAddTaskToBottom = () => {
|
||||
// createTempTask();
|
||||
setShowNewCardBottom(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (showNewCardBottom && scrollContainerRef.current) {
|
||||
const timeout = setTimeout(() => {
|
||||
scrollContainerRef.current.scrollTop = scrollContainerRef.current.scrollHeight;
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}
|
||||
}, [taskGroup.tasks, showNewCardBottom]);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
vertical
|
||||
gap={16}
|
||||
ref={setRefs}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
style={{
|
||||
...style,
|
||||
minWidth: 375,
|
||||
outline: isHover
|
||||
? `1px solid ${themeWiseColor('#edeae9', '#ffffff12', themeMode)}`
|
||||
: 'none',
|
||||
padding: 8,
|
||||
borderRadius: 12,
|
||||
}}
|
||||
className="h-[600px] max-h-[600px] overflow-y-scroll board-section"
|
||||
data-section-id={taskGroup.id}
|
||||
data-droppable="true"
|
||||
data-over="false"
|
||||
>
|
||||
<BoardSectionCardHeader
|
||||
groupId={taskGroup.id}
|
||||
key={taskGroup.id}
|
||||
categoryId={taskGroup.category_id ?? null}
|
||||
name={name}
|
||||
tasksCount={taskGroup?.tasks.length}
|
||||
isLoading={isLoading}
|
||||
setName={setName}
|
||||
colorCode={themeWiseColor(taskGroup?.color_code, taskGroup?.color_code_dark, themeMode)}
|
||||
onHoverChange={setIsHover}
|
||||
setShowNewCard={setShowNewCardTop}
|
||||
/>
|
||||
|
||||
<Flex
|
||||
vertical
|
||||
gap={16}
|
||||
ref={scrollContainerRef}
|
||||
style={{
|
||||
borderRadius: 6,
|
||||
height: taskGroup?.tasks.length <= 0 ? 600 : 'auto',
|
||||
maxHeight: taskGroup?.tasks.length <= 0 ? 600 : 'auto',
|
||||
overflowY: 'scroll',
|
||||
padding: taskGroup?.tasks.length <= 0 ? 8 : 6,
|
||||
background:
|
||||
taskGroup?.tasks.length <= 0 && !showNewCardTop && !showNewCardBottom
|
||||
? themeWiseColor(
|
||||
'linear-gradient( 180deg, #fafafa, rgba(245, 243, 243, 0))',
|
||||
'linear-gradient( 180deg, #2a2b2d, rgba(42, 43, 45, 0))',
|
||||
themeMode
|
||||
)
|
||||
: 'transparent',
|
||||
}}
|
||||
>
|
||||
<SortableContext
|
||||
items={taskGroup.tasks.map(task => task.id ?? '')}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<Flex vertical gap={16} align="center">
|
||||
{showNewCardTop && (
|
||||
<BoardViewCreateTaskCard
|
||||
position="top"
|
||||
sectionId={taskGroup.id}
|
||||
setShowNewCard={setShowNewCardTop}
|
||||
/>
|
||||
)}
|
||||
|
||||
{taskGroup.tasks.map((task: any) => (
|
||||
<BoardViewTaskCard key={task.id} sectionId={taskGroup.id} task={task} />
|
||||
))}
|
||||
|
||||
{showNewCardBottom && (
|
||||
<BoardViewCreateTaskCard
|
||||
position="bottom"
|
||||
sectionId={taskGroup.id}
|
||||
setShowNewCard={setShowNewCardBottom}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
</SortableContext>
|
||||
|
||||
<Button
|
||||
type="text"
|
||||
style={{
|
||||
height: '38px',
|
||||
width: '100%',
|
||||
borderRadius: 6,
|
||||
boxShadow: 'none',
|
||||
}}
|
||||
icon={<PlusOutlined />}
|
||||
onClick={handleAddTaskToBottom}
|
||||
>
|
||||
{t('addTask')}
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default BoardSectionCard;
|
||||
@@ -0,0 +1,116 @@
|
||||
import { Flex } from 'antd';
|
||||
import { SortableContext, horizontalListSortingStrategy } from '@dnd-kit/sortable';
|
||||
import BoardSectionCard from './board-section-card/board-section-card';
|
||||
import BoardCreateSectionCard from './board-section-card/board-create-section-card';
|
||||
import { ITaskListGroup } from '@/types/tasks/taskList.types';
|
||||
import { useEffect } from 'react';
|
||||
import { setTaskAssignee, setTaskEndDate } from '@/features/task-drawer/task-drawer.slice';
|
||||
import { fetchTaskAssignees } from '@/features/taskAttributes/taskMemberSlice';
|
||||
import { SocketEvents } from '@/shared/socket-events';
|
||||
import { ITaskAssigneesUpdateResponse } from '@/types/tasks/task-assignee-update-response';
|
||||
import { useSocket } from '@/socket/socketContext';
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { updateTaskAssignees, updateTaskEndDate } from '@/features/board/board-slice';
|
||||
import useIsProjectManager from '@/hooks/useIsProjectManager';
|
||||
|
||||
const BoardSectionCardContainer = ({
|
||||
datasource,
|
||||
group,
|
||||
}: {
|
||||
datasource: ITaskListGroup[];
|
||||
group: 'status' | 'priority' | 'phases' | 'members';
|
||||
}) => {
|
||||
const { socket } = useSocket();
|
||||
const dispatch = useAppDispatch();
|
||||
const currentSession = useAuthService().getCurrentSession();
|
||||
const { taskGroups } = useAppSelector(state => state.boardReducer);
|
||||
const { loadingAssignees } = useAppSelector(state => state.taskReducer);
|
||||
const isOwnerorAdmin = useAuthService().isOwnerOrAdmin();
|
||||
const isProjectManager = useIsProjectManager();
|
||||
|
||||
// Socket handler for assignee updates
|
||||
useEffect(() => {
|
||||
if (!socket) return;
|
||||
|
||||
const handleAssigneesUpdate = (data: ITaskAssigneesUpdateResponse) => {
|
||||
if (!data) return;
|
||||
|
||||
const updatedAssignees = data.assignees.map(assignee => ({
|
||||
...assignee,
|
||||
selected: true,
|
||||
}));
|
||||
|
||||
// Find the group that contains the task or its subtasks
|
||||
const groupId = taskGroups.find(group =>
|
||||
group.tasks.some(
|
||||
task =>
|
||||
task.id === data.id ||
|
||||
(task.sub_tasks && task.sub_tasks.some(subtask => subtask.id === data.id))
|
||||
)
|
||||
)?.id;
|
||||
|
||||
if (groupId) {
|
||||
dispatch(
|
||||
updateTaskAssignees({
|
||||
groupId,
|
||||
taskId: data.id,
|
||||
assignees: updatedAssignees,
|
||||
names: data.names,
|
||||
})
|
||||
);
|
||||
|
||||
dispatch(setTaskAssignee(data));
|
||||
|
||||
if (currentSession?.team_id && !loadingAssignees) {
|
||||
dispatch(fetchTaskAssignees(currentSession.team_id));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
socket.on(SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(), handleAssigneesUpdate);
|
||||
return () => {
|
||||
socket.off(SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(), handleAssigneesUpdate);
|
||||
};
|
||||
}, [socket, currentSession?.team_id, loadingAssignees, taskGroups, dispatch]);
|
||||
|
||||
// Socket handler for due date updates
|
||||
useEffect(() => {
|
||||
if (!socket) return;
|
||||
|
||||
const handleEndDateChange = (task: {
|
||||
id: string;
|
||||
parent_task: string | null;
|
||||
end_date: string;
|
||||
}) => {
|
||||
dispatch(updateTaskEndDate({ task }));
|
||||
dispatch(setTaskEndDate(task));
|
||||
};
|
||||
|
||||
socket.on(SocketEvents.TASK_END_DATE_CHANGE.toString(), handleEndDateChange);
|
||||
|
||||
return () => {
|
||||
socket.off(SocketEvents.TASK_END_DATE_CHANGE.toString(), handleEndDateChange);
|
||||
};
|
||||
}, [socket, dispatch]);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
gap={16}
|
||||
align="flex-start"
|
||||
className="max-w-screen max-h-[620px] min-h-[620px] overflow-x-scroll p-[1px]"
|
||||
>
|
||||
<SortableContext
|
||||
items={datasource?.map((section: any) => section.id)}
|
||||
strategy={horizontalListSortingStrategy}
|
||||
>
|
||||
{datasource?.map((data: any) => <BoardSectionCard key={data.id} taskGroup={data} />)}
|
||||
</SortableContext>
|
||||
|
||||
{(group !== 'priority' && (isOwnerorAdmin || isProjectManager)) && <BoardCreateSectionCard />}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default BoardSectionCardContainer;
|
||||
@@ -0,0 +1,172 @@
|
||||
import { Flex, Input, InputRef } from 'antd';
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import {
|
||||
addSubtask,
|
||||
GROUP_BY_PHASE_VALUE,
|
||||
GROUP_BY_PRIORITY_VALUE,
|
||||
GROUP_BY_STATUS_VALUE,
|
||||
updateSubtask,
|
||||
updateTaskProgress,
|
||||
} from '@features/board/board-slice';
|
||||
import { themeWiseColor } from '@/utils/themeWiseColor';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { getCurrentGroup } from '@/features/tasks/tasks.slice';
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
import { ITaskCreateRequest } from '@/types/tasks/task-create-request.types';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useSocket } from '@/socket/socketContext';
|
||||
import { SocketEvents } from '@/shared/socket-events';
|
||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||
import logger from '@/utils/errorLogger';
|
||||
|
||||
type BoardCreateSubtaskCardProps = {
|
||||
sectionId: string;
|
||||
parentTaskId: string;
|
||||
setShowNewSubtaskCard: (x: boolean) => void;
|
||||
};
|
||||
|
||||
const BoardCreateSubtaskCard = ({
|
||||
sectionId,
|
||||
parentTaskId,
|
||||
setShowNewSubtaskCard,
|
||||
}: BoardCreateSubtaskCardProps) => {
|
||||
const { socket, connected } = useSocket();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const [creatingTask, setCreatingTask] = useState<boolean>(false);
|
||||
const [newSubtaskName, setNewSubtaskName] = useState<string>('');
|
||||
const [isEnterKeyPressed, setIsEnterKeyPressed] = useState<boolean>(false);
|
||||
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<InputRef>(null);
|
||||
|
||||
const { t } = useTranslation('kanban-board');
|
||||
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
const { projectId } = useParams();
|
||||
const currentSession = useAuthService().getCurrentSession();
|
||||
|
||||
const createRequestBody = (): ITaskCreateRequest | null => {
|
||||
if (!projectId || !currentSession) return null;
|
||||
const body: ITaskCreateRequest = {
|
||||
project_id: projectId,
|
||||
name: newSubtaskName,
|
||||
reporter_id: currentSession.id,
|
||||
team_id: currentSession.team_id,
|
||||
};
|
||||
|
||||
const groupBy = getCurrentGroup();
|
||||
if (groupBy.value === GROUP_BY_STATUS_VALUE) {
|
||||
body.status_id = sectionId || undefined;
|
||||
} else if (groupBy.value === GROUP_BY_PRIORITY_VALUE) {
|
||||
body.priority_id = sectionId || undefined;
|
||||
} else if (groupBy.value === GROUP_BY_PHASE_VALUE) {
|
||||
body.phase_id = sectionId || undefined;
|
||||
}
|
||||
|
||||
if (parentTaskId) {
|
||||
body.parent_task_id = parentTaskId;
|
||||
}
|
||||
return body;
|
||||
};
|
||||
|
||||
const handleAddSubtask = () => {
|
||||
if (creatingTask || !projectId || !currentSession || newSubtaskName.trim() === '' || !connected)
|
||||
return;
|
||||
|
||||
try {
|
||||
setCreatingTask(true);
|
||||
const body = createRequestBody();
|
||||
if (!body) return;
|
||||
|
||||
socket?.emit(SocketEvents.QUICK_TASK.toString(), JSON.stringify(body));
|
||||
socket?.once(SocketEvents.QUICK_TASK.toString(), (task: IProjectTask) => {
|
||||
if (!task) return;
|
||||
|
||||
dispatch(updateSubtask({ sectionId, subtask: task, mode: 'add' }));
|
||||
setCreatingTask(false);
|
||||
// Clear the input field after successful task creation
|
||||
setNewSubtaskName('');
|
||||
// Focus back to the input field for adding another subtask
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
}, 0);
|
||||
if (task.parent_task_id) {
|
||||
socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.parent_task_id);
|
||||
socket?.once(SocketEvents.GET_TASK_PROGRESS.toString(), (data: {
|
||||
id: string;
|
||||
complete_ratio: number;
|
||||
completed_count: number;
|
||||
total_tasks_count: number;
|
||||
parent_task: string;
|
||||
}) => {
|
||||
if (!data.parent_task) data.parent_task = task.parent_task_id || '';
|
||||
dispatch(updateTaskProgress(data));
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error adding task:', error);
|
||||
setCreatingTask(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
setIsEnterKeyPressed(true);
|
||||
handleAddSubtask();
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputBlur = () => {
|
||||
if (!isEnterKeyPressed && newSubtaskName.length > 0) {
|
||||
handleAddSubtask();
|
||||
}
|
||||
setIsEnterKeyPressed(false);
|
||||
};
|
||||
|
||||
const handleCancelNewCard = (e: React.FocusEvent<HTMLDivElement>) => {
|
||||
if (cardRef.current && !cardRef.current.contains(e.relatedTarget)) {
|
||||
setNewSubtaskName('');
|
||||
setShowNewSubtaskCard(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex
|
||||
ref={cardRef}
|
||||
vertical
|
||||
gap={12}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: 12,
|
||||
backgroundColor: themeMode === 'dark' ? '#292929' : '#fafafa',
|
||||
borderRadius: 6,
|
||||
cursor: 'pointer',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
className={`outline-1 ${themeWiseColor('outline-[#edeae9]', 'outline-[#6a696a]', themeMode)} hover:outline`}
|
||||
onBlur={handleCancelNewCard}
|
||||
>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
autoFocus
|
||||
value={newSubtaskName}
|
||||
onChange={e => setNewSubtaskName(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleInputBlur}
|
||||
placeholder={t('newSubtaskNamePlaceholder')}
|
||||
style={{
|
||||
width: '100%',
|
||||
borderRadius: 6,
|
||||
padding: 8,
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default BoardCreateSubtaskCard;
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useState } from 'react';
|
||||
import dayjs, { Dayjs } from 'dayjs';
|
||||
import { Col, Flex, Typography, List } from 'antd';
|
||||
import CustomAvatarGroup from '@/components/board/custom-avatar-group';
|
||||
import CustomDueDatePicker from '@/components/board/custom-due-date-picker';
|
||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { setSelectedTaskId, setShowTaskDrawer } from '@/features/task-drawer/task-drawer.slice';
|
||||
|
||||
interface IBoardSubTaskCardProps {
|
||||
subtask: IProjectTask;
|
||||
sectionId: string;
|
||||
}
|
||||
|
||||
const BoardSubTaskCard = ({ subtask, sectionId }: IBoardSubTaskCardProps) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const [subtaskDueDate, setSubtaskDueDate] = useState<Dayjs | null>(
|
||||
subtask?.end_date ? dayjs(subtask?.end_date) : null
|
||||
);
|
||||
|
||||
const handleCardClick = (e: React.MouseEvent, id: string) => {
|
||||
// Prevent the event from propagating to parent elements
|
||||
e.stopPropagation();
|
||||
|
||||
// Add a small delay to ensure it's a click and not the start of a drag
|
||||
const clickTimeout = setTimeout(() => {
|
||||
dispatch(setSelectedTaskId(id));
|
||||
dispatch(setShowTaskDrawer(true));
|
||||
}, 50);
|
||||
|
||||
return () => clearTimeout(clickTimeout);
|
||||
};
|
||||
|
||||
return (
|
||||
<List.Item
|
||||
key={subtask.id}
|
||||
className="group"
|
||||
style={{
|
||||
width: '100%',
|
||||
}}
|
||||
onClick={e => handleCardClick(e, subtask.id || '')}
|
||||
>
|
||||
<Col span={10}>
|
||||
<Typography.Text
|
||||
style={{ fontWeight: 500, fontSize: 14 }}
|
||||
delete={subtask.status === 'done'}
|
||||
ellipsis={{ expanded: false }}
|
||||
>
|
||||
{subtask.name}
|
||||
</Typography.Text>
|
||||
</Col>
|
||||
|
||||
<Flex gap={8} justify="end" style={{ width: '100%' }}>
|
||||
<CustomAvatarGroup task={subtask} sectionId={sectionId} />
|
||||
|
||||
<CustomDueDatePicker task={subtask} onDateChange={setSubtaskDueDate} />
|
||||
</Flex>
|
||||
</List.Item>
|
||||
);
|
||||
};
|
||||
|
||||
export default BoardSubTaskCard;
|
||||
@@ -0,0 +1,248 @@
|
||||
import { Button, Flex, Input, InputRef } from 'antd';
|
||||
import React, { useRef, useState, useEffect } from 'react';
|
||||
import { Dayjs } from 'dayjs';
|
||||
import { nanoid } from '@reduxjs/toolkit';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import {
|
||||
addTaskCardToTheBottom,
|
||||
addTaskCardToTheTop,
|
||||
getCurrentGroupBoard,
|
||||
GROUP_BY_STATUS_VALUE,
|
||||
GROUP_BY_PRIORITY_VALUE,
|
||||
GROUP_BY_PHASE_VALUE,
|
||||
} from '@features/board/board-slice';
|
||||
import { themeWiseColor } from '@/utils/themeWiseColor';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import CustomDueDatePicker from '@/components/board/custom-due-date-picker';
|
||||
import AddMembersDropdown from '@/components/add-members-dropdown-v2/add-members-dropdown';
|
||||
import { useSocket } from '@/socket/socketContext';
|
||||
import { SocketEvents } from '@/shared/socket-events';
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||
import { ITaskCreateRequest } from '@/types/tasks/task-create-request.types';
|
||||
|
||||
type BoardViewCreateTaskCardProps = {
|
||||
position: 'top' | 'bottom';
|
||||
sectionId: string;
|
||||
setShowNewCard: (x: boolean) => void;
|
||||
};
|
||||
|
||||
const BoardViewCreateTaskCard = ({
|
||||
position,
|
||||
sectionId,
|
||||
setShowNewCard,
|
||||
}: BoardViewCreateTaskCardProps) => {
|
||||
const { t } = useTranslation('kanban-board');
|
||||
const dispatch = useAppDispatch();
|
||||
const { socket } = useSocket();
|
||||
const currentSession = useAuthService().getCurrentSession();
|
||||
|
||||
const [newTaskName, setNewTaskName] = useState<string>('');
|
||||
const [dueDate, setDueDate] = useState<Dayjs | null>(null);
|
||||
const [creatingTask, setCreatingTask] = useState<boolean>(false);
|
||||
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<InputRef>(null);
|
||||
|
||||
const focusInput = () => {
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
}, 100);
|
||||
};
|
||||
|
||||
// Focus when component mounts or when showNewCard becomes true
|
||||
useEffect(() => {
|
||||
focusInput();
|
||||
}, []);
|
||||
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
const projectId = useAppSelector(state => state.projectReducer.projectId);
|
||||
|
||||
const createRequestBody = (): ITaskCreateRequest | null => {
|
||||
if (!projectId || !currentSession) return null;
|
||||
|
||||
const body: ITaskCreateRequest = {
|
||||
project_id: projectId,
|
||||
name: newTaskName.trim(),
|
||||
reporter_id: currentSession.id,
|
||||
team_id: currentSession.team_id,
|
||||
};
|
||||
|
||||
// Set end date if provided
|
||||
if (dueDate) {
|
||||
body.end_date = dueDate.toISOString();
|
||||
}
|
||||
|
||||
// Set the appropriate group ID based on the current grouping
|
||||
const groupBy = getCurrentGroupBoard();
|
||||
if (groupBy.value === GROUP_BY_STATUS_VALUE) {
|
||||
body.status_id = sectionId;
|
||||
} else if (groupBy.value === GROUP_BY_PRIORITY_VALUE) {
|
||||
body.priority_id = sectionId;
|
||||
} else if (groupBy.value === GROUP_BY_PHASE_VALUE) {
|
||||
body.phase_id = sectionId;
|
||||
}
|
||||
|
||||
return body;
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setNewTaskName('');
|
||||
setDueDate(null);
|
||||
setCreatingTask(false);
|
||||
setShowNewCard(true);
|
||||
focusInput();
|
||||
};
|
||||
|
||||
const handleAddTaskToTheTop = async () => {
|
||||
if (creatingTask || !projectId || !currentSession || newTaskName.trim() === '') return;
|
||||
|
||||
try {
|
||||
setCreatingTask(true);
|
||||
const body = createRequestBody();
|
||||
if (!body) return;
|
||||
|
||||
// Create a unique event handler for this specific task creation
|
||||
const eventHandler = (task: IProjectTask) => {
|
||||
// Set creating task to false
|
||||
setCreatingTask(false);
|
||||
|
||||
// Add the task to the state at the top of the section
|
||||
dispatch(
|
||||
addTaskCardToTheTop({
|
||||
sectionId: sectionId,
|
||||
task: {
|
||||
...task,
|
||||
id: task.id || nanoid(),
|
||||
name: task.name || newTaskName.trim(),
|
||||
end_date: task.end_date || dueDate,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
// Remove the event listener to prevent memory leaks
|
||||
socket?.off(SocketEvents.QUICK_TASK.toString(), eventHandler);
|
||||
|
||||
// Reset the form
|
||||
resetForm();
|
||||
};
|
||||
|
||||
// Register the event handler before emitting
|
||||
socket?.once(SocketEvents.QUICK_TASK.toString(), eventHandler);
|
||||
|
||||
// Emit the event
|
||||
socket?.emit(SocketEvents.QUICK_TASK.toString(), JSON.stringify(body));
|
||||
} catch (error) {
|
||||
console.error('Error adding task:', error);
|
||||
setCreatingTask(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddTaskToTheBottom = async () => {
|
||||
if (creatingTask || !projectId || !currentSession || newTaskName.trim() === '') return;
|
||||
|
||||
try {
|
||||
setCreatingTask(true);
|
||||
const body = createRequestBody();
|
||||
if (!body) return;
|
||||
|
||||
// Create a unique event handler for this specific task creation
|
||||
const eventHandler = (task: IProjectTask) => {
|
||||
// Set creating task to false
|
||||
setCreatingTask(false);
|
||||
|
||||
// Add the task to the state at the bottom of the section
|
||||
dispatch(
|
||||
addTaskCardToTheBottom({
|
||||
sectionId: sectionId,
|
||||
task: {
|
||||
...task,
|
||||
id: task.id || nanoid(),
|
||||
name: task.name || newTaskName.trim(),
|
||||
end_date: task.end_date || dueDate,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
// Remove the event listener to prevent memory leaks
|
||||
socket?.off(SocketEvents.QUICK_TASK.toString(), eventHandler);
|
||||
|
||||
// Reset the form
|
||||
resetForm();
|
||||
};
|
||||
|
||||
// Register the event handler before emitting
|
||||
socket?.once(SocketEvents.QUICK_TASK.toString(), eventHandler);
|
||||
|
||||
// Emit the event
|
||||
socket?.emit(SocketEvents.QUICK_TASK.toString(), JSON.stringify(body));
|
||||
} catch (error) {
|
||||
console.error('Error adding task:', error);
|
||||
setCreatingTask(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelNewCard = (e: React.FocusEvent<HTMLDivElement>) => {
|
||||
if (cardRef.current && !cardRef.current.contains(e.relatedTarget)) {
|
||||
// Only reset the form without creating a task
|
||||
setNewTaskName('');
|
||||
setShowNewCard(false);
|
||||
setDueDate(null);
|
||||
setCreatingTask(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex
|
||||
ref={cardRef}
|
||||
vertical
|
||||
gap={12}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: 12,
|
||||
backgroundColor: themeMode === 'dark' ? '#292929' : '#fafafa',
|
||||
borderRadius: 6,
|
||||
cursor: 'pointer',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
className={`outline-1 ${themeWiseColor('outline-[#edeae9]', 'outline-[#6a696a]', themeMode)} hover:outline`}
|
||||
onBlur={handleCancelNewCard}
|
||||
>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={newTaskName}
|
||||
onChange={e => setNewTaskName(e.target.value)}
|
||||
onPressEnter={position === 'bottom' ? handleAddTaskToTheBottom : handleAddTaskToTheTop}
|
||||
placeholder={t('newTaskNamePlaceholder')}
|
||||
style={{
|
||||
width: '100%',
|
||||
borderRadius: 6,
|
||||
padding: 8,
|
||||
}}
|
||||
disabled={creatingTask}
|
||||
/>
|
||||
{newTaskName.trim() && (
|
||||
<Flex gap={8} justify="flex-end">
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => setShowNewCard(false)}
|
||||
>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
onClick={position === 'bottom' ? handleAddTaskToTheBottom : handleAddTaskToTheTop}
|
||||
loading={creatingTask}
|
||||
>
|
||||
{t('addTask')}
|
||||
</Button>
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default BoardViewCreateTaskCard;
|
||||
@@ -0,0 +1,413 @@
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
Tooltip,
|
||||
Tag,
|
||||
Progress,
|
||||
Typography,
|
||||
Dropdown,
|
||||
MenuProps,
|
||||
Button,
|
||||
Flex,
|
||||
List,
|
||||
Divider,
|
||||
Popconfirm,
|
||||
Skeleton,
|
||||
} from 'antd';
|
||||
import {
|
||||
DoubleRightOutlined,
|
||||
PauseOutlined,
|
||||
UserAddOutlined,
|
||||
InboxOutlined,
|
||||
DeleteOutlined,
|
||||
MinusOutlined,
|
||||
ForkOutlined,
|
||||
CaretRightFilled,
|
||||
CaretDownFilled,
|
||||
ExclamationCircleFilled,
|
||||
PlusOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import dayjs, { Dayjs } from 'dayjs';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { themeWiseColor } from '@/utils/themeWiseColor';
|
||||
import BoardSubTaskCard from '../board-sub-task-card/board-sub-task-card';
|
||||
import CustomAvatarGroup from '@/components/board/custom-avatar-group';
|
||||
import CustomDueDatePicker from '@/components/board/custom-due-date-picker';
|
||||
import { colors } from '@/styles/colors';
|
||||
import {
|
||||
deleteBoardTask,
|
||||
fetchBoardSubTasks,
|
||||
updateBoardTaskAssignee,
|
||||
} from '@features/board/board-slice';
|
||||
import BoardCreateSubtaskCard from '../board-sub-task-card/board-create-sub-task-card';
|
||||
import { setShowTaskDrawer, setSelectedTaskId } from '@/features/task-drawer/task-drawer.slice';
|
||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||
import { IBulkAssignRequest } from '@/types/tasks/bulk-action-bar.types';
|
||||
import { taskListBulkActionsApiService } from '@/api/tasks/task-list-bulk-actions.api.service';
|
||||
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
|
||||
import {
|
||||
evt_project_task_list_context_menu_archive,
|
||||
evt_project_task_list_context_menu_assign_me,
|
||||
evt_project_task_list_context_menu_delete,
|
||||
} from '@/shared/worklenz-analytics-events';
|
||||
import logger from '@/utils/errorLogger';
|
||||
|
||||
interface IBoardViewTaskCardProps {
|
||||
task: IProjectTask;
|
||||
sectionId: string;
|
||||
}
|
||||
|
||||
const BoardViewTaskCard = ({ task, sectionId }: IBoardViewTaskCardProps) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const { t } = useTranslation('kanban-board');
|
||||
const { trackMixpanelEvent } = useMixpanelTracking();
|
||||
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
const projectId = useAppSelector(state => state.projectReducer.projectId);
|
||||
const [isSubTaskShow, setIsSubTaskShow] = useState(false);
|
||||
const [showNewSubtaskCard, setShowNewSubtaskCard] = useState(false);
|
||||
const [dueDate, setDueDate] = useState<Dayjs | null>(
|
||||
task?.end_date ? dayjs(task?.end_date) : null
|
||||
);
|
||||
const [updatingAssignToMe, setUpdatingAssignToMe] = useState(false);
|
||||
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: task.id || '',
|
||||
data: {
|
||||
type: 'task',
|
||||
task,
|
||||
sectionId,
|
||||
},
|
||||
});
|
||||
|
||||
const style = useMemo(() => ({
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
}), [transform, transition, isDragging]);
|
||||
|
||||
const handleCardClick = useCallback((e: React.MouseEvent, id: string) => {
|
||||
// Prevent the event from propagating to parent elements
|
||||
e.stopPropagation();
|
||||
|
||||
// Don't handle click if we're dragging
|
||||
if (isDragging) return;
|
||||
|
||||
// Add a small delay to ensure it's a click and not the start of a drag
|
||||
const clickTimeout = setTimeout(() => {
|
||||
dispatch(setSelectedTaskId(id));
|
||||
dispatch(setShowTaskDrawer(true));
|
||||
}, 50);
|
||||
|
||||
return () => clearTimeout(clickTimeout);
|
||||
}, [dispatch, isDragging]);
|
||||
|
||||
const handleAssignToMe = useCallback(async () => {
|
||||
if (!projectId || !task.id || updatingAssignToMe) return;
|
||||
|
||||
try {
|
||||
setUpdatingAssignToMe(true);
|
||||
const body: IBulkAssignRequest = {
|
||||
tasks: [task.id],
|
||||
project_id: projectId,
|
||||
};
|
||||
const res = await taskListBulkActionsApiService.assignToMe(body);
|
||||
if (res.done) {
|
||||
trackMixpanelEvent(evt_project_task_list_context_menu_assign_me);
|
||||
dispatch(
|
||||
updateBoardTaskAssignee({
|
||||
body: res.body,
|
||||
sectionId,
|
||||
taskId: task.id,
|
||||
})
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error assigning task to me:', error);
|
||||
} finally {
|
||||
setUpdatingAssignToMe(false);
|
||||
}
|
||||
}, [projectId, task.id, updatingAssignToMe, dispatch, trackMixpanelEvent, sectionId]);
|
||||
|
||||
const handleArchive = useCallback(async () => {
|
||||
if (!projectId || !task.id) return;
|
||||
|
||||
try {
|
||||
const res = await taskListBulkActionsApiService.archiveTasks(
|
||||
{
|
||||
tasks: [task.id],
|
||||
project_id: projectId,
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
if (res.done) {
|
||||
trackMixpanelEvent(evt_project_task_list_context_menu_archive);
|
||||
dispatch(deleteBoardTask({ sectionId, taskId: task.id }));
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error archiving task:', error);
|
||||
}
|
||||
}, [projectId, task.id, dispatch, trackMixpanelEvent, sectionId]);
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (!projectId || !task.id) return;
|
||||
|
||||
try {
|
||||
const res = await taskListBulkActionsApiService.deleteTasks({ tasks: [task.id] }, projectId);
|
||||
|
||||
if (res.done) {
|
||||
trackMixpanelEvent(evt_project_task_list_context_menu_delete);
|
||||
dispatch(deleteBoardTask({ sectionId, taskId: task.id }));
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error deleting task:', error);
|
||||
}
|
||||
}, [projectId, task.id, dispatch, trackMixpanelEvent, sectionId]);
|
||||
|
||||
const handleSubTaskExpand = useCallback(() => {
|
||||
if (task && task.id && projectId) {
|
||||
if (task.show_sub_tasks) {
|
||||
// If subtasks are already loaded, just toggle visibility
|
||||
setIsSubTaskShow(prev => !prev);
|
||||
} else {
|
||||
// If subtasks need to be fetched, show the section first with loading state
|
||||
setIsSubTaskShow(true);
|
||||
dispatch(fetchBoardSubTasks({ taskId: task.id, projectId }));
|
||||
}
|
||||
}
|
||||
}, [task, projectId, dispatch]);
|
||||
|
||||
const handleAddSubtaskClick = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setShowNewSubtaskCard(true);
|
||||
}, []);
|
||||
|
||||
const handleSubtaskButtonClick = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
handleSubTaskExpand();
|
||||
}, [handleSubTaskExpand]);
|
||||
|
||||
const items: MenuProps['items'] = useMemo(() => [
|
||||
{
|
||||
label: (
|
||||
<span>
|
||||
<UserAddOutlined />
|
||||
|
||||
<Typography.Text>{t('assignToMe')}</Typography.Text>
|
||||
</span>
|
||||
),
|
||||
key: '1',
|
||||
onClick: handleAssignToMe,
|
||||
disabled: updatingAssignToMe,
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<span>
|
||||
<InboxOutlined />
|
||||
|
||||
<Typography.Text>{t('archive')}</Typography.Text>
|
||||
</span>
|
||||
),
|
||||
key: '2',
|
||||
onClick: handleArchive,
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<Popconfirm
|
||||
title={t('deleteConfirmationTitle')}
|
||||
icon={<ExclamationCircleFilled style={{ color: colors.vibrantOrange }} />}
|
||||
okText={t('deleteConfirmationOk')}
|
||||
cancelText={t('deleteConfirmationCancel')}
|
||||
onConfirm={handleDelete}
|
||||
>
|
||||
<DeleteOutlined />
|
||||
|
||||
{t('delete')}
|
||||
</Popconfirm>
|
||||
),
|
||||
key: '3',
|
||||
},
|
||||
], [t, handleAssignToMe, handleArchive, handleDelete, updatingAssignToMe]);
|
||||
|
||||
const priorityIcon = useMemo(() => {
|
||||
if (task.priority_value === 0) {
|
||||
return (
|
||||
<MinusOutlined
|
||||
style={{
|
||||
color: '#52c41a',
|
||||
marginRight: '0.25rem',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
} else if (task.priority_value === 1) {
|
||||
return (
|
||||
<PauseOutlined
|
||||
style={{
|
||||
color: '#faad14',
|
||||
transform: 'rotate(90deg)',
|
||||
marginRight: '0.25rem',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<DoubleRightOutlined
|
||||
style={{
|
||||
color: '#f5222d',
|
||||
transform: 'rotate(-90deg)',
|
||||
marginRight: '0.25rem',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}, [task.priority_value]);
|
||||
|
||||
const renderLabels = useMemo(() => {
|
||||
if (!task?.labels?.length) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{task.labels.slice(0, 2).map((label: any) => (
|
||||
<Tag key={label.id} style={{ marginRight: '4px' }} color={label?.color_code}>
|
||||
<span style={{ color: themeMode === 'dark' ? '#383838' : '' }}>
|
||||
{label.name}
|
||||
</span>
|
||||
</Tag>
|
||||
))}
|
||||
{task.labels.length > 2 && <Tag>+ {task.labels.length - 2}</Tag>}
|
||||
</>
|
||||
);
|
||||
}, [task.labels, themeMode]);
|
||||
|
||||
return (
|
||||
<Dropdown menu={{ items }} trigger={['contextMenu']}>
|
||||
<Flex
|
||||
ref={setNodeRef}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
vertical
|
||||
gap={12}
|
||||
style={{
|
||||
...style,
|
||||
width: '100%',
|
||||
padding: 12,
|
||||
backgroundColor: themeMode === 'dark' ? '#292929' : '#fafafa',
|
||||
borderRadius: 6,
|
||||
cursor: 'grab',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
className={`group outline-1 ${themeWiseColor('outline-[#edeae9]', 'outline-[#6a696a]', themeMode)} hover:outline board-task-card`}
|
||||
onClick={e => handleCardClick(e, task.id || '')}
|
||||
data-id={task.id}
|
||||
data-dragging={isDragging ? "true" : "false"}
|
||||
>
|
||||
{/* Labels and Progress */}
|
||||
<Flex align="center" justify="space-between">
|
||||
<Flex>
|
||||
{renderLabels}
|
||||
</Flex>
|
||||
|
||||
<Tooltip title={` ${task?.completed_count} / ${task?.total_tasks_count}`}>
|
||||
<Progress type="circle" percent={task?.complete_ratio } size={26} strokeWidth={(task.complete_ratio || 0) >= 100 ? 9 : 7} />
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
|
||||
{/* Action Icons */}
|
||||
<Flex gap={4}>
|
||||
{priorityIcon}
|
||||
<Typography.Text
|
||||
style={{ fontWeight: 500 }}
|
||||
ellipsis={{ tooltip: task.name }}
|
||||
>
|
||||
{task.name}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
|
||||
<Flex vertical gap={8}>
|
||||
<Flex
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style={{
|
||||
marginBlock: 8,
|
||||
}}
|
||||
>
|
||||
{task && <CustomAvatarGroup task={task} sectionId={sectionId} />}
|
||||
|
||||
<Flex gap={4} align="center">
|
||||
<CustomDueDatePicker task={task} onDateChange={setDueDate} />
|
||||
|
||||
{/* Subtask Section */}
|
||||
<Button
|
||||
onClick={handleSubtaskButtonClick}
|
||||
size="small"
|
||||
style={{
|
||||
padding: 0,
|
||||
}}
|
||||
type="text"
|
||||
>
|
||||
<Tag
|
||||
bordered={false}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
margin: 0,
|
||||
backgroundColor: themeWiseColor('white', '#1e1e1e', themeMode),
|
||||
}}
|
||||
>
|
||||
<ForkOutlined rotate={90} />
|
||||
<span>{task.sub_tasks_count}</span>
|
||||
{isSubTaskShow ? <CaretDownFilled /> : <CaretRightFilled />}
|
||||
</Tag>
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
{isSubTaskShow && (
|
||||
<Flex vertical>
|
||||
<Divider style={{ marginBlock: 0 }} />
|
||||
<List>
|
||||
{task.sub_tasks_loading && (
|
||||
<List.Item>
|
||||
<Skeleton active paragraph={{ rows: 2 }} title={false} style={{ marginTop: 8 }} />
|
||||
</List.Item>
|
||||
)}
|
||||
|
||||
{!task.sub_tasks_loading && task?.sub_tasks &&
|
||||
task?.sub_tasks.map((subtask: any) => (
|
||||
<BoardSubTaskCard key={subtask.id} subtask={subtask} sectionId={sectionId} />
|
||||
))}
|
||||
|
||||
{showNewSubtaskCard && (
|
||||
<BoardCreateSubtaskCard
|
||||
sectionId={sectionId}
|
||||
parentTaskId={task.id || ''}
|
||||
setShowNewSubtaskCard={setShowNewSubtaskCard}
|
||||
/>
|
||||
)}
|
||||
</List>
|
||||
<Button
|
||||
type="text"
|
||||
style={{
|
||||
width: 'fit-content',
|
||||
borderRadius: 6,
|
||||
boxShadow: 'none',
|
||||
}}
|
||||
icon={<PlusOutlined />}
|
||||
onClick={handleAddSubtaskClick}
|
||||
>
|
||||
{t('addSubtask', 'Add Subtask')}
|
||||
</Button>
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default BoardViewTaskCard;
|
||||
@@ -0,0 +1,431 @@
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import TaskListFilters from '../taskList/task-list-filters/task-list-filters';
|
||||
import { Flex, Skeleton } from 'antd';
|
||||
import BoardSectionCardContainer from './board-section/board-section-container';
|
||||
import {
|
||||
fetchBoardTaskGroups,
|
||||
reorderTaskGroups,
|
||||
moveTaskBetweenGroups,
|
||||
IGroupBy,
|
||||
updateTaskProgress,
|
||||
} from '@features/board/board-slice';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import {
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
DragOverEvent,
|
||||
DragStartEvent,
|
||||
closestCorners,
|
||||
DragOverlay,
|
||||
MouseSensor,
|
||||
TouchSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import BoardViewTaskCard from './board-section/board-task-card/board-view-task-card';
|
||||
import { fetchStatusesCategories } from '@/features/taskAttributes/taskStatusSlice';
|
||||
import useTabSearchParam from '@/hooks/useTabSearchParam';
|
||||
import { useSocket } from '@/socket/socketContext';
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
import { SocketEvents } from '@/shared/socket-events';
|
||||
import alertService from '@/services/alerts/alertService';
|
||||
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
|
||||
import { evt_project_board_visit, evt_project_task_list_drag_and_move } from '@/shared/worklenz-analytics-events';
|
||||
import { ITaskStatusCreateRequest } from '@/types/tasks/task-status-create-request';
|
||||
import { statusApiService } from '@/api/taskAttributes/status/status.api.service';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { tasksApiService } from '@/api/tasks/tasks.api.service';
|
||||
import { checkTaskDependencyStatus } from '@/utils/check-task-dependency-status';
|
||||
|
||||
const ProjectViewBoard = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const { projectView } = useTabSearchParam();
|
||||
const { socket } = useSocket();
|
||||
const authService = useAuthService();
|
||||
const currentSession = authService.getCurrentSession();
|
||||
const { trackMixpanelEvent } = useMixpanelTracking();
|
||||
const [ currentTaskIndex, setCurrentTaskIndex] = useState(-1);
|
||||
const { projectId } = useAppSelector(state => state.projectReducer);
|
||||
const { taskGroups, groupBy, loadingGroups, search, archived } = useAppSelector(state => state.boardReducer);
|
||||
const { statusCategories, loading: loadingStatusCategories } = useAppSelector(
|
||||
state => state.taskStatusReducer
|
||||
);
|
||||
const [activeItem, setActiveItem] = useState<any>(null);
|
||||
|
||||
// Store the original source group ID when drag starts
|
||||
const originalSourceGroupIdRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (projectId && groupBy && projectView === 'kanban') {
|
||||
if (!loadingGroups) {
|
||||
dispatch(fetchBoardTaskGroups(projectId));
|
||||
}
|
||||
}
|
||||
}, [dispatch, projectId, groupBy, projectView, search, archived]);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(MouseSensor, {
|
||||
// Require the mouse to move by 10 pixels before activating
|
||||
activationConstraint: {
|
||||
distance: 10,
|
||||
},
|
||||
}),
|
||||
useSensor(TouchSensor, {
|
||||
// Press delay of 250ms, with tolerance of 5px of movement
|
||||
activationConstraint: {
|
||||
delay: 250,
|
||||
tolerance: 5,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const handleTaskProgress = (data: {
|
||||
id: string;
|
||||
status: string;
|
||||
complete_ratio: number;
|
||||
completed_count: number;
|
||||
total_tasks_count: number;
|
||||
parent_task: string;
|
||||
}) => {
|
||||
dispatch(updateTaskProgress(data));
|
||||
};
|
||||
|
||||
const handleDragStart = (event: DragStartEvent) => {
|
||||
const { active } = event;
|
||||
setActiveItem(active.data.current);
|
||||
setCurrentTaskIndex(active.data.current?.sortable.index);
|
||||
// Store the original source group ID when drag starts
|
||||
if (active.data.current?.type === 'task') {
|
||||
originalSourceGroupIdRef.current = active.data.current.sectionId;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (event: DragOverEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over) return;
|
||||
|
||||
const activeId = active.id;
|
||||
const overId = over.id;
|
||||
|
||||
if (activeId === overId) return;
|
||||
|
||||
const isActiveTask = active.data.current?.type === 'task';
|
||||
const isOverTask = over.data.current?.type === 'task';
|
||||
const isOverSection = over.data.current?.type === 'section';
|
||||
|
||||
// Handle task movement between sections
|
||||
if (isActiveTask && (isOverTask || isOverSection)) {
|
||||
// If we're over a task, we want to insert at that position
|
||||
// If we're over a section, we want to append to the end
|
||||
const activeTaskId = active.data.current?.task.id;
|
||||
|
||||
// Use the original source group ID from ref instead of the potentially modified one
|
||||
const sourceGroupId = originalSourceGroupIdRef.current || active.data.current?.sectionId;
|
||||
|
||||
// Fix: Ensure we correctly identify the target group ID
|
||||
let targetGroupId;
|
||||
if (isOverTask) {
|
||||
// If over a task, get its section ID
|
||||
targetGroupId = over.data.current?.sectionId;
|
||||
} else if (isOverSection) {
|
||||
// If over a section directly
|
||||
targetGroupId = over.id;
|
||||
} else {
|
||||
// Fallback
|
||||
targetGroupId = over.id;
|
||||
}
|
||||
|
||||
// Find the target index
|
||||
let targetIndex = -1;
|
||||
if (isOverTask) {
|
||||
const overTaskId = over.data.current?.task.id;
|
||||
const targetGroup = taskGroups.find(group => group.id === targetGroupId);
|
||||
if (targetGroup) {
|
||||
targetIndex = targetGroup.tasks.findIndex(task => task.id === overTaskId);
|
||||
}
|
||||
}
|
||||
|
||||
// Dispatch the action to move the task
|
||||
dispatch(
|
||||
moveTaskBetweenGroups({
|
||||
taskId: activeTaskId,
|
||||
sourceGroupId,
|
||||
targetGroupId,
|
||||
targetIndex,
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragEnd = async (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
if (!over || !projectId) {
|
||||
setActiveItem(null);
|
||||
originalSourceGroupIdRef.current = null; // Reset the ref
|
||||
return;
|
||||
}
|
||||
|
||||
const isActiveTask = active.data.current?.type === 'task';
|
||||
const isActiveSection = active.data.current?.type === 'section';
|
||||
|
||||
// Handle task dragging between columns
|
||||
if (isActiveTask) {
|
||||
const task = active.data.current?.task;
|
||||
|
||||
// Use the original source group ID from ref instead of the potentially modified one
|
||||
const sourceGroupId = originalSourceGroupIdRef.current || active.data.current?.sectionId;
|
||||
|
||||
// Fix: Ensure we correctly identify the target group ID
|
||||
let targetGroupId;
|
||||
if (over.data.current?.type === 'task') {
|
||||
// If dropping on a task, get its section ID
|
||||
targetGroupId = over.data.current?.sectionId;
|
||||
} else if (over.data.current?.type === 'section') {
|
||||
// If dropping directly on a section
|
||||
targetGroupId = over.id;
|
||||
} else {
|
||||
// Fallback to the over ID if type is not specified
|
||||
targetGroupId = over.id;
|
||||
}
|
||||
|
||||
// Find source and target groups
|
||||
const sourceGroup = taskGroups.find(group => group.id === sourceGroupId);
|
||||
const targetGroup = taskGroups.find(group => group.id === targetGroupId);
|
||||
|
||||
if (!sourceGroup || !targetGroup || !task) {
|
||||
logger.error('Could not find source or target group, or task is undefined');
|
||||
setActiveItem(null);
|
||||
originalSourceGroupIdRef.current = null; // Reset the ref
|
||||
return;
|
||||
}
|
||||
if (targetGroupId !== sourceGroupId) {
|
||||
const canContinue = await checkTaskDependencyStatus(task.id, targetGroupId);
|
||||
if (!canContinue) {
|
||||
alertService.error(
|
||||
'Task is not completed',
|
||||
'Please complete the task dependencies before proceeding'
|
||||
);
|
||||
dispatch(
|
||||
moveTaskBetweenGroups({
|
||||
taskId: task.id,
|
||||
sourceGroupId: targetGroupId, // Current group (where it was moved optimistically)
|
||||
targetGroupId: sourceGroupId, // Move it back to the original source group
|
||||
targetIndex: currentTaskIndex !== -1 ? currentTaskIndex : 0, // Original position or append to end
|
||||
})
|
||||
);
|
||||
|
||||
setActiveItem(null);
|
||||
originalSourceGroupIdRef.current = null;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Find indices
|
||||
let fromIndex = sourceGroup.tasks.findIndex(t => t.id === task.id);
|
||||
|
||||
// Handle case where task is not found in source group (might have been moved already in UI)
|
||||
if (fromIndex === -1) {
|
||||
logger.info('Task not found in source group. Using task sort_order from task object.');
|
||||
|
||||
// Use the sort_order from the task object itself
|
||||
const fromSortOrder = task.sort_order;
|
||||
|
||||
// Calculate target index and position
|
||||
let toIndex = -1;
|
||||
if (over.data.current?.type === 'task') {
|
||||
const overTaskId = over.data.current?.task.id;
|
||||
toIndex = targetGroup.tasks.findIndex(t => t.id === overTaskId);
|
||||
} else {
|
||||
// If dropping on a section, append to the end
|
||||
toIndex = targetGroup.tasks.length;
|
||||
}
|
||||
|
||||
// Calculate toPos similar to Angular implementation
|
||||
const toPos = targetGroup.tasks[toIndex]?.sort_order ||
|
||||
targetGroup.tasks[targetGroup.tasks.length - 1]?.sort_order ||
|
||||
-1;
|
||||
|
||||
// Prepare socket event payload
|
||||
const body = {
|
||||
project_id: projectId,
|
||||
from_index: fromSortOrder,
|
||||
to_index: toPos,
|
||||
to_last_index: !toPos,
|
||||
from_group: sourceGroupId,
|
||||
to_group: targetGroupId,
|
||||
group_by: groupBy || 'status',
|
||||
task,
|
||||
team_id: currentSession?.team_id
|
||||
};
|
||||
|
||||
logger.error('Emitting socket event with payload (task not found in source):', body);
|
||||
|
||||
// Emit socket event
|
||||
if (socket) {
|
||||
socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), body);
|
||||
|
||||
// Set up listener for task progress update
|
||||
socket.once(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), () => {
|
||||
if (task.is_sub_task) {
|
||||
socket.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.parent_task_id);
|
||||
} else {
|
||||
socket.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Track analytics event
|
||||
trackMixpanelEvent(evt_project_task_list_drag_and_move);
|
||||
|
||||
setActiveItem(null);
|
||||
originalSourceGroupIdRef.current = null; // Reset the ref
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate target index and position
|
||||
let toIndex = -1;
|
||||
if (over.data.current?.type === 'task') {
|
||||
const overTaskId = over.data.current?.task.id;
|
||||
toIndex = targetGroup.tasks.findIndex(t => t.id === overTaskId);
|
||||
} else {
|
||||
// If dropping on a section, append to the end
|
||||
toIndex = targetGroup.tasks.length;
|
||||
}
|
||||
|
||||
// Calculate toPos similar to Angular implementation
|
||||
const toPos = targetGroup.tasks[toIndex]?.sort_order ||
|
||||
targetGroup.tasks[targetGroup.tasks.length - 1]?.sort_order ||
|
||||
-1;
|
||||
|
||||
// Prepare socket event payload
|
||||
const body = {
|
||||
project_id: projectId,
|
||||
from_index: sourceGroup.tasks[fromIndex].sort_order,
|
||||
to_index: toPos,
|
||||
to_last_index: !toPos,
|
||||
from_group: sourceGroupId, // Use the direct IDs instead of group objects
|
||||
to_group: targetGroupId, // Use the direct IDs instead of group objects
|
||||
group_by: groupBy || 'status', // Use the current groupBy value
|
||||
task,
|
||||
team_id: currentSession?.team_id
|
||||
};
|
||||
|
||||
// Emit socket event
|
||||
if (socket) {
|
||||
socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), body);
|
||||
|
||||
// Set up listener for task progress update
|
||||
socket.once(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), () => {
|
||||
if (task.is_sub_task) {
|
||||
socket.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.parent_task_id);
|
||||
} else {
|
||||
socket.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Track analytics event
|
||||
trackMixpanelEvent(evt_project_task_list_drag_and_move);
|
||||
}
|
||||
// Handle column reordering
|
||||
else if (isActiveSection) {
|
||||
// Don't allow reordering if groupBy is phases
|
||||
if (groupBy === IGroupBy.PHASE) {
|
||||
setActiveItem(null);
|
||||
originalSourceGroupIdRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const sectionId = active.id;
|
||||
const fromIndex = taskGroups.findIndex(group => group.id === sectionId);
|
||||
const toIndex = taskGroups.findIndex(group => group.id === over.id);
|
||||
|
||||
if (fromIndex !== -1 && toIndex !== -1) {
|
||||
// Create a new array with the reordered groups
|
||||
const reorderedGroups = [...taskGroups];
|
||||
const [movedGroup] = reorderedGroups.splice(fromIndex, 1);
|
||||
reorderedGroups.splice(toIndex, 0, movedGroup);
|
||||
|
||||
// Dispatch action to reorder columns with the new array
|
||||
dispatch(reorderTaskGroups(reorderedGroups));
|
||||
|
||||
// Prepare column order for API
|
||||
const columnOrder = reorderedGroups.map(group => group.id);
|
||||
|
||||
// Call API to update status order
|
||||
try {
|
||||
// Use the correct API endpoint based on the Angular code
|
||||
const requestBody: ITaskStatusCreateRequest = {
|
||||
status_order: columnOrder
|
||||
};
|
||||
|
||||
const response = await statusApiService.updateStatusOrder(requestBody, projectId);
|
||||
if (!response.done) {
|
||||
const revertedGroups = [...reorderedGroups];
|
||||
const [movedBackGroup] = revertedGroups.splice(toIndex, 1);
|
||||
revertedGroups.splice(fromIndex, 0, movedBackGroup);
|
||||
dispatch(reorderTaskGroups(revertedGroups));
|
||||
alertService.error('Failed to update column order', 'Please try again');
|
||||
}
|
||||
} catch (error) {
|
||||
// Revert the change if API call fails
|
||||
const revertedGroups = [...reorderedGroups];
|
||||
const [movedBackGroup] = revertedGroups.splice(toIndex, 1);
|
||||
revertedGroups.splice(fromIndex, 0, movedBackGroup);
|
||||
dispatch(reorderTaskGroups(revertedGroups));
|
||||
alertService.error('Failed to update column order', 'Please try again');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setActiveItem(null);
|
||||
originalSourceGroupIdRef.current = null; // Reset the ref
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (socket) {
|
||||
socket.on(SocketEvents.GET_TASK_PROGRESS.toString(), handleTaskProgress);
|
||||
}
|
||||
|
||||
return () => {
|
||||
socket?.off(SocketEvents.GET_TASK_PROGRESS.toString(), handleTaskProgress);
|
||||
};
|
||||
}, [socket]);
|
||||
|
||||
useEffect(() => {
|
||||
trackMixpanelEvent(evt_project_board_visit);
|
||||
if (!statusCategories.length && projectId) {
|
||||
dispatch(fetchStatusesCategories());
|
||||
}
|
||||
}, [dispatch, projectId]);
|
||||
|
||||
return (
|
||||
<Flex vertical gap={16}>
|
||||
<TaskListFilters position={'board'} />
|
||||
|
||||
<Skeleton active loading={loadingGroups} className='mt-4 p-4'>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCorners}
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<BoardSectionCardContainer
|
||||
datasource={taskGroups}
|
||||
group={groupBy as 'status' | 'priority' | 'phases'}
|
||||
/>
|
||||
<DragOverlay>
|
||||
{activeItem?.type === 'task' && (
|
||||
<BoardViewTaskCard task={activeItem.task} sectionId={activeItem.sectionId} />
|
||||
)}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
</Skeleton>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectViewBoard;
|
||||
@@ -0,0 +1,271 @@
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Flex,
|
||||
Popconfirm,
|
||||
Segmented,
|
||||
Table,
|
||||
TableProps,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { colors } from '@/styles/colors';
|
||||
import {
|
||||
AppstoreOutlined,
|
||||
BarsOutlined,
|
||||
CloudDownloadOutlined,
|
||||
DeleteOutlined,
|
||||
ExclamationCircleFilled,
|
||||
ExclamationCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { durationDateFormat } from '@utils/durationDateFormat';
|
||||
import { DEFAULT_PAGE_SIZE, IconsMap } from '@/shared/constants';
|
||||
import {
|
||||
IProjectAttachmentsViewModel,
|
||||
ITaskAttachmentViewModel,
|
||||
} from '@/types/tasks/task-attachment-view-model';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { attachmentsApiService } from '@/api/attachments/attachments.api.service';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { evt_project_files_visit } from '@/shared/worklenz-analytics-events';
|
||||
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
|
||||
|
||||
const ProjectViewFiles = () => {
|
||||
const { t } = useTranslation('project-view-files');
|
||||
const { trackMixpanelEvent } = useMixpanelTracking();
|
||||
const { projectId, refreshTimestamp } = useAppSelector(state => state.projectReducer);
|
||||
const [attachments, setAttachments] = useState<IProjectAttachmentsViewModel>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
|
||||
const [paginationConfig, setPaginationConfig] = useState({
|
||||
total: 0,
|
||||
pageIndex: 1,
|
||||
showSizeChanger: true,
|
||||
defaultPageSize: DEFAULT_PAGE_SIZE,
|
||||
});
|
||||
|
||||
|
||||
const fetchAttachments = async () => {
|
||||
if (!projectId) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await attachmentsApiService.getProjectAttachments(
|
||||
projectId,
|
||||
paginationConfig.pageIndex,
|
||||
paginationConfig.defaultPageSize
|
||||
);
|
||||
if (response.done) {
|
||||
setAttachments(response.body || {});
|
||||
setPaginationConfig(prev => ({ ...prev, total: response.body?.total || 0 }));
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching project attachments', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchAttachments();
|
||||
}, [refreshTimestamp]);
|
||||
|
||||
const getFileTypeIcon = (type: string | undefined) => {
|
||||
if (!type) return IconsMap['search'];
|
||||
return IconsMap[type as string] || IconsMap['search'];
|
||||
};
|
||||
|
||||
const downloadAttachment = async (id: string | undefined, filename: string | undefined) => {
|
||||
if (!id || !filename) return;
|
||||
try {
|
||||
setDownloading(true);
|
||||
|
||||
const response = await attachmentsApiService.downloadAttachment(id, filename);
|
||||
|
||||
if (response.done) {
|
||||
const link = document.createElement('a');
|
||||
link.href = response.body || '';
|
||||
link.download = filename;
|
||||
link.click();
|
||||
link.remove();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error downloading attachment', error);
|
||||
} finally {
|
||||
setDownloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteAttachment = async (id: string | undefined) => {
|
||||
if (!id) return;
|
||||
try {
|
||||
const response = await attachmentsApiService.deleteAttachment(id);
|
||||
if (response.done) {
|
||||
fetchAttachments();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error deleting attachment', error);
|
||||
}
|
||||
};
|
||||
|
||||
const openAttachment = (url: string | undefined) => {
|
||||
if (!url) return;
|
||||
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.target = '_blank';
|
||||
a.style.display = 'none';
|
||||
a.click();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
trackMixpanelEvent(evt_project_files_visit);
|
||||
fetchAttachments();
|
||||
}, [paginationConfig.pageIndex, projectId]);
|
||||
|
||||
const columns: TableProps<ITaskAttachmentViewModel>['columns'] = [
|
||||
{
|
||||
key: 'fileName',
|
||||
title: t('nameColumn'),
|
||||
render: (record: ITaskAttachmentViewModel) => (
|
||||
<Flex
|
||||
gap={4}
|
||||
align="center"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => openAttachment(record.url)}
|
||||
>
|
||||
<img
|
||||
src={`/file-types/${getFileTypeIcon(record.type)}`}
|
||||
alt={t('fileIconAlt')}
|
||||
style={{ width: '100%', maxWidth: 25 }}
|
||||
/>
|
||||
<Typography.Text>
|
||||
[{record.task_key}] {record.name}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'attachedTask',
|
||||
title: t('attachedTaskColumn'),
|
||||
render: (record: ITaskAttachmentViewModel) => (
|
||||
<Typography.Text style={{ cursor: 'pointer' }} onClick={() => openAttachment(record.url)}>
|
||||
{record.task_name}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'size',
|
||||
title: t('sizeColumn'),
|
||||
render: (record: ITaskAttachmentViewModel) => (
|
||||
<Typography.Text style={{ cursor: 'pointer' }} onClick={() => openAttachment(record.url)}>
|
||||
{record.size}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'uploadedBy',
|
||||
title: t('uploadedByColumn'),
|
||||
render: (record: ITaskAttachmentViewModel) => (
|
||||
<Typography.Text style={{ cursor: 'pointer' }} onClick={() => openAttachment(record.url)}>
|
||||
{record.uploader_name}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'uploadedAt',
|
||||
title: t('uploadedAtColumn'),
|
||||
render: (record: ITaskAttachmentViewModel) => (
|
||||
<Typography.Text style={{ cursor: 'pointer' }} onClick={() => openAttachment(record.url)}>
|
||||
<Tooltip title={record.created_at}>{durationDateFormat(record.created_at)}</Tooltip>
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actionBtns',
|
||||
width: 80,
|
||||
render: (record: ITaskAttachmentViewModel) => (
|
||||
<Flex gap={8} style={{ padding: 0 }}>
|
||||
<Popconfirm
|
||||
title={t('deleteConfirmationTitle')}
|
||||
icon={<ExclamationCircleFilled style={{ color: colors.vibrantOrange }} />}
|
||||
okText={t('deleteConfirmationOk')}
|
||||
cancelText={t('deleteConfirmationCancel')}
|
||||
onConfirm={() => deleteAttachment(record.id)}
|
||||
>
|
||||
<Tooltip title="Delete">
|
||||
<Button shape="default" icon={<DeleteOutlined />} size="small" />
|
||||
</Tooltip>
|
||||
</Popconfirm>
|
||||
|
||||
<Tooltip title="Download">
|
||||
<Button
|
||||
size="small"
|
||||
icon={<CloudDownloadOutlined />}
|
||||
onClick={() => downloadAttachment(record.id, record.name)}
|
||||
loading={downloading}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card
|
||||
style={{ width: '100%' }}
|
||||
title={
|
||||
<Flex justify="space-between">
|
||||
<Typography.Text
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 4,
|
||||
alignItems: 'center',
|
||||
color: colors.lightGray,
|
||||
fontSize: 13,
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
<ExclamationCircleOutlined />
|
||||
{t('titleDescriptionText')}
|
||||
</Typography.Text>
|
||||
|
||||
<Tooltip title={t('segmentedTooltip')}>
|
||||
<Segmented
|
||||
options={[
|
||||
{ value: 'listView', icon: <BarsOutlined /> },
|
||||
{ value: 'thumbnailView', icon: <AppstoreOutlined /> },
|
||||
]}
|
||||
defaultValue={'listView'}
|
||||
disabled={true}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
}
|
||||
>
|
||||
<Table<ITaskAttachmentViewModel>
|
||||
className="custom-two-colors-row-table"
|
||||
dataSource={attachments.data}
|
||||
columns={columns}
|
||||
rowKey={record => record.id || ''}
|
||||
loading={loading}
|
||||
pagination={{
|
||||
showSizeChanger: paginationConfig.showSizeChanger,
|
||||
defaultPageSize: paginationConfig.defaultPageSize,
|
||||
total: paginationConfig.total,
|
||||
current: paginationConfig.pageIndex,
|
||||
onChange: (page, pageSize) =>
|
||||
setPaginationConfig(prev => ({
|
||||
...prev,
|
||||
pageIndex: page,
|
||||
defaultPageSize: pageSize,
|
||||
})),
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectViewFiles;
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Card, Flex, Typography } from 'antd';
|
||||
import TaskByMembersTable from './tables/tasks-by-members';
|
||||
|
||||
import MemberStats from '../member-stats/member-stats';
|
||||
import { TFunction } from 'i18next';
|
||||
|
||||
const InsightsMembers = ({ t }: { t: TFunction }) => {
|
||||
return (
|
||||
<Flex vertical gap={24}>
|
||||
<MemberStats />
|
||||
|
||||
<Card
|
||||
className="custom-insights-card"
|
||||
title={
|
||||
<Typography.Text style={{ fontSize: 16, fontWeight: 500 }}>
|
||||
{t('members.tasksByMembers')}
|
||||
</Typography.Text>
|
||||
}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<TaskByMembersTable />
|
||||
</Card>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default InsightsMembers;
|
||||
@@ -0,0 +1,141 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Flex, Tooltip, Typography } from 'antd';
|
||||
|
||||
import { projectInsightsApiService } from '@/api/projects/insights/project-insights.api.service';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { IInsightTasks } from '@/types/project/projectInsights.types';
|
||||
import { colors } from '@/styles/colors';
|
||||
import { simpleDateFormat } from '@/utils/simpleDateFormat';
|
||||
import { themeWiseColor } from '@/utils/themeWiseColor';
|
||||
|
||||
interface AssignedTasksListTableProps {
|
||||
memberId: string;
|
||||
projectId: string;
|
||||
archived: boolean;
|
||||
}
|
||||
|
||||
const columnsList = [
|
||||
{ key: 'name', columnHeader: 'Name', width: 280 },
|
||||
{ key: 'status', columnHeader: 'Status', width: 100 },
|
||||
{ key: 'dueDate', columnHeader: 'Due Date', width: 150 },
|
||||
{ key: 'overdue', columnHeader: 'Days Overdue', width: 150 },
|
||||
{ key: 'completedDate', columnHeader: 'Completed Date', width: 150 },
|
||||
{ key: 'totalAllocation', columnHeader: 'Total Allocation', width: 150 },
|
||||
{ key: 'overLoggedTime', columnHeader: 'Over Logged Time', width: 150 },
|
||||
];
|
||||
|
||||
const AssignedTasksListTable: React.FC<AssignedTasksListTableProps> = ({
|
||||
memberId,
|
||||
projectId,
|
||||
archived,
|
||||
}) => {
|
||||
const [memberTasks, setMemberTasks] = useState<IInsightTasks[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
|
||||
useEffect(() => {
|
||||
const getTasksByMemberId = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await projectInsightsApiService.getMemberTasks({
|
||||
member_id: memberId,
|
||||
project_id: projectId,
|
||||
archived,
|
||||
});
|
||||
if (res.done) {
|
||||
setMemberTasks(res.body);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching member tasks:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
getTasksByMemberId();
|
||||
}, [memberId, projectId, archived]);
|
||||
|
||||
const renderColumnContent = (key: string, task: IInsightTasks) => {
|
||||
switch (key) {
|
||||
case 'name':
|
||||
return (
|
||||
<Tooltip title={task.name}>
|
||||
<Typography.Text>{task.name}</Typography.Text>
|
||||
</Tooltip>
|
||||
);
|
||||
case 'status':
|
||||
return (
|
||||
<Flex
|
||||
gap={4}
|
||||
style={{
|
||||
width: 'fit-content',
|
||||
borderRadius: 24,
|
||||
paddingInline: 6,
|
||||
backgroundColor: task.status_color,
|
||||
color: colors.darkGray,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<Typography.Text
|
||||
ellipsis={{ expanded: false }}
|
||||
style={{
|
||||
color: colors.darkGray,
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{task.status}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
);
|
||||
case 'dueDate':
|
||||
return task.end_date ? simpleDateFormat(task.end_date) : 'N/A';
|
||||
case 'overdue':
|
||||
return task.days_overdue ?? 'N/A';
|
||||
case 'completedDate':
|
||||
return task.completed_at ? simpleDateFormat(task.completed_at) : 'N/A';
|
||||
case 'totalAllocation':
|
||||
return task.total_minutes ?? 'N/A';
|
||||
case 'overLoggedTime':
|
||||
return task.overlogged_time ?? 'N/A';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-h-0 max-w-full overflow-x-auto py-2 pl-12 pr-4"
|
||||
style={{ backgroundColor: themeWiseColor('#f0f2f5', '#000', themeMode) }}
|
||||
>
|
||||
<table className="w-full min-w-max border-collapse">
|
||||
<thead>
|
||||
<tr>
|
||||
{columnsList.map(column => (
|
||||
<th
|
||||
key={column.key}
|
||||
className="p-2 text-left"
|
||||
style={{ width: column.width, fontWeight: 500 }}
|
||||
>
|
||||
{column.columnHeader}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{memberTasks.map(task => (
|
||||
<tr key={task.id} className="h-[42px] border-t">
|
||||
{columnsList.map(column => (
|
||||
<td key={column.key} className="p-2" style={{ width: column.width }}>
|
||||
{renderColumnContent(column.key, task)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AssignedTasksListTable;
|
||||
@@ -0,0 +1,161 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Flex, Progress } from 'antd';
|
||||
import { colors } from '@/styles/colors';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { themeWiseColor } from '@/utils/themeWiseColor';
|
||||
import { DownOutlined, ExclamationCircleOutlined, RightOutlined } from '@ant-design/icons';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { projectsApiService } from '@/api/projects/projects.api.service';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IProjectOverviewStats } from '@/types/project/projectsViewModel.types';
|
||||
import { ITeamMemberOverviewGetResponse } from '@/types/project/project-insights.types';
|
||||
import React from 'react';
|
||||
import AssignedTasksListTable from './assigned-tasks-list';
|
||||
|
||||
const TaskByMembersTable = () => {
|
||||
const { includeArchivedTasks, projectId } = useAppSelector(state => state.projectInsightsReducer);
|
||||
|
||||
const [expandedRows, setExpandedRows] = useState<string[]>([]);
|
||||
const [memberList, setMemberList] = useState<ITeamMemberOverviewGetResponse[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { refreshTimestamp } = useAppSelector(state => state.projectReducer);
|
||||
|
||||
const { t } = useTranslation('project-view-insights');
|
||||
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
|
||||
const getProjectOverviewMembers = async () => {
|
||||
if (!projectId) return;
|
||||
try {
|
||||
const res = await projectsApiService.getOverViewMembersById(projectId);
|
||||
if (res.done) {
|
||||
setMemberList(res.body);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching member tasks:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
setLoading(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getProjectOverviewMembers();
|
||||
}, [projectId,refreshTimestamp]);
|
||||
|
||||
// toggle members row expansions
|
||||
const toggleRowExpansion = (memberId: string) => {
|
||||
setExpandedRows(prev =>
|
||||
prev.includes(memberId) ? prev.filter(id => id !== memberId) : [...prev, memberId]
|
||||
);
|
||||
};
|
||||
|
||||
// columns list
|
||||
const columnsList = [
|
||||
{ key: 'name', columnHeader: t('members.name'), width: 200 },
|
||||
{ key: 'taskCount', columnHeader: t('members.taskCount'), width: 100 },
|
||||
{ key: 'contribution', columnHeader: t('members.contribution'), width: 120 },
|
||||
{ key: 'completed', columnHeader: t('members.completed'), width: 100 },
|
||||
{ key: 'incomplete', columnHeader: t('members.incomplete'), width: 100 },
|
||||
{ key: 'overdue', columnHeader: t('members.overdue'), width: 100 },
|
||||
{ key: 'progress', columnHeader: t('members.progress'), width: 150 },
|
||||
];
|
||||
|
||||
// render content, based on column type
|
||||
const renderColumnContent = (key: string, member: ITeamMemberOverviewGetResponse) => {
|
||||
switch (key) {
|
||||
case 'name':
|
||||
return (
|
||||
<Flex gap={8} align="center">
|
||||
{member?.task_count && (
|
||||
<button onClick={() => toggleRowExpansion(member.id)}>
|
||||
{expandedRows.includes(member.id) ? <DownOutlined /> : <RightOutlined />}
|
||||
</button>
|
||||
)}
|
||||
{member.overdue_task_count ? (
|
||||
<ExclamationCircleOutlined style={{ color: colors.vibrantOrange }} />
|
||||
) : (
|
||||
<div style={{ width: 14, height: 14 }}></div>
|
||||
)}
|
||||
{member.name}
|
||||
</Flex>
|
||||
);
|
||||
case 'taskCount':
|
||||
return member.task_count;
|
||||
case 'contribution':
|
||||
return `${member.contribution}%`;
|
||||
case 'completed':
|
||||
return member.done_task_count;
|
||||
case 'incomplete':
|
||||
return member.pending_task_count;
|
||||
case 'overdue':
|
||||
return member.overdue_task_count;
|
||||
case 'progress':
|
||||
return (
|
||||
<Progress
|
||||
percent={Math.floor(((member.done_task_count ?? 0) / (member.task_count ?? 1)) * 100)}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="memberList-container min-h-0 max-w-full overflow-x-auto">
|
||||
<table className="w-full min-w-max border-collapse rounded">
|
||||
<thead
|
||||
style={{
|
||||
height: 42,
|
||||
backgroundColor: themeWiseColor('#f8f7f9', '#1d1d1d', themeMode),
|
||||
}}
|
||||
>
|
||||
<tr>
|
||||
{columnsList.map(column => (
|
||||
<th
|
||||
key={column.key}
|
||||
className={`p-2`}
|
||||
style={{ width: column.width, fontWeight: 500 }}
|
||||
>
|
||||
{column.columnHeader}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{memberList?.map(member => (
|
||||
<React.Fragment key={member.id}>
|
||||
<tr key={member.id} className="h-[42px] cursor-pointer">
|
||||
{columnsList.map(column => (
|
||||
<td
|
||||
key={column.key}
|
||||
className={`border-t p-2 text-center`}
|
||||
style={{
|
||||
width: column.width,
|
||||
}}
|
||||
>
|
||||
{renderColumnContent(column.key, member)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
|
||||
{expandedRows.includes(member.id) && (
|
||||
<tr>
|
||||
<td colSpan={columnsList.length}>
|
||||
<AssignedTasksListTable
|
||||
memberId={member.id}
|
||||
projectId={projectId}
|
||||
archived={includeArchivedTasks}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskByMembersTable;
|
||||
@@ -0,0 +1,119 @@
|
||||
import { Bar } from 'react-chartjs-2';
|
||||
import { Chart, ArcElement, Tooltip, CategoryScale, LinearScale, BarElement } from 'chart.js';
|
||||
import { ChartOptions } from 'chart.js';
|
||||
import { Flex } from 'antd';
|
||||
import { ITaskPriorityCounts } from '@/types/project/project-insights.types';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { projectInsightsApiService } from '@/api/projects/insights/project-insights.api.service';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { Spin } from 'antd/lib';
|
||||
|
||||
Chart.register(ArcElement, Tooltip, CategoryScale, LinearScale, BarElement);
|
||||
|
||||
const PriorityOverview = () => {
|
||||
const { includeArchivedTasks, projectId } = useAppSelector(state => state.projectInsightsReducer);
|
||||
|
||||
const [stats, setStats] = useState<ITaskPriorityCounts[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { refreshTimestamp } = useAppSelector(state => state.projectReducer);
|
||||
|
||||
|
||||
const getTaskPriorityCounts = async () => {
|
||||
if (!projectId) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await projectInsightsApiService.getPriorityOverview(
|
||||
projectId,
|
||||
includeArchivedTasks
|
||||
);
|
||||
if (res.done) {
|
||||
setStats(res.body);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching task priority counts:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getTaskPriorityCounts();
|
||||
}, [projectId, includeArchivedTasks, refreshTimestamp]);
|
||||
|
||||
const options: ChartOptions<'bar'> = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
x: {
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Priority',
|
||||
align: 'end',
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(200, 200, 200, 0.5)',
|
||||
},
|
||||
},
|
||||
y: {
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Task Count',
|
||||
align: 'end',
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(200, 200, 200, 0.5)',
|
||||
},
|
||||
beginAtZero: true,
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
position: 'top' as const,
|
||||
},
|
||||
datalabels: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const data = {
|
||||
labels: stats.map(stat => stat.name),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Tasks',
|
||||
data: stats.map(stat => stat.data),
|
||||
backgroundColor: stats.map(stat => stat.color),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const mockPriorityData = {
|
||||
labels: ['Low', 'Medium', 'High'],
|
||||
datasets: [
|
||||
{
|
||||
label: 'Tasks',
|
||||
data: [6, 12, 2],
|
||||
backgroundColor: ['#75c997', '#fbc84c', '#f37070'],
|
||||
hoverBackgroundColor: ['#46d980', '#ffc227', '#ff4141'],
|
||||
},
|
||||
],
|
||||
};
|
||||
if (loading) {
|
||||
return (
|
||||
<Flex justify="center" align="center" style={{ height: 350 }}>
|
||||
<Spin size="large" />
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex justify="center">
|
||||
{loading && <Spin />}
|
||||
<Bar options={options} data={data} className="h-[350px] w-full md:max-w-[580px]" />
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default PriorityOverview;
|
||||
@@ -0,0 +1,107 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Doughnut } from 'react-chartjs-2';
|
||||
import { Chart, ArcElement } from 'chart.js';
|
||||
import { Badge, Flex, Tooltip, Typography, Spin } from 'antd';
|
||||
import { ChartOptions } from 'chart.js';
|
||||
import { projectInsightsApiService } from '@/api/projects/insights/project-insights.api.service';
|
||||
import { ITaskStatusCounts } from '@/types/project/project-insights.types';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
|
||||
Chart.register(ArcElement);
|
||||
|
||||
const StatusOverview = () => {
|
||||
const { includeArchivedTasks, projectId } = useAppSelector(state => state.projectInsightsReducer);
|
||||
|
||||
const [stats, setStats] = useState<ITaskStatusCounts[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { refreshTimestamp } = useAppSelector(state => state.projectReducer);
|
||||
|
||||
|
||||
const getTaskStatusCounts = async () => {
|
||||
if (!projectId) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await projectInsightsApiService.getTaskStatusCounts(
|
||||
projectId,
|
||||
includeArchivedTasks
|
||||
);
|
||||
if (res.done) {
|
||||
setStats(res.body);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching task status counts:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getTaskStatusCounts();
|
||||
}, [projectId, includeArchivedTasks,refreshTimestamp]);
|
||||
|
||||
const options: ChartOptions<'doughnut'> = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
datalabels: {
|
||||
display: false,
|
||||
},
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: context => {
|
||||
const value = context.raw as number;
|
||||
return `${context.label}: ${value} task${value !== 1 ? 's' : ''}`;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const data = {
|
||||
labels: stats.map(status => status.name),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Tasks',
|
||||
data: stats.map(status => status.y),
|
||||
backgroundColor: stats.map(status => status.color),
|
||||
hoverBackgroundColor: stats.map(status => status.color + '90'),
|
||||
borderWidth: 1,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Flex justify="center" align="center" style={{ height: 350 }}>
|
||||
<Spin size="large" />
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex gap={24} wrap="wrap-reverse" justify="center">
|
||||
{loading && <Spin />}
|
||||
<div style={{ position: 'relative', height: 350, width: '100%', maxWidth: 350 }}>
|
||||
<Doughnut options={options} data={data} />
|
||||
</div>
|
||||
|
||||
<Flex gap={12} style={{ marginBlockStart: 12 }} wrap="wrap" className="flex-row xl:flex-col">
|
||||
{stats.map(status => (
|
||||
<Flex key={status.name} gap={8} align="center">
|
||||
<Badge color={status.color} />
|
||||
<Typography.Text>
|
||||
{status.name}
|
||||
<span style={{ marginLeft: 4 }}>({status.y})</span>
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
))}
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default StatusOverview;
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Button, Card, Flex, Typography } from 'antd';
|
||||
|
||||
import StatusOverview from './graphs/status-overview';
|
||||
import PriorityOverview from './graphs/priority-overview';
|
||||
import LastUpdatedTasks from './tables/last-updated-tasks';
|
||||
import ProjectDeadline from './tables/project-deadline';
|
||||
import ProjectStats from '../project-stats/project-stats';
|
||||
import { TFunction } from 'i18next';
|
||||
|
||||
const InsightsOverview = ({ t }: { t: TFunction }) => {
|
||||
return (
|
||||
<Flex vertical gap={24}>
|
||||
<ProjectStats t={t} />
|
||||
|
||||
<Flex gap={24} className="grid md:grid-cols-2">
|
||||
<Card
|
||||
className="custom-insights-card"
|
||||
title={
|
||||
<Typography.Text style={{ fontSize: 16, fontWeight: 500 }}>
|
||||
{t('overview.statusOverview')}
|
||||
</Typography.Text>
|
||||
}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<StatusOverview />
|
||||
</Card>
|
||||
<Card
|
||||
className="custom-insights-card"
|
||||
title={
|
||||
<Typography.Text style={{ fontSize: 16, fontWeight: 500 }}>
|
||||
{t('overview.priorityOverview')}
|
||||
</Typography.Text>
|
||||
}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<PriorityOverview />
|
||||
</Card>
|
||||
</Flex>
|
||||
|
||||
<Flex gap={24} className="grid lg:grid-cols-2">
|
||||
<Card
|
||||
className="custom-insights-card"
|
||||
title={
|
||||
<Typography.Text style={{ fontSize: 16, fontWeight: 500 }}>
|
||||
{t('overview.lastUpdatedTasks')}
|
||||
</Typography.Text>
|
||||
}
|
||||
extra={<Button type="link">{t('common.seeAll')}</Button>}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<LastUpdatedTasks />
|
||||
</Card>
|
||||
|
||||
<ProjectDeadline />
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default InsightsOverview;
|
||||
@@ -0,0 +1,128 @@
|
||||
import { Flex, Table, Tooltip, Typography } from 'antd';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { colors } from '@/styles/colors';
|
||||
import { TableProps } from 'antd/lib';
|
||||
import { simpleDateFormat } from '@/utils/simpleDateFormat';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { IInsightTasks } from '@/types/project/projectInsights.types';
|
||||
import { projectInsightsApiService } from '@/api/projects/insights/project-insights.api.service';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { formatDateTimeWithLocale } from '@/utils/format-date-time-with-locale';
|
||||
import { calculateTimeDifference } from '@/utils/calculate-time-difference';
|
||||
|
||||
const LastUpdatedTasks = () => {
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
const { includeArchivedTasks, projectId } = useAppSelector(state => state.projectInsightsReducer);
|
||||
|
||||
const [data, setData] = useState<IInsightTasks[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { refreshTimestamp } = useAppSelector(state => state.projectReducer);
|
||||
|
||||
|
||||
const getLastUpdatedTasks = async () => {
|
||||
if (!projectId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await projectInsightsApiService.getLastUpdatedTasks(
|
||||
projectId,
|
||||
includeArchivedTasks
|
||||
);
|
||||
if (res.done) {
|
||||
setData(res.body);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('getLastUpdatedTasks', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getLastUpdatedTasks();
|
||||
}, [projectId, includeArchivedTasks,refreshTimestamp]);
|
||||
|
||||
// table columns
|
||||
const columns: TableProps['columns'] = [
|
||||
{
|
||||
key: 'name',
|
||||
title: 'Name',
|
||||
render: (record: IInsightTasks) => <Typography.Text>{record.name}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
title: 'Status',
|
||||
render: (record: IInsightTasks) => (
|
||||
<Flex
|
||||
gap={4}
|
||||
style={{
|
||||
width: 'fit-content',
|
||||
borderRadius: 24,
|
||||
paddingInline: 6,
|
||||
backgroundColor: record.status_color,
|
||||
color: colors.darkGray,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<Typography.Text
|
||||
ellipsis={{ expanded: false }}
|
||||
style={{
|
||||
color: colors.darkGray,
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{record.status}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'dueDate',
|
||||
title: 'Due Date',
|
||||
render: (record: IInsightTasks) => (
|
||||
<Typography.Text>
|
||||
{record.end_date ? simpleDateFormat(record.end_date) : 'N/A'}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'lastUpdated',
|
||||
title: 'Last Updated',
|
||||
render: (record: IInsightTasks) => (
|
||||
<Tooltip title={record.updated_at ? formatDateTimeWithLocale(record.updated_at) : 'N/A'}>
|
||||
<Typography.Text>
|
||||
{record.updated_at ? calculateTimeDifference(record.updated_at) : 'N/A'}
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const dataSource = data.map(record => ({
|
||||
...record,
|
||||
key: record.id,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Table
|
||||
className="custom-two-colors-row-table"
|
||||
dataSource={dataSource}
|
||||
columns={columns}
|
||||
rowKey={record => record.id}
|
||||
pagination={{
|
||||
showSizeChanger: true,
|
||||
defaultPageSize: 20,
|
||||
}}
|
||||
loading={loading}
|
||||
onRow={() => {
|
||||
return {
|
||||
style: {
|
||||
cursor: 'pointer',
|
||||
height: 36,
|
||||
},
|
||||
};
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default LastUpdatedTasks;
|
||||
@@ -0,0 +1,137 @@
|
||||
import { Card, Flex, Skeleton, Table, Typography } from 'antd';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { colors } from '@/styles/colors';
|
||||
import { TableProps } from 'antd/lib';
|
||||
import { simpleDateFormat } from '@/utils/simpleDateFormat';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { projectInsightsApiService } from '@/api/projects/insights/project-insights.api.service';
|
||||
import { IDeadlineTaskStats, IInsightTasks } from '@/types/project/project-insights.types';
|
||||
import ProjectStatsCard from '@/components/projects/project-stats-card';
|
||||
import warningIcon from '@assets/icons/insightsIcons/warning.png';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
|
||||
const ProjectDeadline = () => {
|
||||
const { includeArchivedTasks, projectId } = useAppSelector(state => state.projectInsightsReducer);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [data, setData] = useState<IDeadlineTaskStats | null>(null);
|
||||
const { refreshTimestamp } = useAppSelector(state => state.projectReducer);
|
||||
|
||||
const getProjectDeadline = async () => {
|
||||
if (!projectId) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await projectInsightsApiService.getProjectDeadlineStats(
|
||||
projectId,
|
||||
includeArchivedTasks
|
||||
);
|
||||
if (res.done) {
|
||||
setData(res.body);
|
||||
}
|
||||
} catch {
|
||||
logger.error('Error fetching project deadline stats', { projectId, includeArchivedTasks });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getProjectDeadline();
|
||||
}, [projectId, includeArchivedTasks,refreshTimestamp]);
|
||||
|
||||
// table columns
|
||||
const columns: TableProps['columns'] = [
|
||||
{
|
||||
key: 'name',
|
||||
title: 'Name',
|
||||
render: (record: IInsightTasks) => <Typography.Text>{record.name}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
title: 'Status',
|
||||
render: (record: IInsightTasks) => (
|
||||
<Flex
|
||||
gap={4}
|
||||
style={{
|
||||
width: 'fit-content',
|
||||
borderRadius: 24,
|
||||
paddingInline: 6,
|
||||
backgroundColor: record.status_color,
|
||||
color: colors.darkGray,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<Typography.Text
|
||||
ellipsis={{ expanded: false }}
|
||||
style={{
|
||||
color: colors.darkGray,
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{record.status}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'dueDate',
|
||||
title: 'Due Date',
|
||||
render: (record: IInsightTasks) => (
|
||||
<Typography.Text>
|
||||
{record.end_date ? simpleDateFormat(record.end_date) : 'N/A'}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="custom-insights-card"
|
||||
title={
|
||||
<Typography.Text style={{ fontSize: 16, fontWeight: 500 }}>
|
||||
Project Deadline <span style={{ color: colors.lightGray }}>{data?.project_end_date}</span>
|
||||
</Typography.Text>
|
||||
}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<Flex vertical gap={24}>
|
||||
<Flex gap={12} style={{ width: '100%' }}>
|
||||
<Skeleton active loading={loading}>
|
||||
<ProjectStatsCard
|
||||
icon={warningIcon}
|
||||
title="Overdue tasks (hours)"
|
||||
tooltip={'Tasks that has time logged past the end date of the project'}
|
||||
children={data?.deadline_logged_hours_string || 'N/A'}
|
||||
/>
|
||||
<ProjectStatsCard
|
||||
icon={warningIcon}
|
||||
title="Overdue tasks"
|
||||
tooltip={'Tasks that are past the end date of the project'}
|
||||
children={data?.deadline_tasks_count || 'N/A'}
|
||||
/>
|
||||
</Skeleton>
|
||||
</Flex>
|
||||
<Table
|
||||
className="custom-two-colors-row-table"
|
||||
dataSource={data?.tasks}
|
||||
columns={columns}
|
||||
rowKey={record => record.taskId}
|
||||
pagination={{
|
||||
showSizeChanger: true,
|
||||
defaultPageSize: 20,
|
||||
}}
|
||||
onRow={record => {
|
||||
return {
|
||||
style: {
|
||||
cursor: 'pointer',
|
||||
height: 36,
|
||||
},
|
||||
};
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectDeadline;
|
||||
@@ -0,0 +1,100 @@
|
||||
import { Button, Card, Flex, Tooltip, Typography } from 'antd';
|
||||
import { ExclamationCircleOutlined } from '@ant-design/icons';
|
||||
import { colors } from '@/styles/colors';
|
||||
import OverdueTasksTable from './tables/overdue-tasks-table';
|
||||
import OverLoggedTasksTable from './tables/over-logged-tasks-table';
|
||||
import TaskCompletedEarlyTable from './tables/task-completed-early-table';
|
||||
import TaskCompletedLateTable from './tables/task-completed-late-table';
|
||||
import ProjectStats from '../project-stats/project-stats';
|
||||
import { TFunction } from 'i18next';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
|
||||
const InsightsTasks = ({ t }: { t: TFunction }) => {
|
||||
const { includeArchivedTasks, projectId } = useAppSelector(state => state.projectInsightsReducer);
|
||||
|
||||
return (
|
||||
<Flex vertical gap={24}>
|
||||
<ProjectStats t={t} />
|
||||
|
||||
<Flex gap={24} className="grid lg:grid-cols-2">
|
||||
<Card
|
||||
className="custom-insights-card"
|
||||
title={
|
||||
<Typography.Text style={{ fontSize: 16, fontWeight: 500 }}>
|
||||
{t('tasks.overdueTasks')}
|
||||
<Tooltip title={t('tasks.overdueTasksTooltip')}>
|
||||
<ExclamationCircleOutlined
|
||||
style={{
|
||||
color: colors.skyBlue,
|
||||
fontSize: 13,
|
||||
marginInlineStart: 4,
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Typography.Text>
|
||||
}
|
||||
extra={<Button type="link">{t('common.seeAll')}</Button>}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<OverdueTasksTable projectId={projectId} includeArchivedTasks={includeArchivedTasks} />
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
className="custom-insights-card"
|
||||
title={
|
||||
<Typography.Text style={{ fontSize: 16, fontWeight: 500 }}>
|
||||
{t('tasks.overLoggedTasks')}
|
||||
<Tooltip title={t('tasks.overLoggedTasksTooltip')}>
|
||||
<ExclamationCircleOutlined
|
||||
style={{
|
||||
color: colors.skyBlue,
|
||||
fontSize: 13,
|
||||
marginInlineStart: 4,
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Typography.Text>
|
||||
}
|
||||
extra={<Button type="link">{t('common.seeAll')}</Button>}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<OverLoggedTasksTable projectId={projectId} includeArchivedTasks={includeArchivedTasks} />
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
className="custom-insights-card"
|
||||
title={
|
||||
<Typography.Text style={{ fontSize: 16, fontWeight: 500 }}>
|
||||
{t('tasks.tasksCompletedEarly')}
|
||||
</Typography.Text>
|
||||
}
|
||||
extra={<Button type="link">{t('common.seeAll')}</Button>}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<TaskCompletedEarlyTable
|
||||
projectId={projectId}
|
||||
includeArchivedTasks={includeArchivedTasks}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
className="custom-insights-card"
|
||||
title={
|
||||
<Typography.Text style={{ fontSize: 16, fontWeight: 500 }}>
|
||||
{t('tasks.tasksCompletedLate')}
|
||||
</Typography.Text>
|
||||
}
|
||||
extra={<Button type="link">{t('common.seeAll')}</Button>}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<TaskCompletedLateTable
|
||||
projectId={projectId}
|
||||
includeArchivedTasks={includeArchivedTasks}
|
||||
/>
|
||||
</Card>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default InsightsTasks;
|
||||
@@ -0,0 +1,137 @@
|
||||
import { Avatar, Button, Flex, Table, Typography } from 'antd';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { colors } from '@/styles/colors';
|
||||
import { TableProps } from 'antd/lib';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import { IInsightTasks } from '@/types/project/projectInsights.types';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { projectInsightsApiService } from '@/api/projects/insights/project-insights.api.service';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
|
||||
const OverLoggedTasksTable = () => {
|
||||
const { includeArchivedTasks, projectId } = useAppSelector(state => state.projectInsightsReducer);
|
||||
|
||||
const [overLoggedTaskList, setOverLoggedTaskList] = useState<IInsightTasks[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { refreshTimestamp } = useAppSelector(state => state.projectReducer);
|
||||
|
||||
|
||||
const getOverLoggedTasks = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await projectInsightsApiService.getOverloggedTasks(
|
||||
projectId,
|
||||
includeArchivedTasks
|
||||
);
|
||||
if (res.done) {
|
||||
setOverLoggedTaskList(res.body);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching over logged tasks', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getOverLoggedTasks();
|
||||
}, [projectId, includeArchivedTasks,refreshTimestamp]);
|
||||
|
||||
// table columns
|
||||
const columns: TableProps['columns'] = [
|
||||
{
|
||||
key: 'name',
|
||||
title: 'Name',
|
||||
render: (record: IInsightTasks) => <Typography.Text>{record.name}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
title: 'Status',
|
||||
render: (record: IInsightTasks) => (
|
||||
<Flex
|
||||
gap={4}
|
||||
style={{
|
||||
width: 'fit-content',
|
||||
borderRadius: 24,
|
||||
paddingInline: 6,
|
||||
backgroundColor: record.status_color,
|
||||
color: colors.darkGray,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<Typography.Text
|
||||
ellipsis={{ expanded: false }}
|
||||
style={{
|
||||
color: colors.darkGray,
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{record.status_name}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'members',
|
||||
title: 'Members',
|
||||
render: (record: IInsightTasks) =>
|
||||
record.status_name ? (
|
||||
<Avatar.Group>
|
||||
{/* {record.names.map((member) => (
|
||||
<CustomAvatar avatarName={member.memberName} size={26} />
|
||||
))} */}
|
||||
</Avatar.Group>
|
||||
) : (
|
||||
<Button
|
||||
disabled
|
||||
type="dashed"
|
||||
shape="circle"
|
||||
size="small"
|
||||
icon={
|
||||
<PlusOutlined
|
||||
style={{
|
||||
fontSize: 12,
|
||||
width: 22,
|
||||
height: 22,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'overLoggedTime',
|
||||
title: 'Over Logged Time',
|
||||
render: (record: IInsightTasks) => (
|
||||
<Typography.Text>{record.overlogged_time}</Typography.Text>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Table
|
||||
className="custom-two-colors-row-table"
|
||||
dataSource={overLoggedTaskList}
|
||||
columns={columns}
|
||||
rowKey={record => record.taskId}
|
||||
pagination={{
|
||||
showSizeChanger: false,
|
||||
defaultPageSize: 10,
|
||||
}}
|
||||
loading={loading}
|
||||
onRow={record => {
|
||||
return {
|
||||
style: {
|
||||
cursor: 'pointer',
|
||||
height: 36,
|
||||
},
|
||||
};
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default OverLoggedTasksTable;
|
||||
@@ -0,0 +1,113 @@
|
||||
import { Flex, Table, Typography } from 'antd';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { colors } from '@/styles/colors';
|
||||
import { TableProps } from 'antd/lib';
|
||||
import { simpleDateFormat } from '@/utils/simpleDateFormat';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { IInsightTasks } from '@/types/project/projectInsights.types';
|
||||
import { projectInsightsApiService } from '@/api/projects/insights/project-insights.api.service';
|
||||
|
||||
const OverdueTasksTable = ({
|
||||
projectId,
|
||||
includeArchivedTasks,
|
||||
}: {
|
||||
projectId: string;
|
||||
includeArchivedTasks: boolean;
|
||||
}) => {
|
||||
const [overdueTaskList, setOverdueTaskList] = useState<IInsightTasks[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { refreshTimestamp } = useAppSelector(state => state.projectReducer);
|
||||
|
||||
|
||||
const getOverdueTasks = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await projectInsightsApiService.getOverdueTasks(projectId, includeArchivedTasks);
|
||||
if (res.done) {
|
||||
setOverdueTaskList(res.body);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching overdue tasks:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getOverdueTasks();
|
||||
}, [projectId, includeArchivedTasks,refreshTimestamp]);
|
||||
|
||||
// table columns
|
||||
const columns: TableProps['columns'] = [
|
||||
{
|
||||
key: 'name',
|
||||
title: 'Name',
|
||||
render: (record: IInsightTasks) => <Typography.Text>{record.name}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
title: 'Status',
|
||||
render: (record: IInsightTasks) => (
|
||||
<Flex
|
||||
gap={4}
|
||||
style={{
|
||||
width: 'fit-content',
|
||||
borderRadius: 24,
|
||||
paddingInline: 6,
|
||||
backgroundColor: record.status_color,
|
||||
color: colors.darkGray,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<Typography.Text
|
||||
ellipsis={{ expanded: false }}
|
||||
style={{
|
||||
color: colors.darkGray,
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{record.status_name}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'dueDate',
|
||||
title: 'End Date',
|
||||
render: (record: IInsightTasks) => (
|
||||
<Typography.Text>
|
||||
{record.end_date ? simpleDateFormat(record.end_date) : 'N/A'}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'daysOverdue',
|
||||
title: 'Days overdue',
|
||||
render: (record: IInsightTasks) => <Typography.Text>{record.days_overdue}</Typography.Text>,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Table
|
||||
className="custom-two-colors-row-table"
|
||||
dataSource={overdueTaskList}
|
||||
columns={columns}
|
||||
rowKey={record => record.taskId}
|
||||
pagination={{
|
||||
showSizeChanger: false,
|
||||
defaultPageSize: 10,
|
||||
}}
|
||||
loading={loading}
|
||||
onRow={record => {
|
||||
return {
|
||||
style: {
|
||||
cursor: 'pointer',
|
||||
height: 36,
|
||||
},
|
||||
};
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default OverdueTasksTable;
|
||||
@@ -0,0 +1,117 @@
|
||||
import { Flex, Table, Typography } from 'antd';
|
||||
import { TableProps } from 'antd/lib';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { projectInsightsApiService } from '@/api/projects/insights/project-insights.api.service';
|
||||
import { IInsightTasks } from '@/types/project/projectInsights.types';
|
||||
import { colors } from '@/styles/colors';
|
||||
import { simpleDateFormat } from '@/utils/simpleDateFormat';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
|
||||
const TaskCompletedEarlyTable = ({
|
||||
projectId,
|
||||
includeArchivedTasks,
|
||||
}: {
|
||||
projectId: string;
|
||||
includeArchivedTasks: boolean;
|
||||
}) => {
|
||||
const [earlyCompletedTaskList, setEarlyCompletedTaskList] = useState<IInsightTasks[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { refreshTimestamp } = useAppSelector(state => state.projectReducer);
|
||||
|
||||
|
||||
const getEarlyCompletedTasks = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await projectInsightsApiService.getTasksCompletedEarly(
|
||||
projectId,
|
||||
includeArchivedTasks
|
||||
);
|
||||
if (res.done) {
|
||||
setEarlyCompletedTaskList(res.body);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching early completed tasks', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getEarlyCompletedTasks();
|
||||
}, [projectId, includeArchivedTasks, refreshTimestamp]);
|
||||
|
||||
const columns: TableProps['columns'] = [
|
||||
{
|
||||
key: 'name',
|
||||
title: 'Name',
|
||||
render: (record: IInsightTasks) => <Typography.Text>{record.name}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
title: 'Status',
|
||||
render: (record: IInsightTasks) => (
|
||||
<Flex
|
||||
gap={4}
|
||||
style={{
|
||||
width: 'fit-content',
|
||||
borderRadius: 24,
|
||||
paddingInline: 6,
|
||||
backgroundColor: record.status_color,
|
||||
color: colors.darkGray,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<Typography.Text
|
||||
ellipsis={{ expanded: false }}
|
||||
style={{
|
||||
color: colors.darkGray,
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{record.status_name}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'dueDate',
|
||||
title: 'End Date',
|
||||
render: (record: IInsightTasks) => (
|
||||
<Typography.Text>
|
||||
{record.end_date ? simpleDateFormat(record.end_date) : 'N/A'}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'completedDate',
|
||||
title: 'Completed At',
|
||||
render: (record: IInsightTasks) => (
|
||||
<Typography.Text>{simpleDateFormat(record.completed_at || null)}</Typography.Text>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Table
|
||||
className="custom-two-colors-row-table"
|
||||
dataSource={earlyCompletedTaskList}
|
||||
columns={columns}
|
||||
rowKey={record => record.taskId}
|
||||
loading={loading}
|
||||
pagination={{
|
||||
showSizeChanger: false,
|
||||
defaultPageSize: 10,
|
||||
}}
|
||||
onRow={record => ({
|
||||
style: {
|
||||
cursor: 'pointer',
|
||||
height: 36,
|
||||
},
|
||||
})}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskCompletedEarlyTable;
|
||||
@@ -0,0 +1,118 @@
|
||||
import { Flex, Table, Typography } from 'antd';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { colors } from '@/styles/colors';
|
||||
import { TableProps } from 'antd/lib';
|
||||
import { simpleDateFormat } from '@/utils/simpleDateFormat';
|
||||
import { IInsightTasks } from '@/types/project/projectInsights.types';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { projectInsightsApiService } from '@/api/projects/insights/project-insights.api.service';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
|
||||
const TaskCompletedLateTable = ({
|
||||
projectId,
|
||||
includeArchivedTasks,
|
||||
}: {
|
||||
projectId: string;
|
||||
includeArchivedTasks: boolean;
|
||||
}) => {
|
||||
const [lateCompletedTaskList, setLateCompletedTaskList] = useState<IInsightTasks[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { refreshTimestamp } = useAppSelector(state => state.projectReducer);
|
||||
|
||||
|
||||
const getLateCompletedTasks = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await projectInsightsApiService.getTasksCompletedLate(
|
||||
projectId,
|
||||
includeArchivedTasks
|
||||
);
|
||||
if (res.done) {
|
||||
setLateCompletedTaskList(res.body);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching late completed tasks', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getLateCompletedTasks();
|
||||
}, [projectId, includeArchivedTasks,refreshTimestamp]);
|
||||
|
||||
// table columns
|
||||
const columns: TableProps['columns'] = [
|
||||
{
|
||||
key: 'name',
|
||||
title: 'Name',
|
||||
render: (record: IInsightTasks) => <Typography.Text>{record.name}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
title: 'Status',
|
||||
render: (record: IInsightTasks) => (
|
||||
<Flex
|
||||
gap={4}
|
||||
style={{
|
||||
width: 'fit-content',
|
||||
borderRadius: 24,
|
||||
paddingInline: 6,
|
||||
backgroundColor: record.status_color,
|
||||
color: colors.darkGray,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<Typography.Text
|
||||
ellipsis={{ expanded: false }}
|
||||
style={{
|
||||
color: colors.darkGray,
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{record.status_name}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'dueDate',
|
||||
title: 'End Date',
|
||||
render: (record: IInsightTasks) => (
|
||||
<Typography.Text>{simpleDateFormat(record.end_date || null)}</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'completedDate',
|
||||
title: 'Completed At',
|
||||
render: (record: IInsightTasks) => (
|
||||
<Typography.Text>{simpleDateFormat(record.completed_at || null)}</Typography.Text>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Table
|
||||
className="custom-two-colors-row-table"
|
||||
dataSource={lateCompletedTaskList}
|
||||
columns={columns}
|
||||
rowKey={record => record.taskId}
|
||||
pagination={{
|
||||
showSizeChanger: false,
|
||||
defaultPageSize: 10,
|
||||
}}
|
||||
loading={loading}
|
||||
size="small"
|
||||
onRow={record => {
|
||||
return {
|
||||
style: {
|
||||
cursor: 'pointer',
|
||||
height: 36,
|
||||
},
|
||||
};
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskCompletedLateTable;
|
||||
@@ -0,0 +1,64 @@
|
||||
import ProjectStatsCard from '@/components/projects/project-stats-card';
|
||||
import { Flex } from 'antd';
|
||||
import groupIcon from '@/assets/icons/insightsIcons/group.png';
|
||||
import warningIcon from '@/assets/icons/insightsIcons/warning.png';
|
||||
import unassignedIcon from '@/assets/icons/insightsIcons/block-user.png';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { projectInsightsApiService } from '@/api/projects/insights/project-insights.api.service';
|
||||
import { IProjectMemberStats } from '@/types/project/project-insights.types';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
|
||||
const MemberStats = () => {
|
||||
const { includeArchivedTasks, projectId } = useAppSelector(state => state.projectInsightsReducer);
|
||||
|
||||
const [memberStats, setMemberStats] = useState<IProjectMemberStats | null>(null);
|
||||
const [loadingStats, setLoadingStats] = useState(false);
|
||||
const { refreshTimestamp } = useAppSelector(state => state.projectReducer);
|
||||
|
||||
const fetchMemberStats = async () => {
|
||||
setLoadingStats(true);
|
||||
try {
|
||||
const res = await projectInsightsApiService.getMemberInsightAStats(
|
||||
projectId,
|
||||
includeArchivedTasks
|
||||
);
|
||||
if (res.done) {
|
||||
setMemberStats(res.body);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching member stats:', error);
|
||||
} finally {
|
||||
setLoadingStats(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchMemberStats();
|
||||
}, [projectId, includeArchivedTasks,refreshTimestamp]);
|
||||
|
||||
return (
|
||||
<Flex gap={24} className="grid sm:grid-cols-2 sm:grid-rows-2 lg:grid-cols-3 lg:grid-rows-1">
|
||||
<ProjectStatsCard
|
||||
icon={groupIcon}
|
||||
title="Project Members"
|
||||
children={memberStats?.total_members_count}
|
||||
loading={loadingStats}
|
||||
/>
|
||||
<ProjectStatsCard
|
||||
icon={warningIcon}
|
||||
title="Assignees with overdue tasks"
|
||||
children={memberStats?.overdue_members}
|
||||
loading={loadingStats}
|
||||
/>
|
||||
<ProjectStatsCard
|
||||
icon={unassignedIcon}
|
||||
title="Unassigned Members"
|
||||
children={memberStats?.unassigned_members}
|
||||
loading={loadingStats}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default MemberStats;
|
||||
@@ -0,0 +1,96 @@
|
||||
import ProjectStatsCard from '@/components/projects/project-stats-card';
|
||||
import { Flex, Tooltip } from 'antd';
|
||||
import checkIcon from '@assets/icons/insightsIcons/insights-check.png';
|
||||
import clipboardIcon from '@assets/icons/insightsIcons/clipboard.png';
|
||||
import clockIcon from '@assets/icons/insightsIcons/clock-green.png';
|
||||
import warningIcon from '@assets/icons/insightsIcons/warning.png';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { projectInsightsApiService } from '@/api/projects/insights/project-insights.api.service';
|
||||
import { IProjectInsightsGetRequest } from '@/types/project/projectInsights.types';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { TFunction } from 'i18next';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
|
||||
const ProjectStats = ({ t }: { t: TFunction }) => {
|
||||
const { includeArchivedTasks, projectId } = useAppSelector(state => state.projectInsightsReducer);
|
||||
const [stats, setStats] = useState<IProjectInsightsGetRequest>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { refreshTimestamp } = useAppSelector(state => state.projectReducer);
|
||||
|
||||
|
||||
const getProjectStats = async () => {
|
||||
if (!projectId) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await projectInsightsApiService.getProjectOverviewData(
|
||||
projectId,
|
||||
includeArchivedTasks
|
||||
);
|
||||
if (res.done) {
|
||||
setStats(res.body);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Error fetching project stats:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getProjectStats();
|
||||
}, [projectId, includeArchivedTasks,refreshTimestamp]);
|
||||
|
||||
const tooltipTable = (
|
||||
<table>
|
||||
<tbody>
|
||||
<tr style={{ display: 'flex', gap: 12 }}>
|
||||
<td style={{ width: 120 }}>{t('common.totalEstimation')}</td>
|
||||
<td>{stats.total_estimated_hours_string || '0h'}</td>
|
||||
</tr>
|
||||
<tr style={{ display: 'flex', gap: 12 }}>
|
||||
<td style={{ width: 120 }}>{t('common.totalLogged')}</td>
|
||||
<td>{stats.total_logged_hours_string || '0h'}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex gap={24} className="grid sm:grid-cols-2 sm:grid-rows-2 lg:grid-cols-4 lg:grid-rows-1">
|
||||
<ProjectStatsCard
|
||||
icon={checkIcon}
|
||||
title={t('common.completedTasks')}
|
||||
loading={loading}
|
||||
children={stats.completed_tasks_count ?? 0}
|
||||
/>
|
||||
<ProjectStatsCard
|
||||
icon={clipboardIcon}
|
||||
title={t('common.incompleteTasks')}
|
||||
loading={loading}
|
||||
children={stats.todo_tasks_count ?? 0}
|
||||
/>
|
||||
<ProjectStatsCard
|
||||
icon={warningIcon}
|
||||
title={t('common.overdueTasks')}
|
||||
tooltip={t('common.overdueTasksTooltip')}
|
||||
loading={loading}
|
||||
children={stats.overdue_count ?? 0}
|
||||
/>
|
||||
<ProjectStatsCard
|
||||
icon={clockIcon}
|
||||
title={t('common.totalLoggedHours')}
|
||||
tooltip={t('common.totalLoggedHoursTooltip')}
|
||||
loading={loading}
|
||||
children={
|
||||
<Tooltip title={tooltipTable} trigger={'hover'}>
|
||||
{stats.total_logged_hours_string || '0h'}
|
||||
</Tooltip>
|
||||
}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectStats;
|
||||
@@ -0,0 +1,179 @@
|
||||
import { DownloadOutlined } from '@ant-design/icons';
|
||||
import { Badge, Button, Checkbox, Flex, Segmented } from 'antd';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { colors } from '@/styles/colors';
|
||||
import InsightsMembers from './insights-members/insights-members';
|
||||
import InsightsOverview from './insights-overview/insights-overview';
|
||||
import InsightsTasks from './insights-tasks/insights-tasks';
|
||||
import {
|
||||
setActiveSegment,
|
||||
setIncludeArchivedTasks,
|
||||
setProjectId,
|
||||
} from '@/features/projects/insights/project-insights.slice';
|
||||
import { format } from 'date-fns';
|
||||
import html2canvas from 'html2canvas';
|
||||
import jsPDF from 'jspdf';
|
||||
import logo from '@/assets/images/logo.png';
|
||||
import { evt_project_insights_members_visit, evt_project_insights_overview_visit, evt_project_insights_tasks_visit } from '@/shared/worklenz-analytics-events';
|
||||
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
|
||||
|
||||
type SegmentType = 'Overview' | 'Members' | 'Tasks';
|
||||
|
||||
const ProjectViewInsights = () => {
|
||||
const { projectId } = useParams();
|
||||
const { t } = useTranslation('project-view-insights');
|
||||
const { trackMixpanelEvent } = useMixpanelTracking();
|
||||
const exportRef = useRef<HTMLDivElement>(null);
|
||||
const { refreshTimestamp } = useAppSelector(state => state.projectReducer);
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
const dispatch = useAppDispatch();
|
||||
const [exportLoading, setExportLoading] = useState(false);
|
||||
const { activeSegment, includeArchivedTasks } = useAppSelector(
|
||||
state => state.projectInsightsReducer
|
||||
);
|
||||
const {
|
||||
project: selectedProject,
|
||||
} = useAppSelector(state => state.projectReducer);
|
||||
|
||||
const handleSegmentChange = (value: SegmentType) => {
|
||||
dispatch(setActiveSegment(value));
|
||||
};
|
||||
|
||||
const toggleArchivedTasks = () => {
|
||||
dispatch(setIncludeArchivedTasks(!includeArchivedTasks));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (projectId) {
|
||||
dispatch(setProjectId(projectId));
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
const renderSegmentContent = () => {
|
||||
if (!projectId) return null;
|
||||
|
||||
switch (activeSegment) {
|
||||
case 'Overview':
|
||||
trackMixpanelEvent(evt_project_insights_overview_visit);
|
||||
return <InsightsOverview t={t} />;
|
||||
case 'Members':
|
||||
trackMixpanelEvent(evt_project_insights_members_visit);
|
||||
return <InsightsMembers t={t} />;
|
||||
case 'Tasks':
|
||||
trackMixpanelEvent(evt_project_insights_tasks_visit);
|
||||
return <InsightsTasks t={t} />;
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
if (!projectId) return;
|
||||
try {
|
||||
setExportLoading(true);
|
||||
await dispatch(setActiveSegment(activeSegment));
|
||||
await exportPdf(selectedProject?.name || '', activeSegment);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
setExportLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const exportPdf = async (projectName: string | null, activeSegment: string | '') => {
|
||||
if (!exportRef.current) return;
|
||||
const element = exportRef.current;
|
||||
const canvas = await html2canvas(element);
|
||||
const imgData = canvas.toDataURL('image/png');
|
||||
|
||||
const pdf = new jsPDF('p', 'mm', 'a4');
|
||||
const bufferX = 5;
|
||||
const bufferY = 28;
|
||||
const imgProps = pdf.getImageProperties(imgData);
|
||||
const pdfWidth = pdf.internal.pageSize.getWidth() - 2 * bufferX;
|
||||
const pdfHeight = (imgProps.height * pdfWidth) / imgProps.width;
|
||||
|
||||
const logoImg = new Image();
|
||||
logoImg.src = logo;
|
||||
logoImg.onload = () => {
|
||||
pdf.addImage(logoImg, 'PNG', pdf.internal.pageSize.getWidth() / 2 - 12, 5, 30, 6.5);
|
||||
|
||||
pdf.setFontSize(14);
|
||||
pdf.setTextColor(0, 0, 0, 0.85);
|
||||
pdf.text(
|
||||
[`Insights - ${projectName} - ${activeSegment}`, format(new Date(), 'yyyy-MM-dd')],
|
||||
pdf.internal.pageSize.getWidth() / 2,
|
||||
17,
|
||||
{ align: 'center' }
|
||||
);
|
||||
|
||||
pdf.addImage(imgData, 'PNG', bufferX, bufferY, pdfWidth, pdfHeight);
|
||||
pdf.save(`${activeSegment} ${format(new Date(), 'yyyy-MM-dd')}.pdf`);
|
||||
};
|
||||
|
||||
logoImg.onerror = (error) => {
|
||||
pdf.setFontSize(14);
|
||||
pdf.setTextColor(0, 0, 0, 0.85);
|
||||
pdf.text(
|
||||
[`Insights - ${projectName} - ${activeSegment}`, format(new Date(), 'yyyy-MM-dd')],
|
||||
pdf.internal.pageSize.getWidth() / 2,
|
||||
17,
|
||||
{ align: 'center' }
|
||||
);
|
||||
pdf.addImage(imgData, 'PNG', bufferX, bufferY, pdfWidth, pdfHeight);
|
||||
pdf.save(`${activeSegment} ${format(new Date(), 'yyyy-MM-dd')}.pdf`);
|
||||
};
|
||||
};
|
||||
|
||||
useEffect(()=>{
|
||||
if(projectId){
|
||||
dispatch(setActiveSegment('Overview'));
|
||||
}
|
||||
},[refreshTimestamp])
|
||||
|
||||
return (
|
||||
<Flex vertical gap={24}>
|
||||
<Flex align="center" justify="space-between">
|
||||
<Segmented
|
||||
options={['Overview', 'Members', 'Tasks']}
|
||||
defaultValue={activeSegment}
|
||||
value={activeSegment}
|
||||
onChange={handleSegmentChange}
|
||||
/>
|
||||
|
||||
<Flex gap={8}>
|
||||
<Flex
|
||||
gap={8}
|
||||
align="center"
|
||||
style={{
|
||||
backgroundColor: themeMode === 'dark' ? '#141414' : '#f5f5f5',
|
||||
padding: '6px 15px',
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
<Checkbox checked={includeArchivedTasks} onClick={toggleArchivedTasks} />
|
||||
<Badge color={includeArchivedTasks ? colors.limeGreen : colors.vibrantOrange} dot>
|
||||
{t('common.includeArchivedTasks')}
|
||||
</Badge>
|
||||
</Flex>
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={handleExport}
|
||||
loading={exportLoading}
|
||||
>
|
||||
{t('common.export')}
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
<div ref={exportRef}>
|
||||
{renderSegmentContent()}
|
||||
</div>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectViewInsights;
|
||||
@@ -0,0 +1,295 @@
|
||||
// Ant Design Components
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
Card,
|
||||
Flex,
|
||||
Popconfirm,
|
||||
Progress,
|
||||
Skeleton,
|
||||
Table,
|
||||
TableProps,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
|
||||
// Icons
|
||||
import { DeleteOutlined, ExclamationCircleFilled, SyncOutlined } from '@ant-design/icons';
|
||||
|
||||
// React & Router
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
// Services & API
|
||||
import { projectsApiService } from '@/api/projects/projects.api.service';
|
||||
import { projectMembersApiService } from '@/api/project-members/project-members.api.service';
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
|
||||
// Types
|
||||
import { IProjectMembersViewModel, IProjectMemberViewModel } from '@/types/projectMember.types';
|
||||
|
||||
// Constants & Utils
|
||||
import { DEFAULT_PAGE_SIZE } from '@/shared/constants';
|
||||
import { colors } from '../../../../styles/colors';
|
||||
import logger from '@/utils/errorLogger';
|
||||
|
||||
// Components
|
||||
import EmptyListPlaceholder from '../../../../components/EmptyListPlaceholder';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { evt_project_members_visit } from '@/shared/worklenz-analytics-events';
|
||||
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
|
||||
|
||||
interface PaginationType {
|
||||
current: number;
|
||||
pageSize: number;
|
||||
field: string;
|
||||
order: string;
|
||||
total: number;
|
||||
pageSizeOptions: string[];
|
||||
size: 'small' | 'default';
|
||||
}
|
||||
|
||||
const ProjectViewMembers = () => {
|
||||
// Hooks
|
||||
const { projectId } = useParams();
|
||||
const { t } = useTranslation('project-view-members');
|
||||
const auth = useAuthService();
|
||||
const user = auth.getCurrentSession();
|
||||
const isOwnerOrAdmin = auth.isOwnerOrAdmin();
|
||||
const { trackMixpanelEvent } = useMixpanelTracking();
|
||||
|
||||
const { refreshTimestamp } = useAppSelector(state => state.projectReducer);
|
||||
|
||||
// State
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [members, setMembers] = useState<IProjectMembersViewModel>();
|
||||
const [pagination, setPagination] = useState<PaginationType>({
|
||||
current: 1,
|
||||
pageSize: DEFAULT_PAGE_SIZE,
|
||||
field: 'name',
|
||||
order: 'ascend',
|
||||
total: 0,
|
||||
pageSizeOptions: ['5', '10', '15', '20', '50', '100'],
|
||||
size: 'small',
|
||||
});
|
||||
|
||||
// API Functions
|
||||
const getProjectMembers = async () => {
|
||||
if (!projectId) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const res = await projectsApiService.getMembers(
|
||||
projectId,
|
||||
pagination.current,
|
||||
pagination.pageSize,
|
||||
pagination.field,
|
||||
pagination.order,
|
||||
null
|
||||
);
|
||||
if (res.done) {
|
||||
setMembers(res.body);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching members:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteMember = async (memberId: string | undefined) => {
|
||||
if (!memberId || !projectId) return;
|
||||
|
||||
try {
|
||||
const res = await projectMembersApiService.deleteProjectMember(memberId, projectId);
|
||||
if (res.done) {
|
||||
void getProjectMembers();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error deleting member:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Helper Functions
|
||||
const checkDisabled = (record: IProjectMemberViewModel): boolean => {
|
||||
if (!isOwnerOrAdmin) return true;
|
||||
if (user?.team_member_id === record.team_member_id) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
const calculateProgressPercent = (completed: number = 0, total: number = 0): number => {
|
||||
if (total === 0) return 0;
|
||||
return Math.floor((completed / total) * 100);
|
||||
};
|
||||
|
||||
const handleTableChange = (pagination: any, filters: any, sorter: any) => {
|
||||
setPagination({
|
||||
current: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
field: sorter.field || pagination.field,
|
||||
order: sorter.order || pagination.order,
|
||||
total: pagination.total,
|
||||
pageSizeOptions: pagination.pageSizeOptions,
|
||||
size: pagination.size,
|
||||
});
|
||||
};
|
||||
|
||||
// Effects
|
||||
useEffect(() => {
|
||||
void getProjectMembers();
|
||||
}, [refreshTimestamp, projectId, pagination.current, pagination.pageSize, pagination.field, pagination.order]);
|
||||
|
||||
useEffect(() => {
|
||||
trackMixpanelEvent(evt_project_members_visit);
|
||||
}, []);
|
||||
|
||||
// Table Configuration
|
||||
const columns: TableProps['columns'] = [
|
||||
{
|
||||
key: 'memberName',
|
||||
title: t('nameColumn'),
|
||||
dataIndex: 'name',
|
||||
sorter: true,
|
||||
sortOrder: pagination.order === 'ascend' && pagination.field === 'name' ? 'ascend' :
|
||||
pagination.order === 'descend' && pagination.field === 'name' ? 'descend' : null,
|
||||
render: (_,record: IProjectMemberViewModel) => (
|
||||
<Flex gap={8} align="center">
|
||||
<Avatar size={28} src={record.avatar_url}>
|
||||
{record.name?.charAt(0)}
|
||||
</Avatar>
|
||||
<Typography.Text>{record.name}</Typography.Text>
|
||||
</Flex>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'jobTitle',
|
||||
title: t('jobTitleColumn'),
|
||||
dataIndex: 'job_title',
|
||||
sorter: true,
|
||||
sortOrder: pagination.order === 'ascend' && pagination.field === 'job_title' ? 'ascend' :
|
||||
pagination.order === 'descend' && pagination.field === 'job_title' ? 'descend' : null,
|
||||
render: (_, record: IProjectMemberViewModel) => (
|
||||
<Typography.Text style={{ marginInlineStart: 12 }}>
|
||||
{record?.job_title || '-'}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'email',
|
||||
title: t('emailColumn'),
|
||||
dataIndex: 'email',
|
||||
sorter: true,
|
||||
sortOrder: pagination.order === 'ascend' && pagination.field === 'email' ? 'ascend' :
|
||||
pagination.order === 'descend' && pagination.field === 'email' ? 'descend' : null,
|
||||
render: (_, record: IProjectMemberViewModel) => (
|
||||
<Typography.Text>{record.email}</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'tasks',
|
||||
title: t('tasksColumn'),
|
||||
width: 90,
|
||||
render: (_, record: IProjectMemberViewModel) => (
|
||||
<Typography.Text style={{ marginInlineStart: 12 }}>
|
||||
{`${record.completed_tasks_count}/${record.all_tasks_count}`}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'taskProgress',
|
||||
title: t('taskProgressColumn'),
|
||||
render: (_, record: IProjectMemberViewModel) => (
|
||||
<Progress
|
||||
percent={calculateProgressPercent(record.completed_tasks_count, record.all_tasks_count)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'access',
|
||||
title: t('accessColumn'),
|
||||
dataIndex: 'access',
|
||||
sorter: true,
|
||||
sortOrder: pagination.order === 'ascend' && pagination.field === 'access' ? 'ascend' :
|
||||
pagination.order === 'descend' && pagination.field === 'access' ? 'descend' : null,
|
||||
render: (_, record: IProjectMemberViewModel) => (
|
||||
<Typography.Text style={{ textTransform: 'capitalize' }}>{record.access}</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actionBtns',
|
||||
width: 80,
|
||||
render: (record: IProjectMemberViewModel) => (
|
||||
<Flex gap={8} style={{ padding: 0 }} className="action-buttons">
|
||||
<Popconfirm
|
||||
title={t('deleteConfirmationTitle')}
|
||||
icon={<ExclamationCircleFilled style={{ color: colors.vibrantOrange }} />}
|
||||
okText={t('deleteConfirmationOk')}
|
||||
cancelText={t('deleteConfirmationCancel')}
|
||||
onConfirm={() => deleteMember(record.id)}
|
||||
>
|
||||
<Tooltip title={t('deleteButtonTooltip')}>
|
||||
<Button
|
||||
disabled={checkDisabled(record)}
|
||||
shape="default"
|
||||
icon={<DeleteOutlined />}
|
||||
size="small"
|
||||
/>
|
||||
</Tooltip>
|
||||
</Popconfirm>
|
||||
</Flex>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card
|
||||
style={{ width: '100%' }}
|
||||
title={
|
||||
<Flex justify="space-between">
|
||||
<Typography.Text style={{ fontSize: 16, fontWeight: 500 }}>
|
||||
{members?.total} {members?.total !== 1 ? t('membersCountPlural') : t('memberCount')}
|
||||
</Typography.Text>
|
||||
|
||||
<Tooltip title={t('refreshButtonTooltip')}>
|
||||
<Button
|
||||
shape="circle"
|
||||
icon={<SyncOutlined />}
|
||||
onClick={() => void getProjectMembers()}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
}
|
||||
>
|
||||
{members?.total === 0 ? (
|
||||
<EmptyListPlaceholder
|
||||
imageSrc="https://app.worklenz.com/assets/images/empty-box.webp"
|
||||
imageHeight={120}
|
||||
text={t('emptyText')}
|
||||
/>
|
||||
) : isLoading ? (
|
||||
<Skeleton />
|
||||
) : (
|
||||
<Table
|
||||
className="custom-two-colors-row-table"
|
||||
dataSource={members?.data}
|
||||
columns={columns}
|
||||
rowKey={record => record.id}
|
||||
pagination={{
|
||||
showSizeChanger: true,
|
||||
defaultPageSize: 20,
|
||||
}}
|
||||
onChange={handleTableChange}
|
||||
onRow={record => ({
|
||||
style: {
|
||||
cursor: 'pointer',
|
||||
height: 42,
|
||||
},
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectViewMembers;
|
||||
@@ -0,0 +1,309 @@
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
BellFilled,
|
||||
BellOutlined,
|
||||
CalendarOutlined,
|
||||
DownOutlined,
|
||||
EditOutlined,
|
||||
ImportOutlined,
|
||||
SaveOutlined,
|
||||
SettingOutlined,
|
||||
SyncOutlined,
|
||||
UsergroupAddOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { PageHeader } from '@ant-design/pro-components';
|
||||
import { Button, Dropdown, Flex, Tag, Tooltip, Typography } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { colors } from '@/styles/colors';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { SocketEvents } from '@/shared/socket-events';
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
import { useSocket } from '@/socket/socketContext';
|
||||
import { setProject, setImportTaskTemplateDrawerOpen, setRefreshTimestamp } from '@features/project/project.slice';
|
||||
import { addTask, fetchTaskGroups, fetchTaskListColumns, IGroupBy } from '@features/tasks/tasks.slice';
|
||||
import ProjectStatusIcon from '@/components/common/project-status-icon/project-status-icon';
|
||||
import { formatDate } from '@/utils/timeUtils';
|
||||
import { toggleSaveAsTemplateDrawer } from '@/features/projects/projectsSlice';
|
||||
import SaveProjectAsTemplate from '@/components/save-project-as-template/save-project-as-template';
|
||||
import {
|
||||
fetchProjectData,
|
||||
toggleProjectDrawer,
|
||||
setProjectId,
|
||||
} from '@/features/project/project-drawer.slice';
|
||||
import { setSelectedTaskId, setShowTaskDrawer } from '@/features/task-drawer/task-drawer.slice';
|
||||
import { useState } from 'react';
|
||||
import { ITaskCreateRequest } from '@/types/tasks/task-create-request.types';
|
||||
import { DEFAULT_TASK_NAME, UNMAPPED } from '@/shared/constants';
|
||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||
import { getGroupIdByGroupedColumn } from '@/services/task-list/taskList.service';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { createPortal } from 'react-dom';
|
||||
import ImportTaskTemplate from '@/components/task-templates/import-task-template';
|
||||
import ProjectDrawer from '@/components/projects/project-drawer/project-drawer';
|
||||
import { toggleProjectMemberDrawer } from '@/features/projects/singleProject/members/projectMembersSlice';
|
||||
import useIsProjectManager from '@/hooks/useIsProjectManager';
|
||||
import useTabSearchParam from '@/hooks/useTabSearchParam';
|
||||
import { addTaskCardToTheTop, fetchBoardTaskGroups } from '@/features/board/board-slice';
|
||||
import { fetchPhasesByProjectId } from '@/features/projects/singleProject/phase/phases.slice';
|
||||
|
||||
const ProjectViewHeader = () => {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation('project-view/project-view-header');
|
||||
const dispatch = useAppDispatch();
|
||||
const currentSession = useAuthService().getCurrentSession();
|
||||
const isOwnerOrAdmin = useAuthService().isOwnerOrAdmin();
|
||||
const isProjectManager = useIsProjectManager();
|
||||
const { tab } = useTabSearchParam();
|
||||
|
||||
const { socket } = useSocket();
|
||||
|
||||
const {
|
||||
project: selectedProject,
|
||||
projectId,
|
||||
} = useAppSelector(state => state.projectReducer);
|
||||
const { loadingGroups, groupBy } = useAppSelector(state => state.taskReducer);
|
||||
|
||||
const [creatingTask, setCreatingTask] = useState(false);
|
||||
|
||||
const handleRefresh = () => {
|
||||
if (!projectId) return;
|
||||
switch (tab) {
|
||||
case 'tasks-list':
|
||||
dispatch(fetchTaskListColumns(projectId));
|
||||
dispatch(fetchPhasesByProjectId(projectId))
|
||||
dispatch(fetchTaskGroups(projectId));
|
||||
break;
|
||||
case 'board':
|
||||
dispatch(fetchBoardTaskGroups(projectId));
|
||||
break;
|
||||
case 'project-insights-member-overview':
|
||||
dispatch(setRefreshTimestamp());
|
||||
break;
|
||||
case 'all-attachments':
|
||||
dispatch(setRefreshTimestamp());
|
||||
break;
|
||||
case 'members':
|
||||
dispatch(setRefreshTimestamp());
|
||||
break;
|
||||
case 'updates':
|
||||
dispatch(setRefreshTimestamp());
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubscribe = () => {
|
||||
if (selectedProject?.id) {
|
||||
const newSubscriptionState = !selectedProject.subscribed;
|
||||
|
||||
dispatch(setProject({ ...selectedProject, subscribed: newSubscriptionState }));
|
||||
|
||||
socket?.emit(SocketEvents.PROJECT_SUBSCRIBERS_CHANGE.toString(), {
|
||||
project_id: selectedProject.id,
|
||||
user_id: currentSession?.id,
|
||||
team_member_id: currentSession?.team_member_id,
|
||||
mode: newSubscriptionState ? 1 : 0,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSettingsClick = () => {
|
||||
if (selectedProject?.id) {
|
||||
dispatch(setProjectId(selectedProject.id));
|
||||
dispatch(fetchProjectData(selectedProject.id));
|
||||
dispatch(toggleProjectDrawer());
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateTask = () => {
|
||||
try {
|
||||
setCreatingTask(true);
|
||||
|
||||
const body: ITaskCreateRequest = {
|
||||
name: DEFAULT_TASK_NAME,
|
||||
project_id: selectedProject?.id,
|
||||
reporter_id: currentSession?.id,
|
||||
team_id: currentSession?.team_id,
|
||||
};
|
||||
|
||||
socket?.once(SocketEvents.QUICK_TASK.toString(), (task: IProjectTask) => {
|
||||
if (task.id) {
|
||||
dispatch(setSelectedTaskId(task.id));
|
||||
dispatch(setShowTaskDrawer(true));
|
||||
|
||||
const groupId = groupBy === IGroupBy.PHASE ? UNMAPPED : getGroupIdByGroupedColumn(task);
|
||||
if (groupId) {
|
||||
if (tab === 'board') {
|
||||
dispatch(addTaskCardToTheTop({ sectionId: groupId, task }));
|
||||
} else {
|
||||
dispatch(addTask({ task, groupId }));
|
||||
}
|
||||
socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
socket?.emit(SocketEvents.QUICK_TASK.toString(), JSON.stringify(body));
|
||||
} catch (error) {
|
||||
logger.error('Error creating task', error);
|
||||
} finally {
|
||||
setCreatingTask(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImportTaskTemplate = () => {
|
||||
dispatch(setImportTaskTemplateDrawerOpen(true));
|
||||
};
|
||||
|
||||
const dropdownItems = [
|
||||
{
|
||||
key: 'import',
|
||||
label: (
|
||||
<div style={{ width: '100%', margin: 0, padding: 0 }} onClick={handleImportTaskTemplate}>
|
||||
<ImportOutlined /> Import task
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const renderProjectAttributes = () => (
|
||||
<Flex gap={8} align="center">
|
||||
{selectedProject?.category_id && (
|
||||
<Tag color={colors.vibrantOrange} style={{ borderRadius: 24, paddingInline: 8, margin: 0 }}>
|
||||
{selectedProject.category_name}
|
||||
</Tag>
|
||||
)}
|
||||
|
||||
{selectedProject?.status && (
|
||||
<Tooltip title={selectedProject.status}>
|
||||
<ProjectStatusIcon
|
||||
iconName={selectedProject.status_icon || ''}
|
||||
color={selectedProject.status_color || ''}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{(selectedProject?.start_date || selectedProject?.end_date) && (
|
||||
<Tooltip
|
||||
title={
|
||||
<Typography.Text style={{ color: colors.white }}>
|
||||
{selectedProject?.start_date &&
|
||||
`${t('startDate')}: ${formatDate(new Date(selectedProject.start_date))}`}
|
||||
{selectedProject?.end_date && (
|
||||
<>
|
||||
<br />
|
||||
{`${t('endDate')}: ${formatDate(new Date(selectedProject.end_date))}`}
|
||||
</>
|
||||
)}
|
||||
</Typography.Text>
|
||||
}
|
||||
>
|
||||
<CalendarOutlined style={{ fontSize: 16 }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{selectedProject?.notes && (
|
||||
<Typography.Text type="secondary">{selectedProject.notes}</Typography.Text>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
|
||||
const renderHeaderActions = () => (
|
||||
<Flex gap={8} align="center">
|
||||
<Tooltip title="Refresh project">
|
||||
<Button
|
||||
shape="circle"
|
||||
icon={<SyncOutlined spin={loadingGroups} />}
|
||||
onClick={handleRefresh}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
{(isOwnerOrAdmin) && (
|
||||
<Tooltip title="Save as template">
|
||||
<Button
|
||||
shape="circle"
|
||||
icon={<SaveOutlined />}
|
||||
onClick={() => dispatch(toggleSaveAsTemplateDrawer())}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Tooltip title="Project settings">
|
||||
<Button shape="circle" icon={<SettingOutlined />} onClick={handleSettingsClick} />
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title={t('subscribe')}>
|
||||
<Button
|
||||
shape="round"
|
||||
icon={selectedProject?.subscribed ? <BellFilled /> : <BellOutlined />}
|
||||
onClick={handleSubscribe}
|
||||
>
|
||||
{selectedProject?.subscribed ? t('unsubscribe') : t('subscribe')}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
{(isOwnerOrAdmin || isProjectManager) && (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<UsergroupAddOutlined />}
|
||||
onClick={() => dispatch(toggleProjectMemberDrawer())}
|
||||
>
|
||||
Invite
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isOwnerOrAdmin ? (
|
||||
<Dropdown.Button
|
||||
loading={creatingTask}
|
||||
type="primary"
|
||||
icon={<DownOutlined />}
|
||||
menu={{ items: dropdownItems }}
|
||||
trigger={['click']}
|
||||
onClick={handleCreateTask}
|
||||
>
|
||||
<EditOutlined /> {t('createTask')}
|
||||
</Dropdown.Button>
|
||||
) : (
|
||||
<Button
|
||||
loading={creatingTask}
|
||||
type="primary"
|
||||
icon={<EditOutlined />}
|
||||
onClick={handleCreateTask}
|
||||
>
|
||||
{t('createTask')}
|
||||
</Button>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
className="site-page-header"
|
||||
title={
|
||||
<Flex gap={8} align="center">
|
||||
<ArrowLeftOutlined
|
||||
style={{ fontSize: 16 }}
|
||||
onClick={() => navigate('/worklenz/projects')}
|
||||
/>
|
||||
<Typography.Title level={4} style={{ marginBlockEnd: 0, marginInlineStart: 12 }}>
|
||||
{selectedProject?.name}
|
||||
</Typography.Title>
|
||||
{renderProjectAttributes()}
|
||||
</Flex>
|
||||
}
|
||||
style={{ paddingInline: 0, marginBlockEnd: 12 }}
|
||||
extra={renderHeaderActions()}
|
||||
/>
|
||||
{createPortal(<ProjectDrawer onClose={() => { }} />, document.body, 'project-drawer')}
|
||||
{createPortal(<ImportTaskTemplate />, document.body, 'import-task-template')}
|
||||
{createPortal(<SaveProjectAsTemplate />, document.body, 'save-project-as-template')}
|
||||
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectViewHeader;
|
||||
@@ -0,0 +1,6 @@
|
||||
.profile-badge.ant-badge.ant-badge-status .ant-badge-status-success {
|
||||
box-shadow: rgb(255, 255, 255) 0px 0px 0px 1px;
|
||||
background-color: #52c41a;
|
||||
height: 8px;
|
||||
width: 8px;
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { PushpinFilled, PushpinOutlined, QuestionCircleOutlined } from '@ant-design/icons';
|
||||
import { Badge, Button, ConfigProvider, Flex, Tabs, TabsProps, Tooltip } from 'antd';
|
||||
import { useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { getProject, setProjectId, setProjectView } from '@/features/project/project.slice';
|
||||
import { fetchStatuses, resetStatuses } from '@/features/taskAttributes/taskStatusSlice';
|
||||
import { projectsApiService } from '@/api/projects/projects.api.service';
|
||||
import { colors } from '@/styles/colors';
|
||||
import { useDocumentTitle } from '@/hooks/useDoumentTItle';
|
||||
import ProjectViewHeader from './project-view-header';
|
||||
import './project-view.css';
|
||||
import { resetTaskListData } from '@/features/tasks/tasks.slice';
|
||||
import { resetBoardData } from '@/features/board/board-slice';
|
||||
import { fetchLabels } from '@/features/taskAttributes/taskLabelSlice';
|
||||
import { deselectAll } from '@/features/projects/bulkActions/bulkActionSlice';
|
||||
import { tabItems } from '@/lib/project/project-view-constants';
|
||||
import { setSelectedTaskId, setShowTaskDrawer } from '@/features/task-drawer/task-drawer.slice';
|
||||
|
||||
const DeleteStatusDrawer = React.lazy(() => import('@/components/project-task-filters/delete-status-drawer/delete-status-drawer'));
|
||||
const PhaseDrawer = React.lazy(() => import('@features/projects/singleProject/phase/PhaseDrawer'));
|
||||
const StatusDrawer = React.lazy(
|
||||
() => import('@/components/project-task-filters/create-status-drawer/create-status-drawer')
|
||||
);
|
||||
const ProjectMemberDrawer = React.lazy(
|
||||
() => import('@/components/projects/project-member-invite-drawer/project-member-invite-drawer')
|
||||
);
|
||||
const TaskDrawer = React.lazy(() => import('@components/task-drawer/task-drawer'));
|
||||
|
||||
const ProjectView = () => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useAppDispatch();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { projectId } = useParams();
|
||||
|
||||
const selectedProject = useAppSelector(state => state.projectReducer.project);
|
||||
useDocumentTitle(selectedProject?.name || 'Project View');
|
||||
const [activeTab, setActiveTab] = useState<string>(searchParams.get('tab') || tabItems[0].key);
|
||||
const [pinnedTab, setPinnedTab] = useState<string>(searchParams.get('pinned_tab') || '');
|
||||
const [taskid, setTaskId] = useState<string>(searchParams.get('task') || '');
|
||||
|
||||
useEffect(() => {
|
||||
if (projectId) {
|
||||
dispatch(setProjectId(projectId));
|
||||
dispatch(getProject(projectId)).then((res: any) => {
|
||||
if (!res.payload) {
|
||||
navigate('/worklenz/projects');
|
||||
return;
|
||||
}
|
||||
dispatch(fetchStatuses(projectId));
|
||||
dispatch(fetchLabels());
|
||||
});
|
||||
}
|
||||
if (taskid) {
|
||||
dispatch(setSelectedTaskId(taskid || ''));
|
||||
dispatch(setShowTaskDrawer(true));
|
||||
}
|
||||
}, [dispatch, navigate, projectId, taskid]);
|
||||
|
||||
const pinToDefaultTab = async (itemKey: string) => {
|
||||
if (!itemKey || !projectId) return;
|
||||
|
||||
const defaultView = itemKey === 'tasks-list' ? 'TASK_LIST' : 'BOARD';
|
||||
const res = await projectsApiService.updateDefaultTab({
|
||||
project_id: projectId,
|
||||
default_view: defaultView,
|
||||
});
|
||||
|
||||
if (res.done) {
|
||||
setPinnedTab(itemKey);
|
||||
tabItems.forEach(item => {
|
||||
if (item.key === itemKey) {
|
||||
item.isPinned = true;
|
||||
} else {
|
||||
item.isPinned = false;
|
||||
}
|
||||
});
|
||||
|
||||
navigate({
|
||||
pathname: `/worklenz/projects/${projectId}`,
|
||||
search: new URLSearchParams({
|
||||
tab: activeTab,
|
||||
pinned_tab: itemKey
|
||||
}).toString(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleTabChange = (key: string) => {
|
||||
setActiveTab(key);
|
||||
dispatch(setProjectView(key === 'board' ? 'kanban' : 'list'));
|
||||
navigate({
|
||||
pathname: location.pathname,
|
||||
search: new URLSearchParams({
|
||||
tab: key,
|
||||
pinned_tab: pinnedTab,
|
||||
}).toString(),
|
||||
});
|
||||
};
|
||||
|
||||
const tabMenuItems = tabItems.map(item => ({
|
||||
key: item.key,
|
||||
label: (
|
||||
<Flex align="center" style={{ color: colors.skyBlue }}>
|
||||
{item.label}
|
||||
{item.key === 'tasks-list' || item.key === 'board' ? (
|
||||
<ConfigProvider wave={{ disabled: true }}>
|
||||
<Button
|
||||
className="borderless-icon-btn"
|
||||
style={{
|
||||
backgroundColor: colors.transparent,
|
||||
boxShadow: 'none',
|
||||
}}
|
||||
icon={
|
||||
item.key === pinnedTab ? (
|
||||
<PushpinFilled
|
||||
size={20}
|
||||
style={{
|
||||
color: colors.skyBlue,
|
||||
rotate: '-45deg',
|
||||
transition: 'transform ease-in 300ms',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<PushpinOutlined
|
||||
size={20}
|
||||
style={{
|
||||
color: colors.skyBlue,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
pinToDefaultTab(item.key);
|
||||
}}
|
||||
/>
|
||||
</ConfigProvider>
|
||||
) : null}
|
||||
</Flex>
|
||||
),
|
||||
children: item.element,
|
||||
}));
|
||||
|
||||
const resetProjectData = () => {
|
||||
dispatch(setProjectId(null));
|
||||
dispatch(resetStatuses());
|
||||
dispatch(deselectAll());
|
||||
dispatch(resetTaskListData());
|
||||
dispatch(resetBoardData());
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
resetProjectData();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div style={{ marginBlockStart: 80, marginBlockEnd: 24, minHeight: '80vh' }}>
|
||||
<ProjectViewHeader />
|
||||
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={handleTabChange}
|
||||
items={tabMenuItems}
|
||||
tabBarStyle={{ paddingInline: 0 }}
|
||||
destroyInactiveTabPane={true}
|
||||
// tabBarExtraContent={
|
||||
// <div>
|
||||
// <span style={{ position: 'relative', top: '-10px' }}>
|
||||
// <Tooltip title="Members who are active on this project will be displayed here.">
|
||||
// <QuestionCircleOutlined />
|
||||
// </Tooltip>
|
||||
// </span>
|
||||
// <span
|
||||
// style={{
|
||||
// position: 'relative',
|
||||
// right: '20px',
|
||||
// top: '10px',
|
||||
// }}
|
||||
// >
|
||||
// <Badge status="success" dot className="profile-badge" />
|
||||
// </span>
|
||||
// </div>
|
||||
// }
|
||||
/>
|
||||
|
||||
{createPortal(<ProjectMemberDrawer />, document.body, 'project-member-drawer')}
|
||||
{createPortal(<PhaseDrawer />, document.body, 'phase-drawer')}
|
||||
{createPortal(<StatusDrawer />, document.body, 'status-drawer')}
|
||||
{createPortal(<TaskDrawer />, document.body, 'task-drawer')}
|
||||
{createPortal(<DeleteStatusDrawer />, document.body, 'delete-status-drawer')}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectView;
|
||||
@@ -0,0 +1,245 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Flex from 'antd/es/flex';
|
||||
import Badge from 'antd/es/badge';
|
||||
import Button from 'antd/es/button';
|
||||
import ConfigProvider from 'antd/es/config-provider';
|
||||
import Dropdown from 'antd/es/dropdown';
|
||||
import Input from 'antd/es/input';
|
||||
import Typography from 'antd/es/typography';
|
||||
import { MenuProps } from 'antd/es/menu';
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
DragEndEvent,
|
||||
DragStartEvent,
|
||||
} from '@dnd-kit/core';
|
||||
import { EditOutlined, EllipsisOutlined, RetweetOutlined, RightOutlined } from '@ant-design/icons';
|
||||
|
||||
import { colors } from '@/styles/colors';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { ITaskListGroup } from '@/types/tasks/taskList.types';
|
||||
import { useSocket } from '@/socket/socketContext';
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
import { fetchTaskAssignees, updateTaskAssignees } from '@/features/tasks/tasks.slice';
|
||||
import { ITaskAssigneesUpdateResponse } from '@/types/tasks/task-assignee-update-response';
|
||||
import { SocketEvents } from '@/shared/socket-events';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import TaskListTable from '../task-list-table/task-list-table';
|
||||
import Collapsible from '@/components/collapsible/collapsible';
|
||||
import TaskListBulkActionsBar from '@/components/taskListCommon/task-list-bulk-actions-bar/task-list-bulk-actions-bar';
|
||||
import TaskTemplateDrawer from '@/components/task-templates/task-template-drawer';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
interface TaskGroupListProps {
|
||||
taskGroups: ITaskListGroup[];
|
||||
groupBy: string;
|
||||
}
|
||||
|
||||
const TaskGroupList = ({ taskGroups, groupBy }: TaskGroupListProps) => {
|
||||
const [groups, setGroups] = useState(taskGroups);
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>({});
|
||||
const [renamingGroup, setRenamingGroup] = useState<string | null>(null);
|
||||
const [groupNames, setGroupNames] = useState<Record<string, string>>({});
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const { socket } = useSocket();
|
||||
const currentSession = useAuthService().getCurrentSession();
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
const { statusCategories } = useAppSelector(state => state.taskStatusReducer);
|
||||
const loadingAssignees = useAppSelector(state => state.taskReducer.loadingAssignees);
|
||||
const { t } = useTranslation('task-list-table');
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: { distance: 8 },
|
||||
})
|
||||
);
|
||||
|
||||
// Initialize expanded state and names for new groups
|
||||
useEffect(() => {
|
||||
const newExpandedState = { ...expandedGroups };
|
||||
const newNames = { ...groupNames };
|
||||
taskGroups.forEach(group => {
|
||||
if (!(group.id in newExpandedState)) {
|
||||
newExpandedState[group.id] = true;
|
||||
}
|
||||
if (!(group.id in newNames)) {
|
||||
newNames[group.id] = group.name;
|
||||
}
|
||||
});
|
||||
setExpandedGroups(newExpandedState);
|
||||
setGroupNames(newNames);
|
||||
setGroups(taskGroups);
|
||||
}, [taskGroups]);
|
||||
|
||||
// Socket listener for assignee updates
|
||||
useEffect(() => {
|
||||
if (!socket) return;
|
||||
|
||||
const handleAssigneesUpdate = (data: ITaskAssigneesUpdateResponse) => {
|
||||
logger.info('change assignees response:- ', data);
|
||||
if (data) {
|
||||
const updatedAssignees = data.assignees.map(assignee => ({
|
||||
...assignee,
|
||||
selected: true,
|
||||
}));
|
||||
|
||||
const groupId = groups.find(group =>
|
||||
group.tasks.some(task => task.id === data.id)
|
||||
)?.id;
|
||||
|
||||
if (groupId) {
|
||||
dispatch(
|
||||
updateTaskAssignees({
|
||||
groupId,
|
||||
taskId: data.id,
|
||||
assignees: updatedAssignees,
|
||||
})
|
||||
);
|
||||
|
||||
if (currentSession?.team_id && !loadingAssignees) {
|
||||
dispatch(fetchTaskAssignees(currentSession.team_id));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
socket.on(SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(), handleAssigneesUpdate);
|
||||
return () => {
|
||||
socket.off(SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(), handleAssigneesUpdate);
|
||||
};
|
||||
}, [socket, currentSession?.team_id, loadingAssignees, groups]);
|
||||
|
||||
const handleDragStart = (event: DragStartEvent) => {
|
||||
setActiveId(event.active.id as string);
|
||||
};
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
setActiveId(null);
|
||||
const { active, over } = event;
|
||||
if (!over) return;
|
||||
|
||||
const activeGroupId = active.data.current?.groupId;
|
||||
const overGroupId = over.data.current?.groupId;
|
||||
const activeTaskId = active.id;
|
||||
const overTaskId = over.id;
|
||||
|
||||
setGroups(prevGroups => {
|
||||
// ... existing drag end logic ...
|
||||
});
|
||||
};
|
||||
|
||||
const getDropdownItems = (groupId: string): MenuProps['items'] => ([
|
||||
{
|
||||
key: '1',
|
||||
icon: <EditOutlined />,
|
||||
label: 'Rename',
|
||||
onClick: () => setRenamingGroup(groupId),
|
||||
},
|
||||
{
|
||||
key: '2',
|
||||
icon: <RetweetOutlined />,
|
||||
label: 'Change category',
|
||||
children: statusCategories?.map(status => ({
|
||||
key: status.id,
|
||||
label: (
|
||||
<Flex gap={8}>
|
||||
<Badge color={status.color_code} />
|
||||
{status.name}
|
||||
</Flex>
|
||||
),
|
||||
type: 'group',
|
||||
})),
|
||||
},
|
||||
]);
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<ConfigProvider
|
||||
wave={{ disabled: true }}
|
||||
theme={{
|
||||
components: {
|
||||
Select: {
|
||||
colorBorder: colors.transparent,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Flex gap={24} vertical>
|
||||
{groups.map(group => (
|
||||
<div key={group.id}>
|
||||
<Flex vertical>
|
||||
<Flex style={{ transform: 'translateY(6px)' }}>
|
||||
<Button
|
||||
className="custom-collapse-button"
|
||||
style={{
|
||||
backgroundColor: themeMode === 'dark' ? group.color_code_dark : group.color_code,
|
||||
border: 'none',
|
||||
borderBottomLeftRadius: expandedGroups[group.id] ? 0 : 4,
|
||||
borderBottomRightRadius: expandedGroups[group.id] ? 0 : 4,
|
||||
color: colors.darkGray,
|
||||
}}
|
||||
icon={<RightOutlined rotate={expandedGroups[group.id] ? 90 : 0} />}
|
||||
onClick={() => setExpandedGroups(prev => ({ ...prev, [group.id]: !prev[group.id] }))}
|
||||
>
|
||||
{renamingGroup === group.id ? (
|
||||
<Input
|
||||
size="small"
|
||||
value={groupNames[group.id]}
|
||||
onChange={e => setGroupNames(prev => ({ ...prev, [group.id]: e.target.value }))}
|
||||
onBlur={() => setRenamingGroup(null)}
|
||||
onPressEnter={() => setRenamingGroup(null)}
|
||||
onClick={e => e.stopPropagation()}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<Typography.Text style={{ fontSize: 14, fontWeight: 600 }}>
|
||||
{t(groupNames[group.id])} ({group.tasks.length})
|
||||
</Typography.Text>
|
||||
)}
|
||||
</Button>
|
||||
{groupBy === 'status' && !renamingGroup && (
|
||||
<Dropdown menu={{ items: getDropdownItems(group.id) }}>
|
||||
<Button icon={<EllipsisOutlined />} className="borderless-icon-btn" />
|
||||
</Dropdown>
|
||||
)}
|
||||
</Flex>
|
||||
<Collapsible
|
||||
isOpen={expandedGroups[group.id]}
|
||||
className="border-l-[3px] relative after:content after:absolute after:h-full after:w-1 after:z-10 after:top-0 after:left-0 mt-1"
|
||||
color={themeMode === 'dark' ? group.color_code_dark : group.color_code}
|
||||
>
|
||||
<TaskListTable
|
||||
taskList={group.tasks}
|
||||
tableId={group.id}
|
||||
activeId={activeId}
|
||||
/>
|
||||
</Collapsible>
|
||||
</Flex>
|
||||
</div>
|
||||
))}
|
||||
</Flex>
|
||||
</ConfigProvider>
|
||||
|
||||
{createPortal(<TaskListBulkActionsBar />, document.body, 'bulk-action-container')}
|
||||
{createPortal(
|
||||
<TaskTemplateDrawer showDrawer={false} selectedTemplateId={''} onClose={() => {}} />,
|
||||
document.body,
|
||||
'task-template-drawer'
|
||||
)}
|
||||
</DndContext>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskGroupList;
|
||||
@@ -0,0 +1,44 @@
|
||||
import React from 'react';
|
||||
import { TaskPriorityType, TaskType } from '../../../../../../types/task.types';
|
||||
import { Flex } from 'antd';
|
||||
import TaskListTableWrapper from '../../task-list-table/task-list-table-wrapper/task-list-table-wrapper';
|
||||
import { useAppSelector } from '../../../../../../hooks/useAppSelector';
|
||||
import { getPriorityColor } from '../../../../../../utils/getPriorityColors';
|
||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||
|
||||
const PriorityGroupTables = ({ datasource }: { datasource: IProjectTask[] }) => {
|
||||
const priorityList: { id: string; name: string }[] = [
|
||||
{
|
||||
id: 'high',
|
||||
name: 'high',
|
||||
},
|
||||
{
|
||||
id: 'medium',
|
||||
name: 'medium',
|
||||
},
|
||||
{
|
||||
id: 'low',
|
||||
name: 'low',
|
||||
},
|
||||
];
|
||||
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
|
||||
return (
|
||||
<Flex gap={24} vertical>
|
||||
{priorityList.map((priority, index) => (
|
||||
<TaskListTableWrapper
|
||||
key={index}
|
||||
taskList={datasource.filter(task => task.priority === priority.name)}
|
||||
tableId={priority.id}
|
||||
name={priority.name}
|
||||
groupBy="priority"
|
||||
priorityCategory={priority.name}
|
||||
color={getPriorityColor(priority.name as TaskPriorityType, themeMode)}
|
||||
/>
|
||||
))}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default PriorityGroupTables;
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useEffect } from 'react';
|
||||
import Flex from 'antd/es/flex';
|
||||
import Skeleton from 'antd/es/skeleton';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
|
||||
import TaskListFilters from './task-list-filters/task-list-filters';
|
||||
import TaskGroupWrapper from './task-list-table/task-group-wrapper/task-group-wrapper';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { fetchTaskGroups, fetchTaskListColumns } from '@/features/tasks/tasks.slice';
|
||||
import { fetchStatusesCategories } from '@/features/taskAttributes/taskStatusSlice';
|
||||
import { fetchPhasesByProjectId } from '@/features/projects/singleProject/phase/phases.slice';
|
||||
import { Empty } from 'antd';
|
||||
import useTabSearchParam from '@/hooks/useTabSearchParam';
|
||||
|
||||
const ProjectViewTaskList = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const { projectView } = useTabSearchParam();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const { projectId } = useAppSelector(state => state.projectReducer);
|
||||
const { taskGroups, loadingGroups, groupBy, archived, fields, search } = useAppSelector(
|
||||
state => state.taskReducer
|
||||
);
|
||||
const { statusCategories, loading: loadingStatusCategories } = useAppSelector(
|
||||
state => state.taskStatusReducer
|
||||
);
|
||||
const { loadingPhases } = useAppSelector(state => state.phaseReducer);
|
||||
const { loadingColumns } = useAppSelector(state => state.taskReducer);
|
||||
|
||||
useEffect(() => {
|
||||
// Set default view to list if projectView is not list or board
|
||||
if (projectView !== 'list' && projectView !== 'board') {
|
||||
searchParams.set('tab', 'tasks-list');
|
||||
searchParams.set('pinned_tab', 'tasks-list');
|
||||
setSearchParams(searchParams);
|
||||
}
|
||||
}, [projectView, searchParams, setSearchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
if (projectId && groupBy) {
|
||||
if (!loadingColumns) dispatch(fetchTaskListColumns(projectId));
|
||||
if (!loadingPhases) dispatch(fetchPhasesByProjectId(projectId));
|
||||
if (!loadingGroups && projectView === 'list') {
|
||||
dispatch(fetchTaskGroups(projectId));
|
||||
}
|
||||
}
|
||||
if (!statusCategories.length) {
|
||||
dispatch(fetchStatusesCategories());
|
||||
}
|
||||
}, [dispatch, projectId, groupBy, fields, search, archived]);
|
||||
|
||||
return (
|
||||
<Flex vertical gap={16} style={{ overflowX: 'hidden' }}>
|
||||
<TaskListFilters position="list" />
|
||||
|
||||
{(taskGroups.length === 0 && !loadingGroups) ? (
|
||||
<Empty description="No tasks group found" />
|
||||
) : (
|
||||
<Skeleton active loading={loadingGroups} className='mt-4 p-4'>
|
||||
<TaskGroupWrapper taskGroups={taskGroups} groupBy={groupBy} />
|
||||
</Skeleton>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectViewTaskList;
|
||||
@@ -0,0 +1,85 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import Flex from 'antd/es/flex';
|
||||
import Checkbox from 'antd/es/checkbox';
|
||||
import Typography from 'antd/es/typography';
|
||||
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { fetchPriorities } from '@/features/taskAttributes/taskPrioritySlice';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import {
|
||||
fetchLabelsByProject,
|
||||
fetchTaskAssignees,
|
||||
toggleArchived,
|
||||
} from '@/features/tasks/tasks.slice';
|
||||
import { getTeamMembers } from '@/features/team-members/team-members.slice';
|
||||
import useTabSearchParam from '@/hooks/useTabSearchParam';
|
||||
|
||||
const SearchDropdown = React.lazy(() => import('@components/project-task-filters/filter-dropdowns/search-dropdown'));
|
||||
const SortFilterDropdown = React.lazy(() => import('@components/project-task-filters/filter-dropdowns/sort-filter-dropdown'));
|
||||
const LabelsFilterDropdown = React.lazy(() => import('@components/project-task-filters/filter-dropdowns/labels-filter-dropdown'));
|
||||
const MembersFilterDropdown = React.lazy(() => import('@components/project-task-filters/filter-dropdowns/members-filter-dropdown'));
|
||||
const GroupByFilterDropdown = React.lazy(() => import('@components/project-task-filters/filter-dropdowns/group-by-filter-dropdown'));
|
||||
const ShowFieldsFilterDropdown = React.lazy(() => import('@components/project-task-filters/filter-dropdowns/show-fields-filter-dropdown'));
|
||||
const PriorityFilterDropdown = React.lazy(() => import('@components/project-task-filters/filter-dropdowns/priority-filter-dropdown'));
|
||||
|
||||
interface TaskListFiltersProps {
|
||||
position: 'board' | 'list';
|
||||
}
|
||||
|
||||
const TaskListFilters: React.FC<TaskListFiltersProps> = ({ position }) => {
|
||||
const { t } = useTranslation('task-list-filters');
|
||||
const dispatch = useAppDispatch();
|
||||
const { projectView } = useTabSearchParam();
|
||||
|
||||
const priorities = useAppSelector(state => state.priorityReducer.priorities);
|
||||
|
||||
const projectId = useAppSelector(state => state.projectReducer.projectId);
|
||||
const archived = useAppSelector(state => state.taskReducer.archived);
|
||||
|
||||
const handleShowArchivedChange = () => dispatch(toggleArchived());
|
||||
|
||||
useEffect(() => {
|
||||
const fetchInitialData = async () => {
|
||||
if (!priorities.length) await dispatch(fetchPriorities());
|
||||
if (projectId) {
|
||||
await dispatch(fetchLabelsByProject(projectId));
|
||||
await dispatch(fetchTaskAssignees(projectId));
|
||||
}
|
||||
dispatch(getTeamMembers({ index: 0, size: 100, field: null, order: null, search: null, all: true }));
|
||||
};
|
||||
|
||||
fetchInitialData();
|
||||
}, [dispatch, priorities.length, projectId]);
|
||||
|
||||
return (
|
||||
<Flex gap={8} align="center" justify="space-between">
|
||||
<Flex gap={8} wrap={'wrap'}>
|
||||
<SearchDropdown />
|
||||
{projectView === 'list' && <SortFilterDropdown />}
|
||||
<PriorityFilterDropdown priorities={priorities} />
|
||||
<LabelsFilterDropdown />
|
||||
<MembersFilterDropdown />
|
||||
<GroupByFilterDropdown />
|
||||
</Flex>
|
||||
|
||||
{position === 'list' && (
|
||||
<Flex gap={12} wrap={'wrap'}>
|
||||
<Flex
|
||||
gap={4}
|
||||
align="center"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={handleShowArchivedChange}
|
||||
>
|
||||
<Checkbox checked={archived} />
|
||||
<Typography.Text>{t('showArchivedText')}</Typography.Text>
|
||||
</Flex>
|
||||
{/* show fields dropdown */}
|
||||
<ShowFieldsFilterDropdown />
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskListFilters;
|
||||
@@ -0,0 +1,321 @@
|
||||
import {
|
||||
DeleteOutlined,
|
||||
DoubleRightOutlined,
|
||||
InboxOutlined,
|
||||
LoadingOutlined,
|
||||
RetweetOutlined,
|
||||
UserAddOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Badge, Dropdown, Flex, Typography, Modal } from 'antd';
|
||||
import { MenuProps } from 'antd/lib';
|
||||
import { useState } from 'react';
|
||||
import { TFunction } from 'i18next';
|
||||
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
|
||||
import { taskListBulkActionsApiService } from '@/api/tasks/task-list-bulk-actions.api.service';
|
||||
import { IBulkAssignRequest } from '@/types/tasks/bulk-action-bar.types';
|
||||
import {
|
||||
evt_project_task_list_context_menu_archive,
|
||||
evt_project_task_list_context_menu_assign_me,
|
||||
evt_project_task_list_context_menu_delete,
|
||||
} from '@/shared/worklenz-analytics-events';
|
||||
import {
|
||||
deleteTask,
|
||||
fetchTaskAssignees,
|
||||
fetchTaskGroups,
|
||||
IGroupBy,
|
||||
setConvertToSubtaskDrawerOpen,
|
||||
updateTaskAssignees,
|
||||
} from '@/features/tasks/tasks.slice';
|
||||
import { deselectAll } from '@/features/projects/bulkActions/bulkActionSlice';
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
import { useSocket } from '@/socket/socketContext';
|
||||
import { SocketEvents } from '@/shared/socket-events';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||
import { tasksApiService } from '@/api/tasks/tasks.api.service';
|
||||
|
||||
type TaskContextMenuProps = {
|
||||
visible: boolean;
|
||||
position: { x: number; y: number };
|
||||
selectedTask: IProjectTask;
|
||||
onClose: () => void;
|
||||
t: TFunction;
|
||||
};
|
||||
|
||||
const TaskContextMenu = ({ visible, position, selectedTask, onClose, t }: TaskContextMenuProps) => {
|
||||
const statusList = useAppSelector(state => state.taskStatusReducer.status);
|
||||
const priorityList = useAppSelector(state => state.priorityReducer.priorities);
|
||||
const phaseList = useAppSelector(state => state.phaseReducer.phaseList);
|
||||
const { socket } = useSocket();
|
||||
const dispatch = useAppDispatch();
|
||||
const { trackMixpanelEvent } = useMixpanelTracking();
|
||||
const currentSession = useAuthService().getCurrentSession();
|
||||
|
||||
const { projectId } = useAppSelector(state => state.projectReducer);
|
||||
const { taskGroups, archived, groupBy } = useAppSelector(state => state.taskReducer);
|
||||
const [updatingAssignToMe, setUpdatingAssignToMe] = useState(false);
|
||||
|
||||
const handleAssignToMe = async () => {
|
||||
if (!projectId || !selectedTask.id) return;
|
||||
|
||||
try {
|
||||
setUpdatingAssignToMe(true);
|
||||
const body: IBulkAssignRequest = {
|
||||
tasks: [selectedTask.id],
|
||||
project_id: projectId,
|
||||
};
|
||||
const res = await taskListBulkActionsApiService.assignToMe(body);
|
||||
if (res.done) {
|
||||
const { id: taskId, assignees } = res.body;
|
||||
trackMixpanelEvent(evt_project_task_list_context_menu_assign_me);
|
||||
const groupId = taskGroups.find(group =>
|
||||
group.tasks.some(task =>
|
||||
task.id === taskId ||
|
||||
task.sub_tasks?.some(subtask => subtask.id === taskId)
|
||||
)
|
||||
)?.id;
|
||||
|
||||
if (groupId) {
|
||||
dispatch(
|
||||
updateTaskAssignees({
|
||||
groupId,
|
||||
taskId,
|
||||
assignees,
|
||||
})
|
||||
);
|
||||
|
||||
if (currentSession?.team_id) {
|
||||
dispatch(fetchTaskAssignees(currentSession.team_id));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
setUpdatingAssignToMe(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleArchive = async () => {
|
||||
if (!projectId || !selectedTask.id) return;
|
||||
|
||||
try {
|
||||
const res = await taskListBulkActionsApiService.archiveTasks(
|
||||
{
|
||||
tasks: [selectedTask.id],
|
||||
project_id: projectId,
|
||||
},
|
||||
archived ? true : false
|
||||
);
|
||||
|
||||
if (res.done) {
|
||||
trackMixpanelEvent(evt_project_task_list_context_menu_archive);
|
||||
dispatch(deleteTask({ taskId: selectedTask.id }));
|
||||
dispatch(deselectAll());
|
||||
if (selectedTask.parent_task_id) socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), selectedTask.parent_task_id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!projectId || !selectedTask.id) return;
|
||||
|
||||
try {
|
||||
const res = await taskListBulkActionsApiService.deleteTasks(
|
||||
{ tasks: [selectedTask.id] },
|
||||
projectId
|
||||
);
|
||||
|
||||
if (res.done) {
|
||||
trackMixpanelEvent(evt_project_task_list_context_menu_delete);
|
||||
dispatch(deleteTask({ taskId: selectedTask.id }));
|
||||
dispatch(deselectAll());
|
||||
if (selectedTask.parent_task_id) socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), selectedTask.parent_task_id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStatusMoveTo = async (targetId: string | undefined) => {
|
||||
if (!projectId || !selectedTask.id || !targetId) return;
|
||||
|
||||
try {
|
||||
socket?.emit(
|
||||
SocketEvents.TASK_STATUS_CHANGE.toString(),
|
||||
JSON.stringify({
|
||||
task_id: selectedTask.id,
|
||||
status_id: targetId,
|
||||
parent_task: selectedTask.parent_task_id || null,
|
||||
team_id: currentSession?.team_id,
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Error moving status', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePriorityMoveTo = async (targetId: string | undefined) => {
|
||||
if (!projectId || !selectedTask.id || !targetId) return;
|
||||
|
||||
try {
|
||||
socket?.emit(
|
||||
SocketEvents.TASK_PRIORITY_CHANGE.toString(),
|
||||
JSON.stringify({
|
||||
task_id: selectedTask.id,
|
||||
priority_id: targetId,
|
||||
parent_task: selectedTask.parent_task_id || null,
|
||||
team_id: currentSession?.team_id,
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Error moving priority', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePhaseMoveTo = async (targetId: string | undefined) => {
|
||||
if (!projectId || !selectedTask.id || !targetId) return;
|
||||
|
||||
try {
|
||||
socket?.emit(SocketEvents.TASK_PHASE_CHANGE.toString(), {
|
||||
task_id: selectedTask.id,
|
||||
phase_id: targetId,
|
||||
parent_task: selectedTask.parent_task_id || null,
|
||||
team_id: currentSession?.team_id,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error moving phase', error);
|
||||
}
|
||||
};
|
||||
|
||||
const getMoveToOptions = () => {
|
||||
if (groupBy === IGroupBy.STATUS) {
|
||||
return statusList?.map(status => ({
|
||||
key: status.id,
|
||||
label: (
|
||||
<Flex align="center" gap={8}>
|
||||
<Badge color={status.color_code} />
|
||||
<Typography.Text>{status.name}</Typography.Text>
|
||||
</Flex>
|
||||
),
|
||||
onClick: () => handleStatusMoveTo(status.id),
|
||||
}));
|
||||
}
|
||||
if (groupBy === IGroupBy.PRIORITY) {
|
||||
return priorityList?.map(priority => ({
|
||||
key: priority.id,
|
||||
label: (
|
||||
<Flex align="center" gap={8}>
|
||||
<Badge color={priority.color_code} />
|
||||
<Typography.Text>{priority.name}</Typography.Text>
|
||||
</Flex>
|
||||
),
|
||||
onClick: () => handlePriorityMoveTo(priority.id),
|
||||
}));
|
||||
}
|
||||
if (groupBy === IGroupBy.PHASE) {
|
||||
return phaseList?.map(phase => ({
|
||||
key: phase.id,
|
||||
label: (
|
||||
<Flex align="center" gap={8}>
|
||||
<Badge color={phase.color_code} />
|
||||
<Typography.Text>{phase.name}</Typography.Text>
|
||||
</Flex>
|
||||
),
|
||||
onClick: () => handlePhaseMoveTo(phase.id),
|
||||
}));
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const handleConvertToTask = async () => {
|
||||
if (!selectedTask?.id || !projectId) return;
|
||||
|
||||
try {
|
||||
const res = await tasksApiService.convertToTask(
|
||||
selectedTask.id as string,
|
||||
projectId as string
|
||||
);
|
||||
if (res.done) {
|
||||
dispatch(deselectAll());
|
||||
dispatch(fetchTaskGroups(projectId));
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error converting to task', error);
|
||||
}
|
||||
};
|
||||
|
||||
const items: MenuProps['items'] = [
|
||||
{
|
||||
key: '1',
|
||||
icon: updatingAssignToMe ? <LoadingOutlined /> : <UserAddOutlined />,
|
||||
label: t('contextMenu.assignToMe'),
|
||||
onClick: handleAssignToMe,
|
||||
disabled: updatingAssignToMe,
|
||||
},
|
||||
{
|
||||
key: '2',
|
||||
icon: <RetweetOutlined />,
|
||||
label: t('contextMenu.moveTo'),
|
||||
children: getMoveToOptions(),
|
||||
},
|
||||
...(!selectedTask?.parent_task_id ? [
|
||||
{
|
||||
key: '3',
|
||||
icon: <InboxOutlined />,
|
||||
label: archived ? t('contextMenu.unarchive' ) : t('contextMenu.archive'),
|
||||
onClick: handleArchive,
|
||||
},
|
||||
] : []),
|
||||
...(selectedTask?.sub_tasks_count === 0 && !selectedTask?.parent_task_id
|
||||
? [
|
||||
{
|
||||
key: '4',
|
||||
icon: <DoubleRightOutlined />,
|
||||
label: t('contextMenu.convertToSubTask'),
|
||||
onClick: () => dispatch(setConvertToSubtaskDrawerOpen(true)),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(selectedTask?.parent_task_id
|
||||
? [
|
||||
{
|
||||
key: '5',
|
||||
icon: <DoubleRightOutlined />,
|
||||
label: t('contextMenu.convertToTask'),
|
||||
onClick: () => {
|
||||
handleConvertToTask();
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
key: '6',
|
||||
icon: <DeleteOutlined />,
|
||||
label: t('contextMenu.delete'),
|
||||
onClick: handleDelete,
|
||||
},
|
||||
];
|
||||
|
||||
return visible ? (
|
||||
<Dropdown menu={{ items }} trigger={['contextMenu']} open={visible} onOpenChange={onClose}>
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: position.y,
|
||||
left: position.x,
|
||||
zIndex: 1000,
|
||||
width: 1,
|
||||
height: 1,
|
||||
}}
|
||||
></div>
|
||||
</Dropdown>
|
||||
) : null;
|
||||
};
|
||||
|
||||
export default TaskContextMenu;
|
||||
@@ -0,0 +1,19 @@
|
||||
.custom-column-label-dropdown .ant-dropdown-menu {
|
||||
padding: 0 !important;
|
||||
margin-top: 8px !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.custom-column-label-dropdown .ant-dropdown-menu-item {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.custom-column-label-dropdown-card .ant-card-body {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.custom-column-label-menu .ant-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 32px;
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { Badge, Card, Dropdown, Empty, Flex, Menu, MenuProps, Typography } from 'antd';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { DownOutlined } from '@ant-design/icons';
|
||||
// custom css file
|
||||
import './custom-column-label-cell.css';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { colors } from '../../../../../../../../styles/colors';
|
||||
import { ITaskLabel } from '@/types/tasks/taskLabel.types';
|
||||
|
||||
const CustomColumnLabelCell = ({
|
||||
labelsList,
|
||||
selectedLabels = [],
|
||||
onChange
|
||||
}: {
|
||||
labelsList: ITaskLabel[],
|
||||
selectedLabels?: string[],
|
||||
onChange?: (labels: string[]) => void
|
||||
}) => {
|
||||
const [currentLabelOption, setCurrentLabelOption] = useState<ITaskLabel | null>(null);
|
||||
|
||||
// localization
|
||||
const { t } = useTranslation('task-list-table');
|
||||
|
||||
// Set initial selection based on selectedLabels prop
|
||||
useEffect(() => {
|
||||
if (selectedLabels && selectedLabels.length > 0 && labelsList.length > 0) {
|
||||
const selectedLabel = labelsList.find(label => label.id && selectedLabels.includes(label.id));
|
||||
if (selectedLabel) {
|
||||
setCurrentLabelOption(selectedLabel);
|
||||
}
|
||||
}
|
||||
}, [selectedLabels, labelsList]);
|
||||
|
||||
// ensure labelsList is an array and has valid data
|
||||
const labelMenuItems: MenuProps['items'] =
|
||||
Array.isArray(labelsList) && labelsList.length > 0
|
||||
? labelsList
|
||||
.filter(label => label.id) // Filter out items without an id
|
||||
.map(label => ({
|
||||
key: label.id as string, // Assert that id is a string
|
||||
label: (
|
||||
<Flex gap={4}>
|
||||
<Badge color={label.color_code} /> {label.name}
|
||||
</Flex>
|
||||
),
|
||||
type: 'item' as const,
|
||||
}))
|
||||
: [
|
||||
{
|
||||
key: 'noLabels',
|
||||
label: <Empty />,
|
||||
},
|
||||
];
|
||||
|
||||
// handle label selection
|
||||
const handleLabelOptionSelect: MenuProps['onClick'] = e => {
|
||||
const selectedOption = labelsList.find(option => option.id === e.key);
|
||||
if (selectedOption && selectedOption.id) {
|
||||
setCurrentLabelOption(selectedOption);
|
||||
// Call the onChange callback if provided
|
||||
if (onChange) {
|
||||
onChange([selectedOption.id]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// dropdown items
|
||||
const customColumnLabelCellItems: MenuProps['items'] = [
|
||||
{
|
||||
key: '1',
|
||||
label: (
|
||||
<Card className="custom-column-label-dropdown-card" bordered={false}>
|
||||
<Menu
|
||||
className="custom-column-label-menu"
|
||||
items={labelMenuItems}
|
||||
onClick={handleLabelOptionSelect}
|
||||
/>
|
||||
</Card>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
overlayClassName="custom-column-label-dropdown"
|
||||
menu={{ items: customColumnLabelCellItems }}
|
||||
placement="bottomRight"
|
||||
trigger={['click']}
|
||||
>
|
||||
<Flex
|
||||
gap={6}
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style={{
|
||||
width: 'fit-content',
|
||||
borderRadius: 24,
|
||||
paddingInline: 8,
|
||||
height: 22,
|
||||
fontSize: 13,
|
||||
backgroundColor: currentLabelOption?.color_code || colors.transparent,
|
||||
color: colors.darkGray,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{currentLabelOption ? (
|
||||
<Typography.Text
|
||||
ellipsis={{ expanded: false }}
|
||||
style={{
|
||||
textTransform: 'capitalize',
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{currentLabelOption?.name}
|
||||
</Typography.Text>
|
||||
) : (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
|
||||
{t('selectText')}
|
||||
</Typography.Text>
|
||||
)}
|
||||
|
||||
<DownOutlined style={{ fontSize: 12 }} />
|
||||
</Flex>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomColumnLabelCell;
|
||||
@@ -0,0 +1,19 @@
|
||||
.custom-column-selection-dropdown .ant-dropdown-menu {
|
||||
padding: 0 !important;
|
||||
margin-top: 8px !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.custom-column-selection-dropdown .ant-dropdown-menu-item {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.custom-column-selection-dropdown-card .ant-card-body {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.custom-column-selection-menu .ant-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 32px;
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { Badge, Card, Dropdown, Empty, Flex, Menu, MenuProps, Typography } from 'antd';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { DownOutlined } from '@ant-design/icons';
|
||||
// custom css file
|
||||
import './custom-column-selection-cell.css';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { colors } from '../../../../../../../../styles/colors';
|
||||
import { SelectionType } from '../../custom-column-modal/selection-type-column/selection-type-column';
|
||||
import { ALPHA_CHANNEL } from '@/shared/constants';
|
||||
|
||||
const CustomColumnSelectionCell = ({
|
||||
selectionsList,
|
||||
value,
|
||||
onChange
|
||||
}: {
|
||||
selectionsList: SelectionType[],
|
||||
value?: string,
|
||||
onChange?: (value: string) => void
|
||||
}) => {
|
||||
const [currentSelectionOption, setCurrentSelectionOption] = useState<SelectionType | null>(null);
|
||||
|
||||
// localization
|
||||
const { t } = useTranslation('task-list-table');
|
||||
|
||||
// Debug the selectionsList and value
|
||||
console.log('CustomColumnSelectionCell props:', {
|
||||
selectionsList,
|
||||
value,
|
||||
selectionsCount: selectionsList?.length || 0
|
||||
});
|
||||
|
||||
// Set initial selection based on value prop
|
||||
useEffect(() => {
|
||||
if (value && Array.isArray(selectionsList) && selectionsList.length > 0) {
|
||||
const selectedOption = selectionsList.find(option => option.selection_id === value);
|
||||
console.log('Found selected option:', selectedOption);
|
||||
if (selectedOption) {
|
||||
setCurrentSelectionOption(selectedOption);
|
||||
}
|
||||
}
|
||||
}, [value, selectionsList]);
|
||||
|
||||
// ensure selectionsList is an array and has valid data
|
||||
const selectionMenuItems: MenuProps['items'] =
|
||||
Array.isArray(selectionsList) && selectionsList.length > 0
|
||||
? selectionsList.map(selection => ({
|
||||
key: selection.selection_id,
|
||||
label: (
|
||||
<Flex gap={4}>
|
||||
<Badge color={selection.selection_color + ALPHA_CHANNEL} /> {selection.selection_name}
|
||||
</Flex>
|
||||
),
|
||||
}))
|
||||
: [
|
||||
{
|
||||
key: 'noSelections',
|
||||
label: <Empty description="No selections available" />,
|
||||
},
|
||||
];
|
||||
|
||||
// handle selection selection
|
||||
const handleSelectionOptionSelect: MenuProps['onClick'] = e => {
|
||||
if (e.key === 'noSelections') return;
|
||||
|
||||
const selectedOption = selectionsList.find(option => option.selection_id === e.key);
|
||||
if (selectedOption) {
|
||||
setCurrentSelectionOption(selectedOption);
|
||||
// Call the onChange callback if provided
|
||||
if (onChange) {
|
||||
onChange(selectedOption.selection_id);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// dropdown items
|
||||
const customColumnSelectionCellItems: MenuProps['items'] = [
|
||||
{
|
||||
key: '1',
|
||||
label: (
|
||||
<Card className="custom-column-selection-dropdown-card" variant="borderless">
|
||||
<Menu
|
||||
className="custom-column-selection-menu"
|
||||
items={selectionMenuItems}
|
||||
onClick={handleSelectionOptionSelect}
|
||||
/>
|
||||
</Card>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
overlayClassName="custom-column-selection-dropdown"
|
||||
menu={{ items: customColumnSelectionCellItems }}
|
||||
placement="bottomRight"
|
||||
trigger={['click']}
|
||||
>
|
||||
<Flex
|
||||
gap={6}
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style={{
|
||||
width: 'fit-content',
|
||||
borderRadius: 24,
|
||||
paddingInline: 8,
|
||||
height: 22,
|
||||
fontSize: 13,
|
||||
backgroundColor: currentSelectionOption?.selection_color + ALPHA_CHANNEL || colors.transparent,
|
||||
color: colors.darkGray,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{currentSelectionOption ? (
|
||||
<Typography.Text
|
||||
ellipsis={{ expanded: false }}
|
||||
style={{
|
||||
textTransform: 'capitalize',
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{currentSelectionOption?.selection_name}
|
||||
</Typography.Text>
|
||||
) : (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
|
||||
{t('selectText')}
|
||||
</Typography.Text>
|
||||
)}
|
||||
|
||||
<DownOutlined style={{ fontSize: 12 }} />
|
||||
</Flex>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomColumnSelectionCell;
|
||||
@@ -0,0 +1,54 @@
|
||||
import { SettingOutlined } from '@ant-design/icons';
|
||||
import { Button, Flex, Tooltip, Typography } from 'antd';
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import CustomColumnModal from '../custom-column-modal/custom-column-modal';
|
||||
|
||||
type CustomColumnHeaderProps = {
|
||||
columnKey: string;
|
||||
columnName: string;
|
||||
};
|
||||
|
||||
const CustomColumnHeader = ({ columnKey, columnName }: CustomColumnHeaderProps) => {
|
||||
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
|
||||
// localization
|
||||
const { t } = useTranslation('task-list-table');
|
||||
|
||||
// function to open modal
|
||||
const handleModalOpen = () => {
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
// fuction to handle cancel
|
||||
const handleCancel = () => {
|
||||
setIsModalOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex gap={8} align="center" justify="space-between">
|
||||
<Typography.Text ellipsis={{ expanded: false }}>{columnName}</Typography.Text>
|
||||
|
||||
<Tooltip title={t('editTooltip')}>
|
||||
<Button
|
||||
icon={<SettingOutlined />}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
boxShadow: 'none',
|
||||
fontSize: 12,
|
||||
}}
|
||||
onClick={handleModalOpen}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
{/* <CustomColumnModal
|
||||
modalType="edit"
|
||||
isModalOpen={isModalOpen}
|
||||
handleCancel={handleCancel}
|
||||
columnId={columnKey}
|
||||
/> */}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomColumnHeader;
|
||||
@@ -0,0 +1,34 @@
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import {
|
||||
setCustomColumnModalAttributes,
|
||||
toggleCustomColumnModalOpen,
|
||||
} from '@/features/projects/singleProject/task-list-custom-columns/task-list-custom-columns-slice';
|
||||
|
||||
const AddCustomColumnButton = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const handleModalOpen = () => {
|
||||
dispatch(setCustomColumnModalAttributes({ modalType: 'create', columnId: null }));
|
||||
dispatch(toggleCustomColumnModalOpen(true));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip title={'Add a custom column'}>
|
||||
<Button
|
||||
icon={<PlusOutlined />}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
boxShadow: 'none',
|
||||
}}
|
||||
onClick={handleModalOpen}
|
||||
/>
|
||||
</Tooltip>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddCustomColumnButton;
|
||||
@@ -0,0 +1,497 @@
|
||||
import { Button, Divider, Flex, Form, Input, message, Modal, Select, Typography, Popconfirm } from 'antd';
|
||||
import SelectionTypeColumn from './selection-type-column/selection-type-column';
|
||||
import NumberTypeColumn from './number-type-column/number-type-column';
|
||||
import LabelTypeColumn from './label-type-column/label-type-column';
|
||||
import FormulaTypeColumn from './formula-type-column/formula-type-column';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import {
|
||||
CustomFieldNumberTypes,
|
||||
CustomFieldsTypes,
|
||||
setCustomColumnModalAttributes,
|
||||
setCustomFieldType,
|
||||
toggleCustomColumnModalOpen,
|
||||
setCustomFieldNumberType,
|
||||
setDecimals,
|
||||
setLabel,
|
||||
setLabelPosition,
|
||||
setExpression,
|
||||
setFirstNumericColumn,
|
||||
setSecondNumericColumn,
|
||||
setSelectionsList,
|
||||
setLabelsList,
|
||||
} from '@features/projects/singleProject/task-list-custom-columns/task-list-custom-columns-slice';
|
||||
import CustomColumnHeader from '../custom-column-header/custom-column-header';
|
||||
import { nanoid } from '@reduxjs/toolkit';
|
||||
import {
|
||||
CustomTableColumnsType,
|
||||
deleteCustomColumn as deleteCustomColumnFromColumns,
|
||||
} from '@features/projects/singleProject/taskListColumns/taskColumnsSlice';
|
||||
import { themeWiseColor } from '@/utils/themeWiseColor';
|
||||
import KeyTypeColumn from './key-type-column/key-type-column';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { addCustomColumn, deleteCustomColumn as deleteCustomColumnFromTasks } from '@/features/tasks/tasks.slice';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { tasksCustomColumnsService } from '@/api/tasks/tasks-custom-columns.service';
|
||||
import { ExclamationCircleFilled } from '@ant-design/icons';
|
||||
|
||||
const CustomColumnModal = () => {
|
||||
const [mainForm] = Form.useForm();
|
||||
const { projectId } = useParams();
|
||||
|
||||
// get theme details from theme reducer
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const {
|
||||
customColumnId,
|
||||
customColumnModalType,
|
||||
isCustomColumnModalOpen,
|
||||
decimals,
|
||||
label,
|
||||
labelPosition,
|
||||
previewValue,
|
||||
expression,
|
||||
firstNumericColumn,
|
||||
secondNumericColumn,
|
||||
labelsList,
|
||||
selectionsList,
|
||||
customFieldType,
|
||||
} = useAppSelector(state => state.taskListCustomColumnsReducer);
|
||||
// get initial data from task list custom column slice
|
||||
const fieldType: CustomFieldsTypes = useAppSelector(
|
||||
state => state.taskListCustomColumnsReducer.customFieldType
|
||||
);
|
||||
// number column initial data
|
||||
const numberType: CustomFieldNumberTypes = useAppSelector(
|
||||
state => state.taskListCustomColumnsReducer.customFieldNumberType
|
||||
);
|
||||
|
||||
// if it is already created column get the column data
|
||||
const openedColumn = useAppSelector(state => state.taskReducer.customColumns).find(
|
||||
col => col.id === customColumnId
|
||||
);
|
||||
|
||||
// Function to handle deleting a custom column
|
||||
const handleDeleteColumn = async () => {
|
||||
if (!customColumnId) return;
|
||||
|
||||
try {
|
||||
// Make API request to delete the custom column using the service
|
||||
await tasksCustomColumnsService.deleteCustomColumn(openedColumn?.id || customColumnId);
|
||||
|
||||
// Dispatch actions to update the Redux store
|
||||
dispatch(deleteCustomColumnFromTasks(customColumnId));
|
||||
dispatch(deleteCustomColumnFromColumns(customColumnId));
|
||||
|
||||
// Close the modal
|
||||
dispatch(toggleCustomColumnModalOpen(false));
|
||||
dispatch(setCustomColumnModalAttributes({ modalType: 'create', columnId: null }));
|
||||
|
||||
// Show success message
|
||||
message.success('Custom column deleted successfully');
|
||||
|
||||
// Reload the page to reflect the changes
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
logger.error('Error deleting custom column:', error);
|
||||
message.error('Failed to delete custom column');
|
||||
}
|
||||
};
|
||||
|
||||
const fieldTypesOptions = [
|
||||
{
|
||||
key: 'people',
|
||||
value: 'people',
|
||||
label: 'People',
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
key: 'number',
|
||||
value: 'number',
|
||||
label: 'Number',
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
key: 'date',
|
||||
value: 'date',
|
||||
label: 'Date',
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
key: 'selection',
|
||||
value: 'selection',
|
||||
label: 'Selection',
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
key: 'checkbox',
|
||||
value: 'checkbox',
|
||||
label: 'Checkbox',
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
key: 'labels',
|
||||
value: 'labels',
|
||||
label: 'Labels',
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
key: 'key',
|
||||
value: 'key',
|
||||
label: 'Key',
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
key: 'formula',
|
||||
value: 'formula',
|
||||
label: 'Formula',
|
||||
disabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
// function to handle form submit
|
||||
const handleFormSubmit = async (value: any) => {
|
||||
try {
|
||||
if (customColumnModalType === 'create') {
|
||||
const columnKey = nanoid(); // this id is random and unique, generated by redux
|
||||
|
||||
const newColumn: CustomTableColumnsType = {
|
||||
key: columnKey,
|
||||
name: value.fieldTitle,
|
||||
columnHeader: <CustomColumnHeader columnKey={columnKey} columnName={value.fieldTitle} />,
|
||||
width: 120,
|
||||
isVisible: true,
|
||||
custom_column: true,
|
||||
custom_column_obj: {
|
||||
...value,
|
||||
labelsList: value.fieldType === 'labels' ? labelsList : [],
|
||||
selectionsList: value.fieldType === 'selection' ? selectionsList : [],
|
||||
},
|
||||
};
|
||||
|
||||
// Prepare the configuration object
|
||||
const configuration = {
|
||||
field_title: value.fieldTitle,
|
||||
field_type: value.fieldType,
|
||||
number_type: value.numberType,
|
||||
decimals: value.decimals,
|
||||
label: value.label,
|
||||
label_position: value.labelPosition,
|
||||
preview_value: value.previewValue,
|
||||
expression: value.expression,
|
||||
first_numeric_column_key: value.firstNumericColumn?.key,
|
||||
second_numeric_column_key: value.secondNumericColumn?.key,
|
||||
selections_list:
|
||||
value.fieldType === 'selection'
|
||||
? selectionsList.map((selection, index) => ({
|
||||
selection_id: selection.selection_id,
|
||||
selection_name: selection.selection_name,
|
||||
selection_color: selection.selection_color,
|
||||
selection_order: index,
|
||||
}))
|
||||
: [],
|
||||
labels_list:
|
||||
value.fieldType === 'labels'
|
||||
? labelsList.map((label, index) => ({
|
||||
label_id: label.label_id,
|
||||
label_name: label.label_name,
|
||||
label_color: label.label_color,
|
||||
label_order: index,
|
||||
}))
|
||||
: [],
|
||||
};
|
||||
|
||||
// Make API request to create custom column using the service
|
||||
try {
|
||||
const res = await tasksCustomColumnsService.createCustomColumn(projectId || '', {
|
||||
name: value.fieldTitle,
|
||||
key: columnKey,
|
||||
field_type: value.fieldType,
|
||||
width: 120,
|
||||
is_visible: true,
|
||||
configuration
|
||||
});
|
||||
|
||||
if (res.done) {
|
||||
if (res.body.id) newColumn.id = res.body.id;
|
||||
dispatch(addCustomColumn(newColumn));
|
||||
dispatch(setCustomColumnModalAttributes({ modalType: 'create', columnId: null }));
|
||||
dispatch(toggleCustomColumnModalOpen(false));
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error creating custom column:', error);
|
||||
message.error('Failed to create custom column');
|
||||
}
|
||||
} else if (customColumnModalType === 'edit' && customColumnId) {
|
||||
const updatedColumn = openedColumn
|
||||
? {
|
||||
...openedColumn,
|
||||
name: value.fieldTitle,
|
||||
columnHeader: (
|
||||
<CustomColumnHeader columnKey={customColumnId} columnName={value.fieldTitle} />
|
||||
),
|
||||
custom_column_obj: {
|
||||
...openedColumn.custom_column_obj,
|
||||
fieldTitle: value.fieldTitle,
|
||||
fieldType: value.fieldType,
|
||||
numberType: value.numberType,
|
||||
decimals: value.decimals,
|
||||
label: value.label,
|
||||
labelPosition: value.labelPosition,
|
||||
previewValue: value.previewValue,
|
||||
expression: value.expression,
|
||||
firstNumericColumn: value.firstNumericColumn,
|
||||
secondNumericColumn: value.secondNumericColumn,
|
||||
labelsList: value.fieldType === 'labels' ? labelsList : [],
|
||||
selectionsList: value.fieldType === 'selection' ? selectionsList : [],
|
||||
},
|
||||
}
|
||||
: null;
|
||||
|
||||
if (updatedColumn) {
|
||||
try {
|
||||
// Prepare the configuration object
|
||||
const configuration = {
|
||||
field_title: value.fieldTitle,
|
||||
field_type: value.fieldType,
|
||||
number_type: value.numberType,
|
||||
decimals: value.decimals,
|
||||
label: value.label,
|
||||
label_position: value.labelPosition,
|
||||
preview_value: value.previewValue,
|
||||
expression: value.expression,
|
||||
first_numeric_column_key: value.firstNumericColumn?.key,
|
||||
second_numeric_column_key: value.secondNumericColumn?.key,
|
||||
selections_list:
|
||||
value.fieldType === 'selection'
|
||||
? selectionsList.map((selection, index) => ({
|
||||
selection_id: selection.selection_id,
|
||||
selection_name: selection.selection_name,
|
||||
selection_color: selection.selection_color,
|
||||
selection_order: index,
|
||||
}))
|
||||
: [],
|
||||
labels_list:
|
||||
value.fieldType === 'labels'
|
||||
? labelsList.map((label, index) => ({
|
||||
label_id: label.label_id,
|
||||
label_name: label.label_name,
|
||||
label_color: label.label_color,
|
||||
label_order: index,
|
||||
}))
|
||||
: [],
|
||||
};
|
||||
|
||||
// Make API request to update custom column using the service
|
||||
await tasksCustomColumnsService.updateCustomColumn(openedColumn?.id || customColumnId, {
|
||||
name: value.fieldTitle,
|
||||
field_type: value.fieldType,
|
||||
width: 150,
|
||||
is_visible: true,
|
||||
configuration
|
||||
});
|
||||
|
||||
// Close modal
|
||||
dispatch(toggleCustomColumnModalOpen(false));
|
||||
dispatch(setCustomColumnModalAttributes({ modalType: 'create', columnId: null }));
|
||||
|
||||
// Reload the page instead of updating the slice
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
logger.error('Error updating custom column:', error);
|
||||
message.error('Failed to update custom column');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mainForm.resetFields();
|
||||
} catch (error) {
|
||||
logger.error('error in custom column modal', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={customColumnModalType === 'create' ? 'Add field' : 'Edit field'}
|
||||
centered
|
||||
open={isCustomColumnModalOpen}
|
||||
onCancel={() => {
|
||||
dispatch(toggleCustomColumnModalOpen(false));
|
||||
dispatch(setCustomColumnModalAttributes({ modalType: 'create', columnId: null }));
|
||||
}}
|
||||
styles={{
|
||||
header: { position: 'relative' },
|
||||
footer: { display: 'none' },
|
||||
}}
|
||||
onClose={() => {
|
||||
mainForm.resetFields();
|
||||
}}
|
||||
afterOpenChange={open => {
|
||||
if (open && customColumnModalType === 'edit' && openedColumn) {
|
||||
// Set the field type first so the correct form fields are displayed
|
||||
dispatch(setCustomFieldType(openedColumn.custom_column_obj?.fieldType || 'people'));
|
||||
|
||||
// Set other field values based on the custom column type
|
||||
if (openedColumn.custom_column_obj?.fieldType === 'number') {
|
||||
dispatch(setCustomFieldNumberType(openedColumn.custom_column_obj?.numberType || 'formatted'));
|
||||
dispatch(setDecimals(openedColumn.custom_column_obj?.decimals || 0));
|
||||
dispatch(setLabel(openedColumn.custom_column_obj?.label || ''));
|
||||
dispatch(setLabelPosition(openedColumn.custom_column_obj?.labelPosition || 'left'));
|
||||
} else if (openedColumn.custom_column_obj?.fieldType === 'formula') {
|
||||
dispatch(setExpression(openedColumn.custom_column_obj?.expression || 'add'));
|
||||
dispatch(setFirstNumericColumn(openedColumn.custom_column_obj?.firstNumericColumn || null));
|
||||
dispatch(setSecondNumericColumn(openedColumn.custom_column_obj?.secondNumericColumn || null));
|
||||
} else if (openedColumn.custom_column_obj?.fieldType === 'selection') {
|
||||
// Directly set the selections list in the Redux store
|
||||
if (Array.isArray(openedColumn.custom_column_obj?.selectionsList)) {
|
||||
console.log('Setting selections list:', openedColumn.custom_column_obj.selectionsList);
|
||||
dispatch(setSelectionsList(openedColumn.custom_column_obj.selectionsList));
|
||||
}
|
||||
} else if (openedColumn.custom_column_obj?.fieldType === 'labels') {
|
||||
// Directly set the labels list in the Redux store
|
||||
if (Array.isArray(openedColumn.custom_column_obj?.labelsList)) {
|
||||
console.log('Setting labels list:', openedColumn.custom_column_obj.labelsList);
|
||||
dispatch(setLabelsList(openedColumn.custom_column_obj.labelsList));
|
||||
}
|
||||
}
|
||||
|
||||
// Set form values
|
||||
mainForm.setFieldsValue({
|
||||
fieldTitle: openedColumn.name || openedColumn.custom_column_obj?.fieldTitle,
|
||||
fieldType: openedColumn.custom_column_obj?.fieldType,
|
||||
numberType: openedColumn.custom_column_obj?.numberType,
|
||||
decimals: openedColumn.custom_column_obj?.decimals,
|
||||
label: openedColumn.custom_column_obj?.label,
|
||||
labelPosition: openedColumn.custom_column_obj?.labelPosition,
|
||||
previewValue: openedColumn.custom_column_obj?.previewValue,
|
||||
expression: openedColumn.custom_column_obj?.expression,
|
||||
firstNumericColumn: openedColumn.custom_column_obj?.firstNumericColumn,
|
||||
secondNumericColumn: openedColumn.custom_column_obj?.secondNumericColumn,
|
||||
});
|
||||
} else if (open && customColumnModalType === 'create') {
|
||||
// Reset form for create mode
|
||||
mainForm.resetFields();
|
||||
dispatch(setCustomFieldType('people'));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Divider style={{ position: 'absolute', left: 0, top: 32 }} />
|
||||
|
||||
<Form
|
||||
form={mainForm}
|
||||
layout="vertical"
|
||||
onFinish={handleFormSubmit}
|
||||
style={{ marginBlockStart: 24 }}
|
||||
initialValues={
|
||||
customColumnModalType === 'create'
|
||||
? {
|
||||
fieldType,
|
||||
numberType,
|
||||
decimals,
|
||||
label,
|
||||
labelPosition,
|
||||
previewValue,
|
||||
expression,
|
||||
firstNumericColumn,
|
||||
secondNumericColumn,
|
||||
}
|
||||
: {
|
||||
fieldTitle: openedColumn?.custom_column_obj.fieldTitle,
|
||||
fieldType: openedColumn?.custom_column_obj.fieldType,
|
||||
numberType: openedColumn?.custom_column_obj.numberType,
|
||||
decimals: openedColumn?.custom_column_obj.decimals,
|
||||
label: openedColumn?.custom_column_obj.label,
|
||||
labelPosition: openedColumn?.custom_column_obj.labelPosition,
|
||||
previewValue: openedColumn?.custom_column_obj.previewValue,
|
||||
expression: openedColumn?.custom_column_obj.expression,
|
||||
firstNumericColumn: openedColumn?.custom_column_obj.firstNumericColumn,
|
||||
secondNumericColumn: openedColumn?.custom_column_obj.secondNumericColumn,
|
||||
}
|
||||
}
|
||||
>
|
||||
<Flex gap={16} align="center" justify="space-between">
|
||||
<Form.Item
|
||||
name={'fieldTitle'}
|
||||
label={<Typography.Text>Field title</Typography.Text>}
|
||||
layout="vertical"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: 'Field title is required',
|
||||
},
|
||||
]}
|
||||
required={false}
|
||||
>
|
||||
<Input placeholder="title" style={{ minWidth: '100%', width: 300 }} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name={'fieldType'}
|
||||
label={<Typography.Text>Type</Typography.Text>}
|
||||
layout="vertical"
|
||||
>
|
||||
<Select
|
||||
options={fieldTypesOptions}
|
||||
defaultValue={fieldType}
|
||||
value={fieldType}
|
||||
onChange={value => dispatch(setCustomFieldType(value))}
|
||||
style={{
|
||||
minWidth: '100%',
|
||||
width: 150,
|
||||
border: `1px solid ${themeWiseColor('#d9d9d9', '#424242', themeMode)}`,
|
||||
borderRadius: 4,
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Flex>
|
||||
|
||||
{/* render form items based on types */}
|
||||
{customFieldType === 'key' && <KeyTypeColumn />}
|
||||
{customFieldType === 'number' && <NumberTypeColumn />}
|
||||
{customFieldType === 'formula' && <FormulaTypeColumn />}
|
||||
{customFieldType === 'labels' && <LabelTypeColumn />}
|
||||
{customFieldType === 'selection' && <SelectionTypeColumn />}
|
||||
|
||||
<Flex
|
||||
gap={8}
|
||||
align="center"
|
||||
justify={`${customColumnModalType === 'create' ? 'flex-end' : 'space-between'}`}
|
||||
style={{ marginBlockStart: 24 }}
|
||||
>
|
||||
{customColumnModalType === 'edit' && customColumnId && (
|
||||
<Popconfirm
|
||||
title="Are you sure you want to delete this custom column?"
|
||||
description="This action cannot be undone. All data associated with this column will be permanently deleted."
|
||||
icon={<ExclamationCircleFilled style={{ color: 'red' }} />}
|
||||
onConfirm={handleDeleteColumn}
|
||||
okText="Delete"
|
||||
cancelText="Cancel"
|
||||
okButtonProps={{ danger: true }}
|
||||
>
|
||||
<Button danger>Delete</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
|
||||
<Flex gap={8}>
|
||||
<Button onClick={() => dispatch(toggleCustomColumnModalOpen(false))}>Cancel</Button>
|
||||
{customColumnModalType === 'create' ? (
|
||||
<Button type="primary" htmlType="submit">
|
||||
Create
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="primary" htmlType="submit">
|
||||
Update
|
||||
</Button>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Form>
|
||||
|
||||
<Divider style={{ position: 'absolute', left: 0, bottom: 42 }} />
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomColumnModal;
|
||||
@@ -0,0 +1,103 @@
|
||||
import { Flex, Form, Select, Typography } from 'antd';
|
||||
import React from 'react';
|
||||
import { themeWiseColor } from '../../../../../../../../utils/themeWiseColor';
|
||||
import { useAppSelector } from '../../../../../../../../hooks/useAppSelector';
|
||||
import { useAppDispatch } from '../../../../../../../../hooks/useAppDispatch';
|
||||
import {
|
||||
setExpression,
|
||||
setFirstNumericColumn,
|
||||
setSecondNumericColumn,
|
||||
} from '../../../../../../../../features/projects/singleProject/task-list-custom-columns/task-list-custom-columns-slice';
|
||||
|
||||
const FormulaTypeColumn = () => {
|
||||
// get theme details from the theme reducer
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
// get initial data from task list custom column slice
|
||||
const expression = useAppSelector(state => state.taskListCustomColumnsReducer.expression);
|
||||
|
||||
// get columns from column slice and filter only numeric columns
|
||||
const columnsOptions = useAppSelector(
|
||||
state => state.projectViewTaskListColumnsReducer.columnList
|
||||
);
|
||||
|
||||
// filter numeric columns only
|
||||
const numericColumns = columnsOptions.filter(
|
||||
column => column.customColumnObj?.fieldType === 'number'
|
||||
);
|
||||
|
||||
// expression types options
|
||||
const expressionTypesOptions = [
|
||||
{ key: 'add', value: 'add', label: '+ Add' },
|
||||
{ key: 'substract', value: 'substract', label: '- Substract' },
|
||||
{ key: 'divide', value: 'divide', label: '/ Divide' },
|
||||
{ key: 'multiply', value: 'multiply', label: 'x Multiply' },
|
||||
];
|
||||
|
||||
return (
|
||||
<Flex gap={8} align="center" justify="space-between">
|
||||
<Form.Item
|
||||
name={'firstNumericColumn'}
|
||||
label={<Typography.Text>First Column</Typography.Text>}
|
||||
>
|
||||
{/* first numeric column */}
|
||||
<Select
|
||||
options={numericColumns.map(col => ({
|
||||
key: col.key,
|
||||
value: col.key,
|
||||
label: col.name,
|
||||
}))}
|
||||
onChange={value => dispatch(setFirstNumericColumn(value))}
|
||||
placeholder="Select first column"
|
||||
style={{
|
||||
minWidth: '100%',
|
||||
width: 150,
|
||||
border: `1px solid ${themeWiseColor('#d9d9d9', '#424242', themeMode)}`,
|
||||
borderRadius: 4,
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name={'expression'} label={<Typography.Text>Expression</Typography.Text>}>
|
||||
{/* expression type */}
|
||||
<Select
|
||||
options={expressionTypesOptions}
|
||||
value={expression}
|
||||
onChange={value => dispatch(setExpression(value))}
|
||||
style={{
|
||||
minWidth: '100%',
|
||||
width: 150,
|
||||
border: `1px solid ${themeWiseColor('#d9d9d9', '#424242', themeMode)}`,
|
||||
borderRadius: 4,
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name={'secondNumericColumn'}
|
||||
label={<Typography.Text>Second Column</Typography.Text>}
|
||||
>
|
||||
{/* second numeric column */}
|
||||
<Select
|
||||
options={numericColumns.map(col => ({
|
||||
key: col.key,
|
||||
value: col.key,
|
||||
label: col.name,
|
||||
}))}
|
||||
onChange={value => dispatch(setSecondNumericColumn(value))}
|
||||
placeholder="Select second column"
|
||||
style={{
|
||||
minWidth: '100%',
|
||||
width: 150,
|
||||
border: `1px solid ${themeWiseColor('#d9d9d9', '#424242', themeMode)}`,
|
||||
borderRadius: 4,
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default FormulaTypeColumn;
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Flex, Form, Input, Typography } from 'antd';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
const KeyTypeColumn = () => {
|
||||
const [keyLabel, setKeyLabel] = useState<string>('MK');
|
||||
|
||||
return (
|
||||
<Flex gap={16}>
|
||||
<Form.Item name="customKeyLabel" label="Label">
|
||||
<Input
|
||||
value={keyLabel}
|
||||
placeholder="ex-:MK"
|
||||
maxLength={5}
|
||||
style={{ textTransform: 'uppercase' }}
|
||||
onChange={e => setKeyLabel(e.currentTarget.value)}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="customKeyPreviewValue" label="Preview">
|
||||
<Typography.Text style={{ textTransform: 'uppercase' }}>
|
||||
{keyLabel.length === 0 ? 'MK' : keyLabel}-1
|
||||
</Typography.Text>
|
||||
</Form.Item>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default KeyTypeColumn;
|
||||
@@ -0,0 +1,142 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { PhaseColorCodes } from '../../../../../../../../shared/constants';
|
||||
import { Button, Flex, Input, Select, Tag, Typography } from 'antd';
|
||||
import { CloseCircleOutlined, HolderOutlined } from '@ant-design/icons';
|
||||
import { useAppDispatch } from '../../../../../../../../hooks/useAppDispatch';
|
||||
import { useAppSelector } from '../../../../../../../../hooks/useAppSelector';
|
||||
import { setLabelsList } from '../../../../../../../../features/projects/singleProject/task-list-custom-columns/task-list-custom-columns-slice';
|
||||
|
||||
export type LabelType = {
|
||||
label_id: string;
|
||||
label_name: string;
|
||||
label_color: string;
|
||||
};
|
||||
|
||||
const LabelTypeColumn = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const [labels, setLabels] = useState<LabelType[]>([
|
||||
{
|
||||
label_id: nanoid(),
|
||||
label_name: 'Untitled label',
|
||||
label_color: PhaseColorCodes[0],
|
||||
},
|
||||
]);
|
||||
|
||||
// Get the custom column modal type and column ID from the store
|
||||
const { customColumnModalType, customColumnId } = useAppSelector(
|
||||
state => state.taskListCustomColumnsReducer
|
||||
);
|
||||
|
||||
// Get the opened column data if in edit mode
|
||||
const openedColumn = useAppSelector(state =>
|
||||
state.taskReducer.customColumns.find(col => col.key === customColumnId)
|
||||
);
|
||||
|
||||
// Load existing labels when in edit mode
|
||||
useEffect(() => {
|
||||
if (customColumnModalType === 'edit' && openedColumn?.custom_column_obj?.labelsList) {
|
||||
const existingLabels = openedColumn.custom_column_obj.labelsList;
|
||||
if (Array.isArray(existingLabels) && existingLabels.length > 0) {
|
||||
setLabels(existingLabels);
|
||||
dispatch(setLabelsList(existingLabels));
|
||||
}
|
||||
}
|
||||
}, [customColumnModalType, openedColumn, customColumnId, dispatch]);
|
||||
|
||||
// phase color options
|
||||
const phaseOptionColorList = PhaseColorCodes.map(color => ({
|
||||
value: color,
|
||||
label: (
|
||||
<Tag
|
||||
color={color}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
width: 15,
|
||||
height: 15,
|
||||
borderRadius: '50%',
|
||||
}}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
// add a new label
|
||||
const handleAddLabel = () => {
|
||||
const newLabel = {
|
||||
label_id: nanoid(),
|
||||
label_name: 'Untitled label',
|
||||
label_color: PhaseColorCodes[0],
|
||||
};
|
||||
setLabels(prevLabels => [...prevLabels, newLabel]);
|
||||
dispatch(setLabelsList([...labels, newLabel])); // update the slice with the new label
|
||||
};
|
||||
|
||||
// update label name
|
||||
const handleUpdateLabelName = (labelId: string, labelName: string) => {
|
||||
const updatedLabels = labels.map(label =>
|
||||
label.label_id === labelId ? { ...label, label_name: labelName } : label
|
||||
);
|
||||
setLabels(updatedLabels);
|
||||
dispatch(setLabelsList(updatedLabels)); // update the slice with the new label name
|
||||
};
|
||||
|
||||
// update label color
|
||||
const handleUpdateLabelColor = (labelId: string, labelColor: string) => {
|
||||
const updatedLabels = labels.map(label =>
|
||||
label.label_id === labelId ? { ...label, label_color: labelColor } : label
|
||||
);
|
||||
setLabels(updatedLabels);
|
||||
dispatch(setLabelsList(updatedLabels)); // update the slice with the new label color
|
||||
};
|
||||
|
||||
// remove a label
|
||||
const handleRemoveLabel = (labelId: string) => {
|
||||
const updatedLabels = labels.filter(label => label.label_id !== labelId);
|
||||
setLabels(updatedLabels);
|
||||
dispatch(setLabelsList(updatedLabels)); // update the slice after label removal
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: '100%', minHeight: 180 }}>
|
||||
<Typography.Text>Labels</Typography.Text>
|
||||
<Flex vertical gap={8}>
|
||||
<Flex vertical gap={8} style={{ maxHeight: 120, overflow: 'auto' }}>
|
||||
{labels.map(label => (
|
||||
<Flex gap={8} key={label.label_id}>
|
||||
<HolderOutlined style={{ fontSize: 18 }} />
|
||||
<Input
|
||||
value={label.label_name}
|
||||
onChange={e => handleUpdateLabelName(label.label_id, e.target.value)}
|
||||
style={{ width: 'fit-content', maxWidth: 400 }}
|
||||
/>
|
||||
<Flex gap={8} align="center">
|
||||
<Select
|
||||
options={phaseOptionColorList}
|
||||
value={label.label_color}
|
||||
onChange={value => handleUpdateLabelColor(label.label_id, value)}
|
||||
style={{ width: 48 }}
|
||||
suffixIcon={null}
|
||||
/>
|
||||
<CloseCircleOutlined
|
||||
onClick={() => handleRemoveLabel(label.label_id)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
</Flex>
|
||||
</Flex>
|
||||
))}
|
||||
</Flex>
|
||||
|
||||
<Button
|
||||
type="link"
|
||||
onClick={handleAddLabel}
|
||||
style={{ width: 'fit-content', padding: 0 }}
|
||||
>
|
||||
+ Add a label
|
||||
</Button>
|
||||
</Flex>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LabelTypeColumn;
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Form, Select, Typography } from 'antd';
|
||||
import React from 'react';
|
||||
import { useAppSelector } from '../../../../../../../../hooks/useAppSelector';
|
||||
import { themeWiseColor } from '../../../../../../../../utils/themeWiseColor';
|
||||
import { useAppDispatch } from '../../../../../../../../hooks/useAppDispatch';
|
||||
import { setDecimals } from '../../../../../../../../features/projects/singleProject/task-list-custom-columns/task-list-custom-columns-slice';
|
||||
|
||||
const FormattedTypeNumberColumn = () => {
|
||||
// Get theme details from the theme reducer
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
// get initial data from task list custom column slice
|
||||
const decimals: number = useAppSelector(state => state.taskListCustomColumnsReducer.decimals);
|
||||
const previewValue: number = useAppSelector(
|
||||
state => state.taskListCustomColumnsReducer.previewValue
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form.Item name="decimals" label={<Typography.Text>Decimals</Typography.Text>}>
|
||||
<Select
|
||||
options={[1, 2, 3, 4].map(item => ({
|
||||
key: item,
|
||||
value: item,
|
||||
label: item,
|
||||
}))}
|
||||
defaultValue={decimals}
|
||||
onChange={value => dispatch(setDecimals(value))}
|
||||
style={{
|
||||
border: `1px solid ${themeWiseColor('#d9d9d9', '#424242', themeMode)}`,
|
||||
borderRadius: 4,
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="previewValue" label={<Typography.Text>Preview</Typography.Text>}>
|
||||
<Typography.Text>{previewValue.toFixed(decimals)}</Typography.Text>
|
||||
</Form.Item>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default FormattedTypeNumberColumn;
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Form, Select, Typography } from 'antd';
|
||||
import React from 'react';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { themeWiseColor } from '@/utils/themeWiseColor';
|
||||
import FormattedTypeNumberColumn from './formatted-type-number-column';
|
||||
import UnformattedTypeNumberColumn from './unformatted-type-number-column';
|
||||
import PercentageTypeNumberColumn from './percentage-type-number-column';
|
||||
import WithLabelTypeNumberColumn from './with-label-type-number-column';
|
||||
import {
|
||||
CustomFieldNumberTypes,
|
||||
setCustomFieldNumberType,
|
||||
} from '@/features/projects/singleProject/task-list-custom-columns/task-list-custom-columns-slice';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
|
||||
const NumberTypeColumn = () => {
|
||||
// get theme details from theme reducer
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
// get initial data from task list custom column slice
|
||||
const numberType: CustomFieldNumberTypes = useAppSelector(
|
||||
state => state.taskListCustomColumnsReducer.customFieldNumberType
|
||||
);
|
||||
|
||||
const numberTypesOptions = [
|
||||
{ key: 'unformatted', value: 'unformatted', label: 'Unformatted' },
|
||||
{ key: 'percentage', value: 'percentage', label: 'Percentage' },
|
||||
{ key: 'withLabel', value: 'withLabel', label: 'With Label' },
|
||||
{ key: 'formatted', value: 'formatted', label: 'Formatted' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className={numberType === 'withLabel' ? 'grid grid-cols-5 gap-x-4' : 'flex gap-4'}>
|
||||
<Form.Item
|
||||
name="numberType"
|
||||
label={<Typography.Text>Number Type</Typography.Text>}
|
||||
className={numberType === 'withLabel' ? 'col-span-2' : ''}
|
||||
>
|
||||
<Select
|
||||
options={numberTypesOptions}
|
||||
value={numberType}
|
||||
onChange={value => dispatch(setCustomFieldNumberType(value))}
|
||||
style={{
|
||||
minWidth: '100%',
|
||||
width: 150,
|
||||
border: `1px solid ${themeWiseColor('#d9d9d9', '#424242', themeMode)}`,
|
||||
borderRadius: 4,
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{numberType === 'formatted' && <FormattedTypeNumberColumn />}
|
||||
{numberType === 'unformatted' && <UnformattedTypeNumberColumn />}
|
||||
{numberType === 'percentage' && <PercentageTypeNumberColumn />}
|
||||
{numberType === 'withLabel' && <WithLabelTypeNumberColumn />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NumberTypeColumn;
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Form, Select, Typography } from 'antd';
|
||||
import React from 'react';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { themeWiseColor } from '@/utils/themeWiseColor';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { setDecimals } from '@/features/projects/singleProject/task-list-custom-columns/task-list-custom-columns-slice';
|
||||
|
||||
const PercentageTypeNumberColumn = () => {
|
||||
// get theme details from theme reducer
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
// get initial data from task list custom column slice
|
||||
const decimals: number = useAppSelector(state => state.taskListCustomColumnsReducer.decimals);
|
||||
const previewValue: number = useAppSelector(
|
||||
state => state.taskListCustomColumnsReducer.previewValue
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form.Item name={'decimals'} label={<Typography.Text>Decimals</Typography.Text>}>
|
||||
<Select
|
||||
options={[1, 2, 3, 4].map(item => ({
|
||||
key: item,
|
||||
value: item,
|
||||
label: item,
|
||||
}))}
|
||||
defaultValue={decimals}
|
||||
onChange={value => dispatch(setDecimals(value))}
|
||||
style={{
|
||||
border: `1px solid ${themeWiseColor('#d9d9d9', '#424242', themeMode)}`,
|
||||
borderRadius: 4,
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name={'previewValue'} label={<Typography.Text>Preview</Typography.Text>}>
|
||||
<Typography.Text>{previewValue.toFixed(decimals)}%</Typography.Text>
|
||||
</Form.Item>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PercentageTypeNumberColumn;
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Form, Typography } from 'antd';
|
||||
import React from 'react';
|
||||
import { useAppSelector } from '../../../../../../../../hooks/useAppSelector';
|
||||
|
||||
const UnformattedTypeNumberColumn = () => {
|
||||
// get initial data from task list custom column slice
|
||||
const previewValue: number = useAppSelector(
|
||||
state => state.taskListCustomColumnsReducer.previewValue
|
||||
);
|
||||
|
||||
return (
|
||||
<Form.Item name={'previewValue'} label={<Typography.Text>Preview</Typography.Text>}>
|
||||
{previewValue}
|
||||
</Form.Item>
|
||||
);
|
||||
};
|
||||
|
||||
export default UnformattedTypeNumberColumn;
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Form, Input, Select, Typography } from 'antd';
|
||||
import React from 'react';
|
||||
import { useAppSelector } from '../../../../../../../../hooks/useAppSelector';
|
||||
import { themeWiseColor } from '../../../../../../../../utils/themeWiseColor';
|
||||
import { useAppDispatch } from '../../../../../../../../hooks/useAppDispatch';
|
||||
import {
|
||||
setDecimals,
|
||||
setLabel,
|
||||
setLabelPosition,
|
||||
} from '../../../../../../../../features/projects/singleProject/task-list-custom-columns/task-list-custom-columns-slice';
|
||||
|
||||
const WithLabelTypeNumberColumn = () => {
|
||||
// get theme details from theme reducer
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
// get initial data from task list custom column slice
|
||||
const decimals: number = useAppSelector(state => state.taskListCustomColumnsReducer.decimals);
|
||||
const label: string = useAppSelector(state => state.taskListCustomColumnsReducer.label);
|
||||
const labelPosition: 'left' | 'right' = useAppSelector(
|
||||
state => state.taskListCustomColumnsReducer.labelPosition
|
||||
);
|
||||
const previewValue: number = useAppSelector(
|
||||
state => state.taskListCustomColumnsReducer.previewValue
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form.Item name={'label'} label={<Typography.Text>Label</Typography.Text>}>
|
||||
<Input value={label} onChange={e => dispatch(setLabel(e.currentTarget.value))} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name={'labelPosition'} label={<Typography.Text>Position</Typography.Text>}>
|
||||
<Select
|
||||
options={[
|
||||
{
|
||||
key: 'left',
|
||||
value: 'left',
|
||||
label: 'Left',
|
||||
},
|
||||
{ key: 'right', value: 'right', label: 'Right' },
|
||||
]}
|
||||
defaultValue={labelPosition}
|
||||
value={labelPosition}
|
||||
onChange={value => dispatch(setLabelPosition(value))}
|
||||
style={{
|
||||
border: `1px solid ${themeWiseColor('#d9d9d9', '#424242', themeMode)}`,
|
||||
borderRadius: 4,
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name={'decimals'} label={<Typography.Text>Decimals</Typography.Text>}>
|
||||
<Select
|
||||
options={[1, 2, 3, 4].map(item => ({
|
||||
key: item,
|
||||
value: item,
|
||||
label: item,
|
||||
}))}
|
||||
value={decimals}
|
||||
onChange={value => dispatch(setDecimals(value))}
|
||||
style={{
|
||||
border: `1px solid ${themeWiseColor('#d9d9d9', '#424242', themeMode)}`,
|
||||
borderRadius: 4,
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name={'previewValue'}
|
||||
label={<Typography.Text>Preview</Typography.Text>}
|
||||
className="col-span-5"
|
||||
>
|
||||
<Typography.Text>
|
||||
{labelPosition === 'left'
|
||||
? `${label} ${previewValue.toFixed(decimals)}`
|
||||
: `${previewValue.toFixed(decimals)} ${label} `}
|
||||
</Typography.Text>
|
||||
</Form.Item>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default WithLabelTypeNumberColumn;
|
||||
@@ -0,0 +1,154 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { PhaseColorCodes } from '../../../../../../../../shared/constants';
|
||||
import { Button, Flex, Input, Select, Tag, Typography } from 'antd';
|
||||
import { CloseCircleOutlined, HolderOutlined } from '@ant-design/icons';
|
||||
|
||||
import { useAppDispatch } from '../../../../../../../../hooks/useAppDispatch';
|
||||
import { useAppSelector } from '../../../../../../../../hooks/useAppSelector';
|
||||
import { setSelectionsList } from '../../../../../../../../features/projects/singleProject/task-list-custom-columns/task-list-custom-columns-slice';
|
||||
|
||||
export type SelectionType = {
|
||||
selection_color: string;
|
||||
selection_id: string;
|
||||
selection_name: string;
|
||||
};
|
||||
|
||||
const SelectionTypeColumn = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const [selections, setSelections] = useState<SelectionType[]>([
|
||||
{
|
||||
selection_id: nanoid(),
|
||||
selection_name: 'Untitled selection',
|
||||
selection_color: PhaseColorCodes[0],
|
||||
},
|
||||
]);
|
||||
|
||||
// Get the custom column modal type and column ID from the store
|
||||
const { customColumnModalType, customColumnId, selectionsList: storeSelectionsList } = useAppSelector(
|
||||
state => state.taskListCustomColumnsReducer
|
||||
);
|
||||
|
||||
// Get the opened column data if in edit mode
|
||||
const openedColumn = useAppSelector(state =>
|
||||
state.taskReducer.customColumns.find(col => col.key === customColumnId)
|
||||
);
|
||||
|
||||
console.log('SelectionTypeColumn render:', {
|
||||
customColumnModalType,
|
||||
customColumnId,
|
||||
openedColumn,
|
||||
storeSelectionsList,
|
||||
'openedColumn?.custom_column_obj?.selectionsList': openedColumn?.custom_column_obj?.selectionsList
|
||||
});
|
||||
|
||||
// Load existing selections when in edit mode
|
||||
useEffect(() => {
|
||||
if (customColumnModalType === 'edit' && openedColumn?.custom_column_obj?.selectionsList) {
|
||||
const existingSelections = openedColumn.custom_column_obj.selectionsList;
|
||||
console.log('Loading existing selections:', existingSelections);
|
||||
|
||||
if (Array.isArray(existingSelections) && existingSelections.length > 0) {
|
||||
setSelections(existingSelections);
|
||||
dispatch(setSelectionsList(existingSelections));
|
||||
}
|
||||
}
|
||||
}, [customColumnModalType, openedColumn, customColumnId, dispatch]);
|
||||
|
||||
// phase color options
|
||||
const phaseOptionColorList = PhaseColorCodes.map(color => ({
|
||||
value: color,
|
||||
label: (
|
||||
<Tag
|
||||
color={color}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
width: 15,
|
||||
height: 15,
|
||||
borderRadius: '50%',
|
||||
}}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
// add a new selection
|
||||
const handleAddSelection = () => {
|
||||
const newSelection = {
|
||||
selection_id: nanoid(),
|
||||
selection_name: 'Untitled selection',
|
||||
selection_color: PhaseColorCodes[0],
|
||||
};
|
||||
setSelections(prevSelections => [...prevSelections, newSelection]);
|
||||
dispatch(setSelectionsList([...selections, newSelection])); // update the slice with the new selection
|
||||
};
|
||||
|
||||
// update selection name
|
||||
const handleUpdateSelectionName = (selectionId: string, selectionName: string) => {
|
||||
const updatedSelections = selections.map(selection =>
|
||||
selection.selection_id === selectionId ? { ...selection, selection_name: selectionName } : selection
|
||||
);
|
||||
setSelections(updatedSelections);
|
||||
dispatch(setSelectionsList(updatedSelections)); // update the slice with the new selection name
|
||||
};
|
||||
|
||||
// update selection color
|
||||
const handleUpdateSelectionColor = (selectionId: string, selectionColor: string) => {
|
||||
const updatedSelections = selections.map(selection =>
|
||||
selection.selection_id === selectionId ? { ...selection, selection_color: selectionColor } : selection
|
||||
);
|
||||
setSelections(updatedSelections);
|
||||
dispatch(setSelectionsList(updatedSelections)); // update the slice with the new selection color
|
||||
};
|
||||
|
||||
// remove a selection
|
||||
const handleRemoveSelection = (selectionId: string) => {
|
||||
const updatedSelections = selections.filter(selection => selection.selection_id !== selectionId);
|
||||
setSelections(updatedSelections);
|
||||
dispatch(setSelectionsList(updatedSelections)); // update the slice after selection removal
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: '100%', minHeight: 180 }}>
|
||||
<Typography.Text>Selections</Typography.Text>
|
||||
<Flex vertical gap={8}>
|
||||
<Flex vertical gap={8} style={{ maxHeight: 120, overflow: 'auto' }}>
|
||||
{selections.map(selection => (
|
||||
<Flex gap={8} key={selection.selection_id}>
|
||||
<HolderOutlined style={{ fontSize: 18 }} />
|
||||
<Input
|
||||
value={selection.selection_name}
|
||||
onChange={e => handleUpdateSelectionName(selection.selection_id, e.target.value)}
|
||||
style={{ width: 'fit-content', maxWidth: 400 }}
|
||||
/>
|
||||
<Flex gap={8} align="center">
|
||||
<Select
|
||||
options={phaseOptionColorList}
|
||||
value={selection.selection_color}
|
||||
onChange={value => handleUpdateSelectionColor(selection.selection_id, value)}
|
||||
style={{ width: 48 }}
|
||||
suffixIcon={null}
|
||||
/>
|
||||
|
||||
<CloseCircleOutlined
|
||||
onClick={() => handleRemoveSelection(selection.selection_id)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
</Flex>
|
||||
</Flex>
|
||||
))}
|
||||
</Flex>
|
||||
|
||||
<Button
|
||||
type="link"
|
||||
onClick={handleAddSelection}
|
||||
style={{ width: 'fit-content', padding: 0 }}
|
||||
>
|
||||
+ Add a selection
|
||||
</Button>
|
||||
</Flex>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SelectionTypeColumn;
|
||||
@@ -0,0 +1,671 @@
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { useSocket } from '@/socket/socketContext';
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import Flex from 'antd/es/flex';
|
||||
import useIsomorphicLayoutEffect from '@/hooks/useIsomorphicLayoutEffect';
|
||||
|
||||
import {
|
||||
DndContext,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
DragEndEvent,
|
||||
DragStartEvent,
|
||||
pointerWithin,
|
||||
} from '@dnd-kit/core';
|
||||
|
||||
import { SocketEvents } from '@/shared/socket-events';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import alertService from '@/services/alerts/alertService';
|
||||
import { tasksApiService } from '@/api/tasks/tasks.api.service';
|
||||
|
||||
import { ITaskListGroup } from '@/types/tasks/taskList.types';
|
||||
import { ITaskAssigneesUpdateResponse } from '@/types/tasks/task-assignee-update-response';
|
||||
import { ILabelsChangeResponse } from '@/types/tasks/taskList.types';
|
||||
import { ITaskListStatusChangeResponse } from '@/types/tasks/task-list-status.types';
|
||||
import { ITaskListPriorityChangeResponse } from '@/types/tasks/task-list-priority.types';
|
||||
import { ITaskPhaseChangeResponse } from '@/types/tasks/task-phase-change-response';
|
||||
import { InlineMember } from '@/types/teamMembers/inlineMember.types';
|
||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||
|
||||
import {
|
||||
fetchTaskAssignees,
|
||||
updateTaskAssignees,
|
||||
fetchLabelsByProject,
|
||||
updateTaskLabel,
|
||||
updateTaskStatus,
|
||||
updateTaskPriority,
|
||||
updateTaskEndDate,
|
||||
updateTaskEstimation,
|
||||
updateTaskName,
|
||||
updateTaskPhase,
|
||||
updateTaskStartDate,
|
||||
IGroupBy,
|
||||
updateTaskDescription,
|
||||
updateSubTasks,
|
||||
updateTaskProgress,
|
||||
} from '@/features/tasks/tasks.slice';
|
||||
import { fetchLabels } from '@/features/taskAttributes/taskLabelSlice';
|
||||
import {
|
||||
setStartDate,
|
||||
setTaskAssignee,
|
||||
setTaskEndDate,
|
||||
setTaskLabels,
|
||||
setTaskPriority,
|
||||
setTaskStatus,
|
||||
setTaskSubscribers,
|
||||
} from '@/features/task-drawer/task-drawer.slice';
|
||||
import { deselectAll } from '@/features/projects/bulkActions/bulkActionSlice';
|
||||
|
||||
import TaskListTableWrapper from '@/pages/projects/projectView/taskList/task-list-table/task-list-table-wrapper/task-list-table-wrapper';
|
||||
import TaskListBulkActionsBar from '@/components/taskListCommon/task-list-bulk-actions-bar/task-list-bulk-actions-bar';
|
||||
import TaskTemplateDrawer from '@/components/task-templates/task-template-drawer';
|
||||
|
||||
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
|
||||
import { evt_project_task_list_drag_and_move } from '@/shared/worklenz-analytics-events';
|
||||
import { ALPHA_CHANNEL } from '@/shared/constants';
|
||||
import { checkTaskDependencyStatus } from '@/utils/check-task-dependency-status';
|
||||
|
||||
interface TaskGroupWrapperProps {
|
||||
taskGroups: ITaskListGroup[];
|
||||
groupBy: string;
|
||||
}
|
||||
|
||||
const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
|
||||
const [groups, setGroups] = useState(taskGroups);
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const { socket } = useSocket();
|
||||
const currentSession = useAuthService().getCurrentSession();
|
||||
const { trackMixpanelEvent } = useMixpanelTracking();
|
||||
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
const loadingAssignees = useAppSelector(state => state.taskReducer.loadingAssignees);
|
||||
const { projectId } = useAppSelector(state => state.projectReducer);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: { distance: 8 },
|
||||
})
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setGroups(taskGroups);
|
||||
}, [taskGroups]);
|
||||
|
||||
const resetTaskRowStyles = useCallback(() => {
|
||||
document.querySelectorAll<HTMLElement>('.task-row').forEach(row => {
|
||||
row.style.transition = 'transform 0.2s ease, opacity 0.2s ease';
|
||||
row.style.cssText =
|
||||
'opacity: 1 !important; position: relative !important; z-index: auto !important; transform: none !important;';
|
||||
row.setAttribute('data-is-dragging', 'false');
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Socket handler for assignee updates
|
||||
useEffect(() => {
|
||||
if (!socket) return;
|
||||
|
||||
const handleAssigneesUpdate = (data: ITaskAssigneesUpdateResponse) => {
|
||||
if (!data) return;
|
||||
|
||||
const updatedAssignees = data.assignees.map(assignee => ({
|
||||
...assignee,
|
||||
selected: true,
|
||||
}));
|
||||
|
||||
// Find the group that contains the task or its subtasks
|
||||
const groupId = groups.find(group =>
|
||||
group.tasks.some(
|
||||
task =>
|
||||
task.id === data.id ||
|
||||
(task.sub_tasks && task.sub_tasks.some(subtask => subtask.id === data.id))
|
||||
)
|
||||
)?.id;
|
||||
|
||||
if (groupId) {
|
||||
dispatch(
|
||||
updateTaskAssignees({
|
||||
groupId,
|
||||
taskId: data.id,
|
||||
assignees: updatedAssignees,
|
||||
})
|
||||
);
|
||||
|
||||
dispatch(setTaskAssignee(data));
|
||||
|
||||
if (currentSession?.team_id && !loadingAssignees) {
|
||||
dispatch(fetchTaskAssignees(currentSession.team_id));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
socket.on(SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(), handleAssigneesUpdate);
|
||||
return () => {
|
||||
socket.off(SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(), handleAssigneesUpdate);
|
||||
};
|
||||
}, [socket, currentSession?.team_id, loadingAssignees, groups, dispatch]);
|
||||
|
||||
// Socket handler for label updates
|
||||
useEffect(() => {
|
||||
if (!socket) return;
|
||||
|
||||
const handleLabelsChange = async (labels: ILabelsChangeResponse) => {
|
||||
await Promise.all([
|
||||
dispatch(updateTaskLabel(labels)),
|
||||
dispatch(setTaskLabels(labels)),
|
||||
dispatch(fetchLabels()),
|
||||
projectId && dispatch(fetchLabelsByProject(projectId)),
|
||||
]);
|
||||
};
|
||||
|
||||
socket.on(SocketEvents.TASK_LABELS_CHANGE.toString(), handleLabelsChange);
|
||||
socket.on(SocketEvents.CREATE_LABEL.toString(), handleLabelsChange);
|
||||
|
||||
return () => {
|
||||
socket.off(SocketEvents.TASK_LABELS_CHANGE.toString(), handleLabelsChange);
|
||||
socket.off(SocketEvents.CREATE_LABEL.toString(), handleLabelsChange);
|
||||
};
|
||||
}, [socket, dispatch, projectId]);
|
||||
|
||||
// Socket handler for status updates
|
||||
useEffect(() => {
|
||||
if (!socket) return;
|
||||
|
||||
const handleTaskStatusChange = (response: ITaskListStatusChangeResponse) => {
|
||||
if (response.completed_deps === false) {
|
||||
alertService.error(
|
||||
'Task is not completed',
|
||||
'Please complete the task dependencies before proceeding'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(updateTaskStatus(response));
|
||||
// dispatch(setTaskStatus(response));
|
||||
dispatch(deselectAll());
|
||||
};
|
||||
|
||||
const handleTaskProgress = (data: {
|
||||
id: string;
|
||||
status: string;
|
||||
complete_ratio: number;
|
||||
completed_count: number;
|
||||
total_tasks_count: number;
|
||||
parent_task: string;
|
||||
}) => {
|
||||
dispatch(
|
||||
updateTaskProgress({
|
||||
taskId: data.parent_task || data.id,
|
||||
progress: data.complete_ratio,
|
||||
totalTasksCount: data.total_tasks_count,
|
||||
completedCount: data.completed_count,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
socket.on(SocketEvents.TASK_STATUS_CHANGE.toString(), handleTaskStatusChange);
|
||||
socket.on(SocketEvents.GET_TASK_PROGRESS.toString(), handleTaskProgress);
|
||||
|
||||
return () => {
|
||||
socket.off(SocketEvents.TASK_STATUS_CHANGE.toString(), handleTaskStatusChange);
|
||||
socket.off(SocketEvents.GET_TASK_PROGRESS.toString(), handleTaskProgress);
|
||||
};
|
||||
}, [socket, dispatch]);
|
||||
|
||||
// Socket handler for priority updates
|
||||
useEffect(() => {
|
||||
if (!socket) return;
|
||||
|
||||
const handlePriorityChange = (response: ITaskListPriorityChangeResponse) => {
|
||||
dispatch(updateTaskPriority(response));
|
||||
dispatch(setTaskPriority(response));
|
||||
dispatch(deselectAll());
|
||||
};
|
||||
|
||||
socket.on(SocketEvents.TASK_PRIORITY_CHANGE.toString(), handlePriorityChange);
|
||||
|
||||
return () => {
|
||||
socket.off(SocketEvents.TASK_PRIORITY_CHANGE.toString(), handlePriorityChange);
|
||||
};
|
||||
}, [socket, dispatch]);
|
||||
|
||||
// Socket handler for due date updates
|
||||
useEffect(() => {
|
||||
if (!socket) return;
|
||||
|
||||
const handleEndDateChange = (task: {
|
||||
id: string;
|
||||
parent_task: string | null;
|
||||
end_date: string;
|
||||
}) => {
|
||||
dispatch(updateTaskEndDate({ task }));
|
||||
dispatch(setTaskEndDate(task));
|
||||
};
|
||||
|
||||
socket.on(SocketEvents.TASK_END_DATE_CHANGE.toString(), handleEndDateChange);
|
||||
|
||||
return () => {
|
||||
socket.off(SocketEvents.TASK_END_DATE_CHANGE.toString(), handleEndDateChange);
|
||||
};
|
||||
}, [socket, dispatch]);
|
||||
|
||||
// Socket handler for task name updates
|
||||
useEffect(() => {
|
||||
if (!socket) return;
|
||||
|
||||
const handleTaskNameChange = (data: { id: string; parent_task: string; name: string }) => {
|
||||
dispatch(updateTaskName(data));
|
||||
};
|
||||
|
||||
socket.on(SocketEvents.TASK_NAME_CHANGE.toString(), handleTaskNameChange);
|
||||
|
||||
return () => {
|
||||
socket.off(SocketEvents.TASK_NAME_CHANGE.toString(), handleTaskNameChange);
|
||||
};
|
||||
}, [socket, dispatch]);
|
||||
|
||||
// Socket handler for phase updates
|
||||
useEffect(() => {
|
||||
if (!socket) return;
|
||||
|
||||
const handlePhaseChange = (data: ITaskPhaseChangeResponse) => {
|
||||
dispatch(updateTaskPhase(data));
|
||||
dispatch(deselectAll());
|
||||
};
|
||||
|
||||
socket.on(SocketEvents.TASK_PHASE_CHANGE.toString(), handlePhaseChange);
|
||||
|
||||
return () => {
|
||||
socket.off(SocketEvents.TASK_PHASE_CHANGE.toString(), handlePhaseChange);
|
||||
};
|
||||
}, [socket, dispatch]);
|
||||
|
||||
// Socket handler for start date updates
|
||||
useEffect(() => {
|
||||
if (!socket) return;
|
||||
|
||||
const handleStartDateChange = (task: {
|
||||
id: string;
|
||||
parent_task: string | null;
|
||||
start_date: string;
|
||||
}) => {
|
||||
dispatch(updateTaskStartDate({ task }));
|
||||
dispatch(setStartDate(task));
|
||||
};
|
||||
|
||||
socket.on(SocketEvents.TASK_START_DATE_CHANGE.toString(), handleStartDateChange);
|
||||
|
||||
return () => {
|
||||
socket.off(SocketEvents.TASK_START_DATE_CHANGE.toString(), handleStartDateChange);
|
||||
};
|
||||
}, [socket, dispatch]);
|
||||
|
||||
// Socket handler for task subscribers updates
|
||||
useEffect(() => {
|
||||
if (!socket) return;
|
||||
|
||||
const handleTaskSubscribersChange = (data: InlineMember[]) => {
|
||||
dispatch(setTaskSubscribers(data));
|
||||
};
|
||||
|
||||
socket.on(SocketEvents.TASK_SUBSCRIBERS_CHANGE.toString(), handleTaskSubscribersChange);
|
||||
|
||||
return () => {
|
||||
socket.off(SocketEvents.TASK_SUBSCRIBERS_CHANGE.toString(), handleTaskSubscribersChange);
|
||||
};
|
||||
}, [socket, dispatch]);
|
||||
|
||||
// Socket handler for task estimation updates
|
||||
useEffect(() => {
|
||||
if (!socket) return;
|
||||
|
||||
const handleEstimationChange = (task: {
|
||||
id: string;
|
||||
parent_task: string | null;
|
||||
estimation: number;
|
||||
}) => {
|
||||
dispatch(updateTaskEstimation({ task }));
|
||||
};
|
||||
|
||||
socket.on(SocketEvents.TASK_TIME_ESTIMATION_CHANGE.toString(), handleEstimationChange);
|
||||
|
||||
return () => {
|
||||
socket.off(SocketEvents.TASK_TIME_ESTIMATION_CHANGE.toString(), handleEstimationChange);
|
||||
};
|
||||
}, [socket, dispatch]);
|
||||
|
||||
// Socket handler for task description updates
|
||||
useEffect(() => {
|
||||
if (!socket) return;
|
||||
|
||||
const handleTaskDescriptionChange = (data: {
|
||||
id: string;
|
||||
parent_task: string;
|
||||
description: string;
|
||||
}) => {
|
||||
dispatch(updateTaskDescription(data));
|
||||
};
|
||||
|
||||
socket.on(SocketEvents.TASK_DESCRIPTION_CHANGE.toString(), handleTaskDescriptionChange);
|
||||
|
||||
return () => {
|
||||
socket.off(SocketEvents.TASK_DESCRIPTION_CHANGE.toString(), handleTaskDescriptionChange);
|
||||
};
|
||||
}, [socket, dispatch]);
|
||||
|
||||
// Socket handler for new task creation
|
||||
useEffect(() => {
|
||||
if (!socket) return;
|
||||
|
||||
const handleNewTaskReceived = (data: IProjectTask) => {
|
||||
if (!data) return;
|
||||
|
||||
if (data.parent_task_id) {
|
||||
dispatch(updateSubTasks(data));
|
||||
}
|
||||
};
|
||||
|
||||
socket.on(SocketEvents.QUICK_TASK.toString(), handleNewTaskReceived);
|
||||
|
||||
return () => {
|
||||
socket.off(SocketEvents.QUICK_TASK.toString(), handleNewTaskReceived);
|
||||
};
|
||||
}, [socket, dispatch]);
|
||||
|
||||
const handleDragStart = useCallback(({ active }: DragStartEvent) => {
|
||||
setActiveId(active.id as string);
|
||||
|
||||
// Add smooth transition to the dragged item
|
||||
const draggedElement = document.querySelector(`[data-id="${active.id}"]`);
|
||||
if (draggedElement) {
|
||||
(draggedElement as HTMLElement).style.transition = 'transform 0.2s ease';
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
async ({ active, over }: DragEndEvent) => {
|
||||
setActiveId(null);
|
||||
if (!over) return;
|
||||
|
||||
const activeGroupId = active.data.current?.groupId;
|
||||
const overGroupId = over.data.current?.groupId;
|
||||
const activeTaskId = active.id;
|
||||
const overTaskId = over.id;
|
||||
|
||||
const sourceGroup = taskGroups.find(g => g.id === activeGroupId);
|
||||
const targetGroup = taskGroups.find(g => g.id === overGroupId);
|
||||
|
||||
if (!sourceGroup || !targetGroup) return;
|
||||
|
||||
const fromIndex = sourceGroup.tasks.findIndex(t => t.id === activeTaskId);
|
||||
if (fromIndex === -1) return;
|
||||
|
||||
// Create a deep clone of the task to avoid reference issues
|
||||
const task = JSON.parse(JSON.stringify(sourceGroup.tasks[fromIndex]));
|
||||
|
||||
// Check if task dependencies allow the move
|
||||
if (activeGroupId !== overGroupId) {
|
||||
const canContinue = await checkTaskDependencyStatus(task.id, overGroupId);
|
||||
if (!canContinue) {
|
||||
alertService.error(
|
||||
'Task is not completed',
|
||||
'Please complete the task dependencies before proceeding'
|
||||
);
|
||||
resetTaskRowStyles();
|
||||
return;
|
||||
}
|
||||
|
||||
// Update task properties based on target group
|
||||
switch (groupBy) {
|
||||
case IGroupBy.STATUS:
|
||||
task.status = overGroupId;
|
||||
task.status_color = targetGroup.color_code;
|
||||
task.status_color_dark = targetGroup.color_code_dark;
|
||||
break;
|
||||
case IGroupBy.PRIORITY:
|
||||
task.priority = overGroupId;
|
||||
task.priority_color = targetGroup.color_code;
|
||||
task.priority_color_dark = targetGroup.color_code_dark;
|
||||
break;
|
||||
case IGroupBy.PHASE:
|
||||
// Check if ALPHA_CHANNEL is already added
|
||||
const baseColor = targetGroup.color_code.endsWith(ALPHA_CHANNEL)
|
||||
? targetGroup.color_code.slice(0, -ALPHA_CHANNEL.length) // Remove ALPHA_CHANNEL
|
||||
: targetGroup.color_code; // Use as is if not present
|
||||
task.phase_id = overGroupId;
|
||||
task.phase_color = baseColor; // Set the cleaned color
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const isTargetGroupEmpty = targetGroup.tasks.length === 0;
|
||||
|
||||
// Calculate toIndex - for empty groups, always add at index 0
|
||||
const toIndex = isTargetGroupEmpty
|
||||
? 0
|
||||
: overTaskId
|
||||
? targetGroup.tasks.findIndex(t => t.id === overTaskId)
|
||||
: targetGroup.tasks.length;
|
||||
|
||||
// Calculate toPos similar to Angular implementation
|
||||
const toPos = isTargetGroupEmpty
|
||||
? -1
|
||||
: targetGroup.tasks[toIndex]?.sort_order ||
|
||||
targetGroup.tasks[targetGroup.tasks.length - 1]?.sort_order ||
|
||||
-1;
|
||||
|
||||
// Update Redux state
|
||||
if (activeGroupId === overGroupId) {
|
||||
// Same group - move within array
|
||||
const updatedTasks = [...sourceGroup.tasks];
|
||||
updatedTasks.splice(fromIndex, 1);
|
||||
updatedTasks.splice(toIndex, 0, task);
|
||||
|
||||
dispatch({
|
||||
type: 'taskReducer/reorderTasks',
|
||||
payload: {
|
||||
activeGroupId,
|
||||
overGroupId,
|
||||
fromIndex,
|
||||
toIndex,
|
||||
task,
|
||||
updatedSourceTasks: updatedTasks,
|
||||
updatedTargetTasks: updatedTasks,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Different groups - transfer between arrays
|
||||
const updatedSourceTasks = sourceGroup.tasks.filter((_, i) => i !== fromIndex);
|
||||
const updatedTargetTasks = [...targetGroup.tasks];
|
||||
|
||||
if (isTargetGroupEmpty) {
|
||||
updatedTargetTasks.push(task);
|
||||
} else if (toIndex >= 0 && toIndex <= updatedTargetTasks.length) {
|
||||
updatedTargetTasks.splice(toIndex, 0, task);
|
||||
} else {
|
||||
updatedTargetTasks.push(task);
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: 'taskReducer/reorderTasks',
|
||||
payload: {
|
||||
activeGroupId,
|
||||
overGroupId,
|
||||
fromIndex,
|
||||
toIndex,
|
||||
task,
|
||||
updatedSourceTasks,
|
||||
updatedTargetTasks,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Emit socket event
|
||||
socket?.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), {
|
||||
project_id: projectId,
|
||||
from_index: sourceGroup.tasks[fromIndex].sort_order,
|
||||
to_index: toPos,
|
||||
to_last_index: isTargetGroupEmpty,
|
||||
from_group: sourceGroup.id,
|
||||
to_group: targetGroup.id,
|
||||
group_by: groupBy,
|
||||
task: sourceGroup.tasks[fromIndex], // Send original task to maintain references
|
||||
team_id: currentSession?.team_id,
|
||||
});
|
||||
|
||||
// Reset styles
|
||||
setTimeout(resetTaskRowStyles, 0);
|
||||
|
||||
trackMixpanelEvent(evt_project_task_list_drag_and_move);
|
||||
},
|
||||
[
|
||||
taskGroups,
|
||||
groupBy,
|
||||
projectId,
|
||||
currentSession?.team_id,
|
||||
dispatch,
|
||||
socket,
|
||||
resetTaskRowStyles,
|
||||
trackMixpanelEvent,
|
||||
]
|
||||
);
|
||||
|
||||
const handleDragOver = useCallback(
|
||||
({ active, over }: DragEndEvent) => {
|
||||
if (!over) return;
|
||||
|
||||
const activeGroupId = active.data.current?.groupId;
|
||||
const overGroupId = over.data.current?.groupId;
|
||||
const activeTaskId = active.id;
|
||||
const overTaskId = over.id;
|
||||
|
||||
const sourceGroup = taskGroups.find(g => g.id === activeGroupId);
|
||||
const targetGroup = taskGroups.find(g => g.id === overGroupId);
|
||||
|
||||
if (!sourceGroup || !targetGroup) return;
|
||||
|
||||
const fromIndex = sourceGroup.tasks.findIndex(t => t.id === activeTaskId);
|
||||
const toIndex = targetGroup.tasks.findIndex(t => t.id === overTaskId);
|
||||
|
||||
if (fromIndex === -1 || toIndex === -1) return;
|
||||
|
||||
// Create a deep clone of the task to avoid reference issues
|
||||
const task = JSON.parse(JSON.stringify(sourceGroup.tasks[fromIndex]));
|
||||
|
||||
// Update Redux state
|
||||
if (activeGroupId === overGroupId) {
|
||||
// Same group - move within array
|
||||
const updatedTasks = [...sourceGroup.tasks];
|
||||
updatedTasks.splice(fromIndex, 1);
|
||||
updatedTasks.splice(toIndex, 0, task);
|
||||
|
||||
dispatch({
|
||||
type: 'taskReducer/reorderTasks',
|
||||
payload: {
|
||||
activeGroupId,
|
||||
overGroupId,
|
||||
fromIndex,
|
||||
toIndex,
|
||||
task,
|
||||
updatedSourceTasks: updatedTasks,
|
||||
updatedTargetTasks: updatedTasks,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Different groups - transfer between arrays
|
||||
const updatedSourceTasks = sourceGroup.tasks.filter((_, i) => i !== fromIndex);
|
||||
const updatedTargetTasks = [...targetGroup.tasks];
|
||||
|
||||
updatedTargetTasks.splice(toIndex, 0, task);
|
||||
|
||||
dispatch({
|
||||
type: 'taskReducer/reorderTasks',
|
||||
payload: {
|
||||
activeGroupId,
|
||||
overGroupId,
|
||||
fromIndex,
|
||||
toIndex,
|
||||
task,
|
||||
updatedSourceTasks,
|
||||
updatedTargetTasks,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
[taskGroups, dispatch]
|
||||
);
|
||||
|
||||
// Add CSS styles for drag and drop animations
|
||||
useIsomorphicLayoutEffect(() => {
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
.task-row {
|
||||
opacity: 1 !important;
|
||||
position: relative !important;
|
||||
z-index: auto !important;
|
||||
transform: none !important;
|
||||
transition: transform 0.2s ease, opacity 0.2s ease !important;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
.task-row[data-is-dragging="true"] {
|
||||
z-index: 100 !important;
|
||||
transition: none !important;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
|
||||
return () => {
|
||||
document.head.removeChild(style);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Handle animation cleanup after drag ends
|
||||
useIsomorphicLayoutEffect(() => {
|
||||
if (activeId === null) {
|
||||
// Final cleanup after React updates DOM
|
||||
const timeoutId = setTimeout(resetTaskRowStyles, 50);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}
|
||||
}, [activeId, resetTaskRowStyles]);
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={pointerWithin}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={handleDragOver}
|
||||
>
|
||||
<Flex gap={24} vertical>
|
||||
{taskGroups?.map(taskGroup => (
|
||||
<TaskListTableWrapper
|
||||
key={taskGroup.id}
|
||||
taskList={taskGroup.tasks}
|
||||
tableId={taskGroup.id}
|
||||
name={taskGroup.name}
|
||||
groupBy={groupBy}
|
||||
statusCategory={taskGroup.category_id}
|
||||
color={themeMode === 'dark' ? taskGroup.color_code_dark : taskGroup.color_code}
|
||||
activeId={activeId}
|
||||
/>
|
||||
))}
|
||||
|
||||
{createPortal(<TaskListBulkActionsBar />, document.body, 'bulk-action-container')}
|
||||
|
||||
{createPortal(
|
||||
<TaskTemplateDrawer showDrawer={false} selectedTemplateId="" onClose={() => {}} />,
|
||||
document.body,
|
||||
'task-template-drawer'
|
||||
)}
|
||||
</Flex>
|
||||
</DndContext>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskGroupWrapper;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user