Add task progress tracking methods and enhance UI components

- Introduced a comprehensive guide for users on task progress tracking methods, including manual, weighted, and time-based progress.
- Implemented backend support for progress calculations, including SQL functions and migrations to accommodate new progress features.
- Enhanced frontend components to support progress input and display, including updates to task and project drawers.
- Added localization for new progress-related terms and validation messages.
- Integrated real-time updates for task progress and weight changes through socket events.
This commit is contained in:
chamiakJ
2025-04-30 15:24:07 +05:30
parent a2bfdb682b
commit 6128c64c31
24 changed files with 1466 additions and 150 deletions

View File

@@ -38,6 +38,8 @@
"createClient": "Create client",
"searchInputPlaceholder": "Search by name or email",
"hoursPerDayValidationMessage": "Hours per day must be a number between 1 and 24",
"workingDaysValidationMessage": "Working days must be a positive number",
"manDaysValidationMessage": "Man days must be a positive number",
"noPermission": "No permission",
"progressSettings": "Progress Settings",
"manualProgress": "Manual Progress",

View File

@@ -38,6 +38,8 @@
"createClient": "Crear cliente",
"searchInputPlaceholder": "Busca por nombre o email",
"hoursPerDayValidationMessage": "Las horas por día deben ser un número entre 1 y 24",
"workingDaysValidationMessage": "Los días de trabajo deben ser un número positivo",
"manDaysValidationMessage": "Los días hombre deben ser un número positivo",
"noPermission": "Sin permiso",
"progressSettings": "Configuración de Progreso",
"manualProgress": "Progreso Manual",

View File

@@ -38,6 +38,8 @@
"createClient": "Criar cliente",
"searchInputPlaceholder": "Pesquise por nome ou email",
"hoursPerDayValidationMessage": "As horas por dia devem ser um número entre 1 e 24",
"workingDaysValidationMessage": "Os dias de trabalho devem ser um número positivo",
"manDaysValidationMessage": "Os dias de homem devem ser um número positivo",
"noPermission": "Sem permissão",
"progressSettings": "Configurações de Progresso",
"manualProgress": "Progresso Manual",

View File

