Implement manual and weighted progress features for tasks

- Added SQL migrations to support manual progress and weighted progress calculations in tasks.
- Updated the `get_task_complete_ratio` function to consider manual progress and subtask weights.
- Enhanced the project model to include flags for manual, weighted, and time-based progress.
- Integrated new progress settings in the project drawer and task drawer components.
- Implemented socket events for real-time updates on task progress and weight changes.
- Updated frontend localization files to include new progress-related terms and tooltips.
This commit is contained in:
chamikaJ
2025-04-29 17:04:36 +05:30
parent a50ef47a52
commit f7582173ed
24 changed files with 1230 additions and 68 deletions

View File

@@ -14,6 +14,7 @@ import {
Popconfirm,
Skeleton,
Space,
Switch,
Tooltip,
Typography,
} from 'antd';
@@ -96,6 +97,9 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => {
working_days: project?.working_days || 0,
man_days: project?.man_days || 0,
hours_per_day: project?.hours_per_day || 8,
use_manual_progress: project?.use_manual_progress || false,
use_weighted_progress: project?.use_weighted_progress || false,
use_time_progress: project?.use_time_progress || false,
}),
[project, projectStatuses, projectHealths]
);
@@ -155,6 +159,9 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => {
man_days: parseInt(values.man_days),
hours_per_day: parseInt(values.hours_per_day),
project_manager: selectedProjectManager,
use_manual_progress: values.use_manual_progress || false,
use_weighted_progress: values.use_weighted_progress || false,
use_time_progress: values.use_time_progress || false,
};
const action =
@@ -214,6 +221,9 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => {
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,
use_manual_progress: project.use_manual_progress || false,
use_weighted_progress: project.use_weighted_progress || false,
use_time_progress: project.use_time_progress || false,
});
setSelectedProjectManager(project.project_manager || null);
setLoading(false);
@@ -284,6 +294,49 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => {
setIsFormValid(isValid);
};
// Progress calculation method handlers
const handleManualProgressChange = (checked: boolean) => {
if (checked) {
form.setFieldsValue({
use_manual_progress: true,
use_weighted_progress: false,
use_time_progress: false,
});
} else {
form.setFieldsValue({
use_manual_progress: false,
});
}
};
const handleWeightedProgressChange = (checked: boolean) => {
if (checked) {
form.setFieldsValue({
use_manual_progress: false,
use_weighted_progress: true,
use_time_progress: false,
});
} else {
form.setFieldsValue({
use_weighted_progress: false,
});
}
};
const handleTimeProgressChange = (checked: boolean) => {
if (checked) {
form.setFieldsValue({
use_manual_progress: false,
use_weighted_progress: false,
use_time_progress: true,
});
} else {
form.setFieldsValue({
use_time_progress: false,
});
}
};
return (
<Drawer
// loading={loading}
@@ -429,22 +482,15 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => {
</Form.Item>
</Flex>
</Form.Item>
{/* <Form.Item
name="working_days"
label={t('estimateWorkingDays')}
>
<Input
type="number"
disabled // Make it read-only since it's calculated
/>
</Form.Item> */}
<Form.Item name="working_days" label={t('estimateWorkingDays')}>
<Input type="number" disabled={!isProjectManager && !isOwnerorAdmin} />
</Form.Item>
<Form.Item name="man_days" label={t('estimateManDays')}>
<Input type="number" disabled={!isProjectManager && !isOwnerorAdmin} />
</Form.Item>
<Form.Item
name="hours_per_day"
label={t('hoursPerDay')}
@@ -461,6 +507,62 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => {
>
<Input type="number" disabled={!isProjectManager && !isOwnerorAdmin} />
</Form.Item>
<Divider orientation="left">{t('progressSettings')}</Divider>
<Form.Item
name="use_manual_progress"
label={
<Space>
<Typography.Text>{t('manualProgress')}</Typography.Text>
<Tooltip title={t('manualProgressTooltip')}>
<Button type="text" size="small" icon={<Typography.Text></Typography.Text>} />
</Tooltip>
</Space>
}
valuePropName="checked"
>
<Switch
onChange={handleManualProgressChange}
disabled={!isProjectManager && !isOwnerorAdmin}
/>
</Form.Item>
<Form.Item
name="use_weighted_progress"
label={
<Space>
<Typography.Text>{t('weightedProgress')}</Typography.Text>
<Tooltip title={t('weightedProgressTooltip')}>
<Button type="text" size="small" icon={<Typography.Text></Typography.Text>} />
</Tooltip>
</Space>
}
valuePropName="checked"
>
<Switch
onChange={handleWeightedProgressChange}
disabled={!isProjectManager && !isOwnerorAdmin}
/>
</Form.Item>
<Form.Item
name="use_time_progress"
label={
<Space>
<Typography.Text>{t('timeProgress')}</Typography.Text>
<Tooltip title={t('timeProgressTooltip')}>
<Button type="text" size="small" icon={<Typography.Text></Typography.Text>} />
</Tooltip>
</Space>
}
valuePropName="checked"
>
<Switch
onChange={handleTimeProgressChange}
disabled={!isProjectManager && !isOwnerorAdmin}
/>
</Form.Item>
</Form>
{editMode && (

View File

@@ -0,0 +1,155 @@
import { Form, InputNumber, Tooltip } from 'antd';
import { useTranslation } from 'react-i18next';
import { QuestionCircleOutlined } from '@ant-design/icons';
import { useAppSelector } from '@/hooks/useAppSelector';
import { ITaskViewModel } from '@/types/tasks/task.types';
import Flex from 'antd/lib/flex';
import { SocketEvents } from '@/shared/socket-events';
import { useEffect } from 'react';
import { useSocket } from '@/socket/socketContext';
interface TaskDrawerProgressProps {
task: ITaskViewModel;
form: any;
}
const TaskDrawerProgress = ({ task, form }: TaskDrawerProgressProps) => {
const { t } = useTranslation('task-drawer/task-drawer');
const { project } = useAppSelector(state => state.projectReducer);
const { socket, connected } = useSocket();
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;
const showTaskWeightInput = project?.use_weighted_progress && isSubTask;
useEffect(() => {
// Listen for progress updates from the server
socket?.on(SocketEvents.TASK_PROGRESS_UPDATED.toString(), (data) => {
if (data.task_id === task.id) {
if (data.progress_value !== undefined) {
form.setFieldsValue({ progress_value: data.progress_value });
}
if (data.weight !== undefined) {
form.setFieldsValue({ weight: data.weight });
}
}
});
return () => {
socket?.off(SocketEvents.TASK_PROGRESS_UPDATED.toString());
};
}, [socket, 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
}));
}
};
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
}));
}
};
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;
};
if (!showManualProgressInput && !showTaskWeightInput) {
return null; // Don't show any progress inputs if not applicable
}
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"
label={
<Flex align="center" gap={4}>
{t('taskInfoTab.details.taskWeight')}
<Tooltip title={t('taskInfoTab.details.taskWeightTooltip')}>
<QuestionCircleOutlined />
</Tooltip>
</Flex>
}
rules={[
{
required: true,
message: t('taskInfoTab.details.taskWeightRequired'),
},
{
type: 'number',
min: 0,
max: 100,
message: t('taskInfoTab.details.taskWeightRange'),
},
]}
>
<InputNumber
min={0}
max={100}
formatter={percentFormatter}
parser={percentParser}
onBlur={(e) => {
const value = percentParser(e.target.value);
handleWeightChange(value);
}}
/>
</Form.Item>
)}
</>
);
};
export default TaskDrawerProgress;

