feat(project-finance): implement hierarchical task loading and subtasks retrieval
- Enhanced the project finance controller to support hierarchical loading of tasks, allowing for better aggregation of financial data from parent and subtasks. - Introduced a new endpoint to fetch subtasks along with their financial details, improving the granularity of task management. - Updated the frontend to handle subtasks, including UI adjustments for displaying subtasks and their associated financial data. - Added necessary Redux actions and state management for fetching and displaying subtasks in the finance table. - Improved user experience by providing tooltips and disabling time estimation for tasks with subtasks, ensuring clarity in task management.
This commit is contained in:
@@ -23,6 +23,7 @@
|
||||
"show-start-date": "Show Start Date",
|
||||
"hours": "Hours",
|
||||
"minutes": "Minutes",
|
||||
"time-estimation-disabled-tooltip": "Time estimation is disabled because this task has {{count}} subtasks. The estimation shown is the sum of all subtasks.",
|
||||
"progressValue": "Progress Value",
|
||||
"progressValueTooltip": "Set the progress percentage (0-100%)",
|
||||
"progressValueRequired": "Please enter a progress value",
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"show-start-date": "Mostrar fecha de inicio",
|
||||
"hours": "Horas",
|
||||
"minutes": "Minutos",
|
||||
"time-estimation-disabled-tooltip": "La estimación de tiempo está deshabilitada porque esta tarea tiene {{count}} subtareas. La estimación mostrada es la suma de todas las subtareas.",
|
||||
"progressValue": "Valor de Progreso",
|
||||
"progressValueTooltip": "Establecer el porcentaje de progreso (0-100%)",
|
||||
"progressValueRequired": "Por favor, introduce un valor de progreso",
|
||||
|
||||
@@ -23,7 +23,8 @@
|
||||
"show-start-date": "Mostrar data de início",
|
||||
"hours": "Horas",
|
||||
"minutes": "Minutos",
|
||||
"progressValue": "Valor de Progresso",
|
||||
"time-estimation-disabled-tooltip": "A estimativa de tempo está desabilitada porque esta tarefa tem {{count}} subtarefas. A estimativa mostrada é a soma de todas as subtarefas.",
|
||||
"progressValue": "Valor do Progresso",
|
||||
"progressValueTooltip": "Definir a porcentagem de progresso (0-100%)",
|
||||
"progressValueRequired": "Por favor, insira um valor de progresso",
|
||||
"progressValueRange": "O progresso deve estar entre 0 e 100",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { API_BASE_URL } from "@/shared/constants";
|
||||
import { IServerResponse } from "@/types/common.types";
|
||||
import apiClient from "../api-client";
|
||||
import { IProjectFinanceResponse, ITaskBreakdownResponse } from "@/types/project/project-finance.types";
|
||||
import { IProjectFinanceResponse, ITaskBreakdownResponse, IProjectFinanceTask } from "@/types/project/project-finance.types";
|
||||
|
||||
const rootUrl = `${API_BASE_URL}/project-finance`;
|
||||
|
||||
@@ -20,6 +20,16 @@ export const projectFinanceApiService = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getSubTasks: async (
|
||||
projectId: string,
|
||||
parentTaskId: string
|
||||
): Promise<IServerResponse<IProjectFinanceTask[]>> => {
|
||||
const response = await apiClient.get<IServerResponse<IProjectFinanceTask[]>>(
|
||||
`${rootUrl}/project/${projectId}/tasks/${parentTaskId}/subtasks`
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getTaskBreakdown: async (
|
||||
taskId: string
|
||||
): Promise<IServerResponse<ITaskBreakdownResponse>> => {
|
||||
|
||||
@@ -2,23 +2,41 @@ import { SocketEvents } from '@/shared/socket-events';
|
||||
import { useSocket } from '@/socket/socketContext';
|
||||
import { colors } from '@/styles/colors';
|
||||
import { ITaskViewModel } from '@/types/tasks/task.types';
|
||||
import { Flex, Form, FormInstance, InputNumber, Typography } from 'antd';
|
||||
import { Flex, Form, FormInstance, InputNumber, Typography, Tooltip } from 'antd';
|
||||
import { TFunction } from 'i18next';
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
interface TaskDrawerEstimationProps {
|
||||
t: TFunction;
|
||||
task: ITaskViewModel;
|
||||
form: FormInstance<any>;
|
||||
subTasksEstimation?: { hours: number; minutes: number }; // Sum of subtasks estimation
|
||||
}
|
||||
|
||||
const TaskDrawerEstimation = ({ t, task, form }: TaskDrawerEstimationProps) => {
|
||||
const TaskDrawerEstimation = ({ t, task, form, subTasksEstimation }: TaskDrawerEstimationProps) => {
|
||||
const { socket, connected } = useSocket();
|
||||
const [hours, setHours] = useState(0);
|
||||
const [minutes, setMinutes] = useState(0);
|
||||
|
||||
// Check if task has subtasks
|
||||
const hasSubTasks = (task?.sub_tasks_count || 0) > 0;
|
||||
|
||||
// Use subtasks estimation if available, otherwise use task's own estimation
|
||||
const displayHours = hasSubTasks && subTasksEstimation ? subTasksEstimation.hours : (task?.total_hours || 0);
|
||||
const displayMinutes = hasSubTasks && subTasksEstimation ? subTasksEstimation.minutes : (task?.total_minutes || 0);
|
||||
|
||||
useEffect(() => {
|
||||
// Update form values when subtasks estimation changes
|
||||
if (hasSubTasks && subTasksEstimation) {
|
||||
form.setFieldsValue({
|
||||
hours: subTasksEstimation.hours,
|
||||
minutes: subTasksEstimation.minutes
|
||||
});
|
||||
}
|
||||
}, [subTasksEstimation, hasSubTasks, form]);
|
||||
|
||||
const handleTimeEstimationBlur = (e: React.FocusEvent<HTMLInputElement>) => {
|
||||
if (!connected || !task.id) return;
|
||||
if (!connected || !task.id || hasSubTasks) return;
|
||||
|
||||
// Get current form values instead of using state
|
||||
const currentHours = form.getFieldValue('hours') || 0;
|
||||
@@ -35,48 +53,69 @@ const TaskDrawerEstimation = ({ t, task, form }: TaskDrawerEstimationProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
const tooltipTitle = hasSubTasks
|
||||
? t('taskInfoTab.details.time-estimation-disabled-tooltip', {
|
||||
count: task?.sub_tasks_count || 0,
|
||||
defaultValue: `Time estimation is disabled because this task has ${task?.sub_tasks_count || 0} subtasks. The estimation shown is the sum of all subtasks.`
|
||||
})
|
||||
: '';
|
||||
|
||||
return (
|
||||
<Form.Item name="timeEstimation" label={t('taskInfoTab.details.time-estimation')}>
|
||||
<Flex gap={8}>
|
||||
<Form.Item
|
||||
name={'hours'}
|
||||
label={
|
||||
<Typography.Text style={{ color: colors.lightGray, fontSize: 12 }}>
|
||||
{t('taskInfoTab.details.hours')}
|
||||
</Typography.Text>
|
||||
}
|
||||
style={{ marginBottom: 36 }}
|
||||
labelCol={{ style: { paddingBlock: 0 } }}
|
||||
layout="vertical"
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={24}
|
||||
placeholder={t('taskInfoTab.details.hours')}
|
||||
onBlur={handleTimeEstimationBlur}
|
||||
onChange={value => setHours(value || 0)}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={'minutes'}
|
||||
label={
|
||||
<Typography.Text style={{ color: colors.lightGray, fontSize: 12 }}>
|
||||
{t('taskInfoTab.details.minutes')}
|
||||
</Typography.Text>
|
||||
}
|
||||
style={{ marginBottom: 36 }}
|
||||
labelCol={{ style: { paddingBlock: 0 } }}
|
||||
layout="vertical"
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={60}
|
||||
placeholder={t('taskInfoTab.details.minutes')}
|
||||
onBlur={handleTimeEstimationBlur}
|
||||
onChange={value => setMinutes(value || 0)}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Flex>
|
||||
<Tooltip title={tooltipTitle} trigger={hasSubTasks ? 'hover' : []}>
|
||||
<Flex gap={8}>
|
||||
<Form.Item
|
||||
name={'hours'}
|
||||
label={
|
||||
<Typography.Text style={{ color: colors.lightGray, fontSize: 12 }}>
|
||||
{t('taskInfoTab.details.hours')}
|
||||
</Typography.Text>
|
||||
}
|
||||
style={{ marginBottom: 36 }}
|
||||
labelCol={{ style: { paddingBlock: 0 } }}
|
||||
layout="vertical"
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={24}
|
||||
placeholder={t('taskInfoTab.details.hours')}
|
||||
onBlur={handleTimeEstimationBlur}
|
||||
onChange={value => !hasSubTasks && setHours(value || 0)}
|
||||
disabled={hasSubTasks}
|
||||
value={displayHours}
|
||||
style={{
|
||||
cursor: hasSubTasks ? 'not-allowed' : 'default',
|
||||
opacity: hasSubTasks ? 0.6 : 1
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={'minutes'}
|
||||
label={
|
||||
<Typography.Text style={{ color: colors.lightGray, fontSize: 12 }}>
|
||||
{t('taskInfoTab.details.minutes')}
|
||||
</Typography.Text>
|
||||
}
|
||||
style={{ marginBottom: 36 }}
|
||||
labelCol={{ style: { paddingBlock: 0 } }}
|
||||
layout="vertical"
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={60}
|
||||
placeholder={t('taskInfoTab.details.minutes')}
|
||||
onBlur={handleTimeEstimationBlur}
|
||||
onChange={value => !hasSubTasks && setMinutes(value || 0)}
|
||||
disabled={hasSubTasks}
|
||||
value={displayMinutes}
|
||||
style={{
|
||||
cursor: hasSubTasks ? 'not-allowed' : 'default',
|
||||
opacity: hasSubTasks ? 0.6 : 1
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Flex>
|
||||
</Tooltip>
|
||||
</Form.Item>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { colors } from '@/styles/colors';
|
||||
import { ITaskFormViewModel, ITaskViewModel } from '@/types/tasks/task.types';
|
||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||
import { ISubTask } from '@/types/tasks/subTask.types';
|
||||
import { simpleDateFormat } from '@/utils/simpleDateFormat';
|
||||
|
||||
import NotifyMemberSelector from './notify-member-selector';
|
||||
@@ -33,6 +34,7 @@ import TaskDrawerRecurringConfig from './details/task-drawer-recurring-config/ta
|
||||
|
||||
interface TaskDetailsFormProps {
|
||||
taskFormViewModel?: ITaskFormViewModel | null;
|
||||
subTasks?: ISubTask[]; // Array of subtasks to calculate estimation sum
|
||||
}
|
||||
|
||||
// Custom wrapper that enforces stricter rules for displaying progress input
|
||||
@@ -71,11 +73,20 @@ const ConditionalProgressInput = ({ task, form }: ConditionalProgressInputProps)
|
||||
return null;
|
||||
};
|
||||
|
||||
const TaskDetailsForm = ({ taskFormViewModel = null }: TaskDetailsFormProps) => {
|
||||
const TaskDetailsForm = ({ taskFormViewModel = null, subTasks = [] }: TaskDetailsFormProps) => {
|
||||
const { t } = useTranslation('task-drawer/task-drawer');
|
||||
const [form] = Form.useForm();
|
||||
const { project } = useAppSelector(state => state.projectReducer);
|
||||
|
||||
// Calculate sum of subtasks estimation
|
||||
const subTasksEstimation = subTasks.reduce(
|
||||
(acc, subTask) => ({
|
||||
hours: acc.hours + (subTask.total_hours || 0),
|
||||
minutes: acc.minutes + (subTask.total_minutes || 0)
|
||||
}),
|
||||
{ hours: 0, minutes: 0 }
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!taskFormViewModel) {
|
||||
form.resetFields();
|
||||
@@ -157,7 +168,7 @@ const TaskDetailsForm = ({ taskFormViewModel = null }: TaskDetailsFormProps) =>
|
||||
|
||||
<TaskDrawerDueDate task={taskFormViewModel?.task as ITaskViewModel} t={t} form={form} />
|
||||
|
||||
<TaskDrawerEstimation t={t} task={taskFormViewModel?.task as ITaskViewModel} form={form} />
|
||||
<TaskDrawerEstimation t={t} task={taskFormViewModel?.task as ITaskViewModel} form={form} subTasksEstimation={subTasksEstimation} />
|
||||
|
||||
{taskFormViewModel?.task && (
|
||||
<ConditionalProgressInput
|
||||
|
||||
@@ -100,7 +100,7 @@ const TaskDrawerInfoTab = ({ t }: TaskDrawerInfoTabProps) => {
|
||||
{
|
||||
key: 'details',
|
||||
label: <Typography.Text strong>{t('taskInfoTab.details.title')}</Typography.Text>,
|
||||
children: <TaskDetailsForm taskFormViewModel={taskFormViewModel} />,
|
||||
children: <TaskDetailsForm taskFormViewModel={taskFormViewModel} subTasks={subTasks} />,
|
||||
style: panelStyle,
|
||||
className: 'custom-task-drawer-info-collapse',
|
||||
},
|
||||
|
||||
@@ -85,6 +85,14 @@ export const fetchProjectFinancesSilent = createAsyncThunk(
|
||||
}
|
||||
);
|
||||
|
||||
export const fetchSubTasks = createAsyncThunk(
|
||||
'projectFinances/fetchSubTasks',
|
||||
async ({ projectId, parentTaskId }: { projectId: string; parentTaskId: string }) => {
|
||||
const response = await projectFinanceApiService.getSubTasks(projectId, parentTaskId);
|
||||
return { parentTaskId, subTasks: response.body };
|
||||
}
|
||||
);
|
||||
|
||||
export const updateTaskFixedCostAsync = createAsyncThunk(
|
||||
'projectFinances/updateTaskFixedCostAsync',
|
||||
async ({ taskId, groupId, fixedCost }: { taskId: string; groupId: string; fixedCost: number }) => {
|
||||
@@ -144,6 +152,16 @@ export const projectFinancesSlice = createSlice({
|
||||
task.variance = variance;
|
||||
}
|
||||
}
|
||||
},
|
||||
toggleTaskExpansion: (state, action: PayloadAction<{ taskId: string; groupId: string }>) => {
|
||||
const { taskId, groupId } = action.payload;
|
||||
const group = state.taskGroups.find(g => g.group_id === groupId);
|
||||
if (group) {
|
||||
const task = group.tasks.find(t => t.id === taskId);
|
||||
if (task) {
|
||||
task.show_sub_tasks = !task.show_sub_tasks;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
@@ -174,6 +192,22 @@ export const projectFinancesSlice = createSlice({
|
||||
// Don't recalculate here - trigger a refresh instead for accuracy
|
||||
}
|
||||
}
|
||||
})
|
||||
.addCase(fetchSubTasks.fulfilled, (state, action) => {
|
||||
const { parentTaskId, subTasks } = action.payload;
|
||||
// Find the parent task in any group and add the subtasks
|
||||
for (const group of state.taskGroups) {
|
||||
const parentTask = group.tasks.find(t => t.id === parentTaskId);
|
||||
if (parentTask) {
|
||||
parentTask.sub_tasks = subTasks.map(subTask => ({
|
||||
...subTask,
|
||||
is_sub_task: true,
|
||||
parent_task_id: parentTaskId
|
||||
}));
|
||||
parentTask.show_sub_tasks = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -183,7 +217,8 @@ export const {
|
||||
setActiveGroup,
|
||||
updateTaskFixedCost,
|
||||
updateTaskEstimatedCost,
|
||||
updateTaskTimeLogged
|
||||
updateTaskTimeLogged,
|
||||
toggleTaskExpansion
|
||||
} = projectFinancesSlice.actions;
|
||||
|
||||
export default projectFinancesSlice.reducer;
|
||||
|
||||
@@ -11,7 +11,13 @@ import { colors } from '@/styles/colors';
|
||||
import { financeTableColumns, FinanceTableColumnKeys } from '@/lib/project/project-view-finance-table-columns';
|
||||
import Avatars from '@/components/avatars/avatars';
|
||||
import { IProjectFinanceGroup, IProjectFinanceTask } from '@/types/project/project-finance.types';
|
||||
import { updateTaskFixedCostAsync, updateTaskFixedCost, fetchProjectFinancesSilent } from '@/features/projects/finance/project-finance.slice';
|
||||
import {
|
||||
updateTaskFixedCostAsync,
|
||||
updateTaskFixedCost,
|
||||
fetchProjectFinancesSilent,
|
||||
toggleTaskExpansion,
|
||||
fetchSubTasks
|
||||
} from '@/features/projects/finance/project-finance.slice';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { setSelectedTaskId, setShowTaskDrawer, fetchTask } from '@/features/task-drawer/task-drawer.slice';
|
||||
import { useParams } from 'react-router-dom';
|
||||
@@ -143,6 +149,19 @@ const FinanceTable = ({
|
||||
dispatch(fetchTask({ taskId, projectId }));
|
||||
};
|
||||
|
||||
// Handle task expansion/collapse
|
||||
const handleTaskExpansion = async (task: IProjectFinanceTask) => {
|
||||
if (!projectId) return;
|
||||
|
||||
// If task has subtasks but they're not loaded yet, load them
|
||||
if (task.sub_tasks_count > 0 && !task.sub_tasks) {
|
||||
dispatch(fetchSubTasks({ projectId, parentTaskId: task.id }));
|
||||
} else {
|
||||
// Just toggle the expansion state
|
||||
dispatch(toggleTaskExpansion({ taskId: task.id, groupId: table.group_id }));
|
||||
}
|
||||
};
|
||||
|
||||
// Debounced save function for fixed cost
|
||||
const debouncedSaveFixedCost = (value: number | null, taskId: string) => {
|
||||
// Clear existing timeout
|
||||
@@ -181,10 +200,27 @@ const FinanceTable = ({
|
||||
return (
|
||||
<Tooltip title={task.name}>
|
||||
<Flex gap={8} align="center">
|
||||
{/* Indentation for subtasks */}
|
||||
{task.is_sub_task && <div style={{ width: 20 }} />}
|
||||
|
||||
{/* Expand/collapse icon for parent tasks */}
|
||||
{task.sub_tasks_count > 0 && (
|
||||
<div
|
||||
style={{ cursor: 'pointer', width: 16, display: 'flex', justifyContent: 'center' }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleTaskExpansion(task);
|
||||
}}
|
||||
>
|
||||
{task.show_sub_tasks ? <DownOutlined /> : <RightOutlined />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Task name */}
|
||||
<Typography.Text
|
||||
ellipsis={{ expanded: false }}
|
||||
style={{
|
||||
maxWidth: 160,
|
||||
maxWidth: task.is_sub_task ? 140 : (task.sub_tasks_count > 0 ? 140 : 160),
|
||||
cursor: 'pointer',
|
||||
color: '#1890ff'
|
||||
}}
|
||||
@@ -341,6 +377,25 @@ const FinanceTable = ({
|
||||
variance: totals.variance
|
||||
}), [totals]);
|
||||
|
||||
// Flatten tasks to include subtasks for rendering
|
||||
const flattenedTasks = useMemo(() => {
|
||||
const flattened: IProjectFinanceTask[] = [];
|
||||
|
||||
tasks.forEach(task => {
|
||||
// Add the parent task
|
||||
flattened.push(task);
|
||||
|
||||
// Add subtasks if they are expanded and loaded
|
||||
if (task.show_sub_tasks && task.sub_tasks) {
|
||||
task.sub_tasks.forEach(subTask => {
|
||||
flattened.push(subTask);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return flattened;
|
||||
}, [tasks]);
|
||||
|
||||
return (
|
||||
<Skeleton active loading={loading}>
|
||||
<>
|
||||
@@ -388,7 +443,7 @@ const FinanceTable = ({
|
||||
</tr>
|
||||
|
||||
{/* task rows */}
|
||||
{!isCollapse && tasks.map((task, idx) => (
|
||||
{!isCollapse && flattenedTasks.map((task, idx) => (
|
||||
<tr
|
||||
key={task.id}
|
||||
style={{
|
||||
|
||||
@@ -40,6 +40,11 @@ export interface IProjectFinanceTask {
|
||||
variance: number;
|
||||
total_budget: number;
|
||||
total_actual: number;
|
||||
sub_tasks_count: number; // Number of subtasks
|
||||
sub_tasks?: IProjectFinanceTask[]; // Loaded subtasks
|
||||
show_sub_tasks?: boolean; // Whether subtasks are expanded
|
||||
is_sub_task?: boolean; // Whether this is a subtask
|
||||
parent_task_id?: string; // Parent task ID for subtasks
|
||||
}
|
||||
|
||||
export interface IProjectFinanceGroup {
|
||||
|
||||
Reference in New Issue
Block a user