@@ -47,7 +47,11 @@ import { ITeamMemberViewModel } from '@/types/teamMembers/teamMembersGetResponse
import { calculateTimeDifference } from '@/utils/calculate-time-difference';
import { formatDateTimeWithLocale } from '@/utils/format-date-time-with-locale';
import logger from '@/utils/errorLogger';
import { setProjectData, toggleProjectDrawer, setProjectId as setDrawerProjectId } from '@/features/project/project-drawer.slice';
import {
setProjectData,
toggleProjectDrawer,
setProjectId as setDrawerProjectId,
} from '@/features/project/project-drawer.slice';
import useIsProjectManager from '@/hooks/useIsProjectManager';
import { useAuthService } from '@/hooks/useAuth';
import { evt_projects_create } from '@/shared/worklenz-analytics-events';
@@ -61,7 +65,7 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => {
const [form] = Form.useForm();
const [loading, setLoading] = useState<boolean>(true);
const currentSession = useAuthService().getCurrentSession();
// State
const [editMode, setEditMode] = useState<boolean>(false);
const [selectedProjectManager, setSelectedProjectManager] = useState<ITeamMemberViewModel | null>(
@@ -176,7 +180,9 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => {
dispatch(toggleProjectDrawer());
if (!editMode) {
trackMixpanelEvent(evt_projects_create);
navigate(`/worklenz/projects/${response.data.body.id}?tab=tasks-list&pinned_tab=tasks-list`);
navigate(
`/worklenz/projects/${response.data.body.id}?tab=tasks-list&pinned_tab=tasks-list`
);
}
refetchProjects();
window.location.reload(); // Refresh the page
@@ -191,8 +197,17 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => {
logger.error('Error saving project', error);
}
};
const calculateWorkingDays = (startDate: dayjs.Dayjs | null, endDate: dayjs.Dayjs | null): number => {
if (!startDate || !endDate || !startDate.isValid() || !endDate.isValid() || startDate.isAfter(endDate)) {
const calculateWorkingDays = (
startDate: dayjs.Dayjs | null,
endDate: dayjs.Dayjs | null
): number => {
if (
!startDate ||
!endDate ||
!startDate.isValid() ||
!endDate.isValid() ||
startDate.isAfter(endDate)
) {
return 0;
}
@@ -220,7 +235,13 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => {
...project,
start_date: project.start_date ? dayjs(project.start_date) : null,
end_date: project.end_date ? dayjs(project.end_date) : null,
working_days: form.getFieldValue('start_date') && form.getFieldValue('end_date') ? calculateWorkingDays(form.getFieldValue('start_date'), form.getFieldValue('end_date')) : project.working_days || 0,
working_days:
form.getFieldValue('start_date') && form.getFieldValue('end_date')
? calculateWorkingDays(
form.getFieldValue('start_date'),
form.getFieldValue('end_date')
)
: project.working_days || 0,
use_manual_progress: project.use_manual_progress || false,
use_weighted_progress: project.use_weighted_progress || false,
use_time_progress: project.use_time_progress || false,
@@ -382,12 +403,7 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => {
}
>
{!isEditable && (
<Alert
message={t('noPermission')}
type="warning"
showIcon
style={{ marginBottom: 16 }}
/>
<Alert message={t('noPermission')} type="warning" showIcon style={{ marginBottom: 16 }} />
)}
<Skeleton active paragraph={{ rows: 12 }} loading={projectLoading}>
<Form
@@ -448,14 +464,11 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => {
<Form.Item name="date" layout="horizontal">
<Flex gap={8}>
<Form.Item
name="start_date"
label={t('startDate')}
>
<Form.Item name="start_date" label={t('startDate')}>
<DatePicker
disabledDate={disabledStartDate}
disabled={!isProjectManager && !isOwnerorAdmin}
onChange={(date) => {
onChange={date => {
const endDate = form.getFieldValue('end_date');
if (date && endDate) {
const days = calculateWorkingDays(date, endDate);
@@ -464,14 +477,11 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => {
}}
/>
</Form.Item>
<Form.Item
name="end_date"
label={t('endDate')}
>
<Form.Item name="end_date" label={t('endDate')}>
<DatePicker
disabledDate={disabledEndDate}
disabled={!isProjectManager && !isOwnerorAdmin}
onChange={(date) => {
onChange={date => {
const startDate = form.getFieldValue('start_date');
if (startDate && date) {
const days = calculateWorkingDays(startDate, date);
@@ -483,12 +493,48 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => {
</Flex>
</Form.Item>
<Form.Item name="working_days" label={t('estimateWorkingDays')}>
<Input type="number" disabled={!isProjectManager && !isOwnerorAdmin} />
<Form.Item
name="working_days"
label={t('estimateWorkingDays')}
rules={[
{
validator: (_, value) => {
if (value === undefined || value >= 0) {
return Promise.resolve();
}
return Promise.reject(new Error(t('workingDaysValidationMessage', { min: 0 })));
},
},
]}
>
<Input type="number" min={0} disabled={!isProjectManager && !isOwnerorAdmin} />
</Form.Item>
<Form.Item name="man_days" label={t('estimateManDays')}>
<Input type="number" disabled={!isProjectManager && !isOwnerorAdmin} />
<Form.Item
name="man_days"
label={t('estimateManDays')}
rules={[
{
validator: (_, value) => {
if (value === undefined || value >= 0) {
return Promise.resolve();
}
return Promise.reject(new Error(t('manDaysValidationMessage', { min: 0 })));
},
},
]}
>
<Input
type="number"
min={0}
disabled={!isProjectManager && !isOwnerorAdmin}
onBlur={e => {
const value = parseInt(e.target.value, 10);
if (value < 0) {
form.setFieldsValue({ man_days: 0 });
}
}}
/>
</Form.Item>
<Form.Item
@@ -500,16 +546,28 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => {
if (value === undefined || (value >= 0 && value <= 24)) {
return Promise.resolve();
}
return Promise.reject(new Error(t('hoursPerDayValidationMessage', { min: 0, max: 24 })));
return Promise.reject(
new Error(t('hoursPerDayValidationMessage', { min: 0, max: 24 }))
);
},
},
]}
>
<Input type="number" disabled={!isProjectManager && !isOwnerorAdmin} />
<Input
type="number"
min={0}
disabled={!isProjectManager && !isOwnerorAdmin}
onBlur={e => {
const value = parseInt(e.target.value, 10);
if (value < 0) {
form.setFieldsValue({ hours_per_day: 8 });
}
}}
/>
</Form.Item>
<Divider orientation="left">{t('progressSettings')}</Divider>
<Form.Item
name="use_manual_progress"
label={
@@ -522,7 +580,7 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => {
}
valuePropName="checked"
>
<Switch
<Switch
onChange={handleManualProgressChange}
disabled={!isProjectManager && !isOwnerorAdmin}
/>
@@ -540,7 +598,7 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => {
}
valuePropName="checked"
>
<Switch
<Switch
onChange={handleWeightedProgressChange}
disabled={!isProjectManager && !isOwnerorAdmin}
/>
@@ -558,7 +616,7 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => {
}
valuePropName="checked"
>
<Switch
<Switch
onChange={handleTimeProgressChange}
disabled={!isProjectManager && !isOwnerorAdmin}
/>

View File

@@ -111,6 +111,32 @@ const TaskDrawerActivityLog = () => {
</Tag>
</Flex>
);
case IActivityLogAttributeTypes.PROGRESS:
return (
<Flex gap={4} align="center">
<Tag color="blue">
{activity.previous || '0'}%
</Tag>
<ArrowRightOutlined />&nbsp;
<Tag color="blue">
{activity.current || '0'}%
</Tag>
</Flex>
);
case IActivityLogAttributeTypes.WEIGHT:
return (
<Flex gap={4} align="center">
<Tag color="purple">
Weight: {activity.previous || '100'}
</Tag>
<ArrowRightOutlined />&nbsp;
<Tag color="purple">
Weight: {activity.current || '100'}
</Tag>
</Flex>
);
default:
return (

View File

@@ -21,13 +21,16 @@ const TaskDrawerProgress = ({ task, form }: TaskDrawerProgressProps) => {
const isSubTask = !!task?.parent_task_id;
const hasSubTasks = task?.sub_tasks_count > 0;
// Determine which progress input to show based on project settings
const showManualProgressInput = project?.use_manual_progress && !hasSubTasks && !isSubTask;
// Show manual progress input only for tasks without subtasks (not parent tasks)
// Parent tasks get their progress calculated from subtasks
const showManualProgressInput = !hasSubTasks;
// Only show weight input for subtasks in weighted progress mode
const showTaskWeightInput = project?.use_weighted_progress && isSubTask;
useEffect(() => {
// Listen for progress updates from the server
socket?.on(SocketEvents.TASK_PROGRESS_UPDATED.toString(), (data) => {
const handleProgressUpdate = (data: any) => {
if (data.task_id === task.id) {
if (data.progress_value !== undefined) {
form.setFieldsValue({ progress_value: data.progress_value });
@@ -36,34 +39,74 @@ const TaskDrawerProgress = ({ task, form }: TaskDrawerProgressProps) => {
form.setFieldsValue({ weight: data.weight });
}
}
});
};
socket?.on(SocketEvents.TASK_PROGRESS_UPDATED.toString(), handleProgressUpdate);
// When the component mounts, explicitly request the latest progress for this task
if (connected && task.id) {
socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.id);
}
return () => {
socket?.off(SocketEvents.TASK_PROGRESS_UPDATED.toString());
socket?.off(SocketEvents.TASK_PROGRESS_UPDATED.toString(), handleProgressUpdate);
};
}, [socket, task.id, form]);
}, [socket, connected, task.id, form]);
const handleProgressChange = (value: number | null) => {
if (connected && task.id && value !== null) {
socket?.emit(SocketEvents.UPDATE_TASK_PROGRESS.toString(), JSON.stringify({
task_id: task.id,
progress_value: value,
parent_task_id: task.parent_task_id
}));
// Ensure parent_task_id is not undefined
const parent_task_id = task.parent_task_id || null;
socket?.emit(
SocketEvents.UPDATE_TASK_PROGRESS.toString(),
JSON.stringify({
task_id: task.id,
progress_value: value,
parent_task_id: parent_task_id,
})
);
// If this task has subtasks, request recalculation of its progress
if (hasSubTasks) {
setTimeout(() => {
socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.id);
}, 100);
}
// If this is a subtask, request the parent's progress to be updated in UI
if (parent_task_id) {
setTimeout(() => {
socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), parent_task_id);
}, 100);
}
}
};
const handleWeightChange = (value: number | null) => {
if (connected && task.id && value !== null) {
socket?.emit(SocketEvents.UPDATE_TASK_WEIGHT.toString(), JSON.stringify({
task_id: task.id,
weight: value,
parent_task_id: task.parent_task_id
}));
// Ensure parent_task_id is not undefined
const parent_task_id = task.parent_task_id || null;
socket?.emit(
SocketEvents.UPDATE_TASK_WEIGHT.toString(),
JSON.stringify({
task_id: task.id,
weight: value,
parent_task_id: parent_task_id,
})
);
// If this is a subtask, request the parent's progress to be updated in UI
if (parent_task_id) {
setTimeout(() => {
socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), parent_task_id);
}, 100);
}
}
};
const percentFormatter = (value: number | undefined) => value ? `${value}%` : '0%';
const percentFormatter = (value: number | undefined) => (value ? `${value}%` : '0%');
const percentParser = (value: string | undefined) => {
const parsed = parseInt(value?.replace('%', '') || '0', 10);
return isNaN(parsed) ? 0 : parsed;
@@ -75,43 +118,6 @@ const TaskDrawerProgress = ({ task, form }: TaskDrawerProgressProps) => {
return (
<>
{showManualProgressInput && (
<Form.Item
name="progress_value"
label={
<Flex align="center" gap={4}>
{t('taskInfoTab.details.progressValue')}
<Tooltip title={t('taskInfoTab.details.progressValueTooltip')}>
<QuestionCircleOutlined />
</Tooltip>
</Flex>
}
rules={[
{
required: true,
message: t('taskInfoTab.details.progressValueRequired'),
},
{
type: 'number',
min: 0,
max: 100,
message: t('taskInfoTab.details.progressValueRange'),
},
]}
>
<InputNumber
min={0}
max={100}
formatter={percentFormatter}
parser={percentParser}
onBlur={(e) => {
const value = percentParser(e.target.value);
handleProgressChange(value);
}}
/>
</Form.Item>
)}
{showTaskWeightInput && (
<Form.Item
name="weight"
@@ -124,10 +130,6 @@ const TaskDrawerProgress = ({ task, form }: TaskDrawerProgressProps) => {
</Flex>
}
rules={[
{
required: true,
message: t('taskInfoTab.details.taskWeightRequired'),
},
{
type: 'number',
min: 0,
@@ -141,15 +143,47 @@ const TaskDrawerProgress = ({ task, form }: TaskDrawerProgressProps) => {
max={100}
formatter={percentFormatter}
parser={percentParser}
onBlur={(e) => {
onBlur={e => {
const value = percentParser(e.target.value);
handleWeightChange(value);
}}
/>
</Form.Item>
)}
{showManualProgressInput && (
<Form.Item
name="progress_value"
label={
<Flex align="center" gap={4}>
{t('taskInfoTab.details.progressValue')}
<Tooltip title={t('taskInfoTab.details.progressValueTooltip')}>
<QuestionCircleOutlined />
</Tooltip>
</Flex>
}
rules={[
{
type: 'number',
min: 0,
max: 100,
message: t('taskInfoTab.details.progressValueRange'),
},
]}
>
<InputNumber
min={0}
max={100}
formatter={percentFormatter}
parser={percentParser}
onBlur={e => {
const value = percentParser(e.target.value);
handleProgressChange(value);
}}
/>
</Form.Item>
)}
</>
);
};
export default TaskDrawerProgress;
export default TaskDrawerProgress;

View File

@@ -210,10 +210,11 @@ const SubTaskTable = ({ subTasks, loadingSubTasks, refreshSubTasks, t }: SubTask
icon={<ExclamationCircleFilled style={{ color: colors.vibrantOrange }} />}
okText="Yes"
cancelText="No"
onConfirm={() => handleDeleteSubTask(record.id)}
onPopupClick={(e) => e.stopPropagation()}
onConfirm={(e) => {handleDeleteSubTask(record.id)}}
>
<Tooltip title="Delete">
<Button shape="default" icon={<DeleteOutlined />} size="small" />
<Button shape="default" icon={<DeleteOutlined />} size="small" onClick={(e)=> e.stopPropagation()} />
</Tooltip>
</Popconfirm>
</Flex>

View File

@@ -125,7 +125,7 @@ const TaskDrawerInfoTab = ({ t }: TaskDrawerInfoTabProps) => {
<Button
shape="circle"
icon={<ReloadOutlined spin={loadingSubTasks} />}
onClick={(e) => {
onClick={e => {
e.stopPropagation(); // Prevent click from bubbling up
fetchSubTasks();
}}
@@ -182,19 +182,15 @@ const TaskDrawerInfoTab = ({ t }: TaskDrawerInfoTabProps) => {
label: <Typography.Text strong>{t('taskInfoTab.comments.title')}</Typography.Text>,
style: panelStyle,
className: 'custom-task-drawer-info-collapse',
children: (
<TaskComments
taskId={selectedTaskId || ''}
t={t}
/>
),
children: <TaskComments taskId={selectedTaskId || ''} t={t} />,
},
];
// Filter out the 'subTasks' item if this task is a subtask
const infoItems = taskFormViewModel?.task?.parent_task_id
? allInfoItems.filter(item => item.key !== 'subTasks')
: allInfoItems;
// Filter out the 'subTasks' item if this task is more than level 2
const infoItems =
(taskFormViewModel?.task?.task_level ?? 0) >= 2
? allInfoItems.filter(item => item.key !== 'subTasks')
: allInfoItems;
const fetchSubTasks = async () => {
if (!selectedTaskId || loadingSubTasks) return;
@@ -281,7 +277,7 @@ const TaskDrawerInfoTab = ({ t }: TaskDrawerInfoTabProps) => {
defaultActiveKey={[
'details',
'description',
...(taskFormViewModel?.task?.parent_task_id ? [] : ['subTasks']),
'subTasks',
'dependencies',
'attachments',
'comments',

View File

@@ -32,7 +32,7 @@ const TaskDrawer = () => {
const [refreshTimeLogTrigger, setRefreshTimeLogTrigger] = useState(0);
const { showTaskDrawer, timeLogEditing } = useAppSelector(state => state.taskDrawerReducer);
const { taskFormViewModel, selectedTaskId } = useAppSelector(state => state.taskDrawerReducer);
const taskNameInputRef = useRef<InputRef>(null);
const isClosingManually = useRef(false);
@@ -47,20 +47,32 @@ const TaskDrawer = () => {
const dispatch = useAppDispatch();
const handleOnClose = () => {
// Set flag to indicate we're manually closing the drawer
isClosingManually.current = true;
setActiveTab('info');
// Explicitly clear the task parameter from URL
clearTaskFromUrl();
// Update the Redux state
const resetTaskState = () => {
dispatch(setShowTaskDrawer(false));
dispatch(setSelectedTaskId(null));
dispatch(setTaskFormViewModel({}));
dispatch(setTaskSubscribers([]));
};
const handleOnClose = (
e?: React.MouseEvent<Element, MouseEvent> | React.KeyboardEvent<Element>
) => {
// Set flag to indicate we're manually closing the drawer
isClosingManually.current = true;
setActiveTab('info');
clearTaskFromUrl();
const isClickOutsideDrawer =
e?.target && (e.target as HTMLElement).classList.contains('ant-drawer-mask');
if (isClickOutsideDrawer || !taskFormViewModel?.task?.is_sub_task) {
resetTaskState();
} else {
dispatch(setSelectedTaskId(null));
dispatch(setTaskFormViewModel({}));
dispatch(setTaskSubscribers([]));
dispatch(setSelectedTaskId(taskFormViewModel?.task?.parent_task_id || null));
}
// Reset the flag after a short delay
setTimeout(() => {
isClosingManually.current = false;
@@ -176,8 +188,8 @@ const TaskDrawer = () => {
// Get conditional body style
const getBodyStyle = () => {
const baseStyle = {
padding: '24px',
overflow: 'auto'
padding: '24px',
overflow: 'auto',
};
if (activeTab === 'timeLog' && timeLogEditing.isEditing) {

View File

@@ -22,7 +22,7 @@ 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 { setProject, setImportTaskTemplateDrawerOpen, setRefreshTimestamp, getProject } 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';
@@ -70,6 +70,7 @@ const ProjectViewHeader = () => {
const handleRefresh = () => {
if (!projectId) return;
dispatch(getProject(projectId));
switch (tab) {
case 'tasks-list':
dispatch(fetchTaskListColumns(projectId));

View File

@@ -86,7 +86,7 @@ const TaskListTaskCell = ({
isSubTask: boolean,
subTasksCount: number
) => {
if (subTasksCount > 0) {
if (subTasksCount > 0 && !isSubTask) {
return (
<button
onClick={() => handleToggleExpansion(taskId)}
@@ -112,23 +112,21 @@ const TaskListTaskCell = ({
const renderSubtasksCountLabel = (taskId: string, isSubTask: boolean, subTasksCount: number) => {
if (!taskId) return null;
return (
!isSubTask && (
<Button
onClick={() => handleToggleExpansion(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>
)
<Button
onClick={() => handleToggleExpansion(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>
);
};

View File

@@ -31,6 +31,8 @@ export enum IActivityLogAttributeTypes {
ATTACHMENT = 'attachment',
COMMENT = 'comment',
ARCHIVE = 'archive',
PROGRESS = 'progress',
WEIGHT = 'weight',
}
export interface IActivityLog {

View File

@@ -63,6 +63,7 @@ export interface ITaskViewModel extends ITask {
task_labels?: ITaskLabel[];
timer_start_time?: number;
recurring?: boolean;
task_level?: number;
}
export interface ITaskTeamMember extends ITeamMember {