View File

@@ -26,6 +26,8 @@ import TaskDrawerDueDate from './details/task-drawer-due-date/task-drawer-due-da
import TaskDrawerEstimation from './details/task-drawer-estimation/task-drawer-estimation';
import TaskDrawerPrioritySelector from './details/task-drawer-priority-selector/task-drawer-priority-selector';
import TaskDrawerBillable from './details/task-drawer-billable/task-drawer-billable';
import TaskDrawerProgress from './details/task-drawer-progress/task-drawer-progress';
import { useAppSelector } from '@/hooks/useAppSelector';
interface TaskDetailsFormProps {
taskFormViewModel?: ITaskFormViewModel | null;
@@ -34,6 +36,7 @@ interface TaskDetailsFormProps {
const TaskDetailsForm = ({ taskFormViewModel = null }: TaskDetailsFormProps) => {
const { t } = useTranslation('task-drawer/task-drawer');
const [form] = Form.useForm();
const { project } = useAppSelector(state => state.projectReducer);
useEffect(() => {
if (!taskFormViewModel) {
@@ -53,6 +56,8 @@ const TaskDetailsForm = ({ taskFormViewModel = null }: TaskDetailsFormProps) =>
labels: task?.labels || [],
billable: task?.billable || false,
notify: [],
progress_value: task?.progress_value || null,
weight: task?.weight || null,
});
}, [taskFormViewModel, form]);
@@ -89,6 +94,8 @@ const TaskDetailsForm = ({ taskFormViewModel = null }: TaskDetailsFormProps) =>
hours: 0,
minutes: 0,
billable: false,
progress_value: null,
weight: null,
}}
onFinish={handleSubmit}
>
@@ -103,7 +110,7 @@ const TaskDetailsForm = ({ taskFormViewModel = null }: TaskDetailsFormProps) =>
<Form.Item name="assignees" label={t('taskInfoTab.details.assignees')}>
<Flex gap={4} align="center">
<Avatars members={taskFormViewModel?.task?.names || []} />
<Avatars members={taskFormViewModel?.task?.assignee_names || []} />
<TaskDrawerAssigneeSelector
task={(taskFormViewModel?.task as ITaskViewModel) || null}
/>
@@ -114,6 +121,10 @@ const TaskDetailsForm = ({ taskFormViewModel = null }: TaskDetailsFormProps) =>
<TaskDrawerEstimation t={t} task={taskFormViewModel?.task as ITaskViewModel} form={form} />
{(project?.use_manual_progress || project?.use_weighted_progress) && (taskFormViewModel?.task) && (
<TaskDrawerProgress task={taskFormViewModel?.task as ITaskViewModel} form={form} />
)}
<Form.Item name="priority" label={t('taskInfoTab.details.priority')}>
<TaskDrawerPrioritySelector task={taskFormViewModel?.task as ITaskViewModel} />
</Form.Item>