This commit is contained in:
chamikaJ
2025-04-17 18:28:54 +05:30
parent f583291d8a
commit 8825b0410a
2837 changed files with 241385 additions and 127578 deletions

View 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;
}

View 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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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>
);
};

View File

@@ -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;

View File

@@ -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} />
// )
// })
];
};

View File

@@ -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 */
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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,
},
];

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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',
},
];

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -0,0 +1,7 @@
import React from 'react';
const ProjectViewWorkload = () => {
return <div>ProjectViewWorkload</div>;
};
export default ProjectViewWorkload;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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 />
&nbsp;
<Typography.Text>{t('assignToMe')}</Typography.Text>
</span>
),
key: '1',
onClick: handleAssignToMe,
disabled: updatingAssignToMe,
},
{
label: (
<span>
<InboxOutlined />
&nbsp;
<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 />
&nbsp;
{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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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