diff --git a/worklenz-backend/src/controllers/project-finance-controller.ts b/worklenz-backend/src/controllers/project-finance-controller.ts index fdc98789..1092d6aa 100644 --- a/worklenz-backend/src/controllers/project-finance-controller.ts +++ b/worklenz-backend/src/controllers/project-finance-controller.ts @@ -66,56 +66,132 @@ export default class ProjectfinanceController extends WorklenzControllerBase { const rateCardResult = await db.query(rateCardQuery, [projectId]); const projectRateCards = rateCardResult.rows; - // Get all tasks with their financial data - using project_members.project_rate_card_role_id + // Get tasks with their financial data - support hierarchical loading const q = ` - WITH task_costs AS ( + WITH RECURSIVE task_tree AS ( + -- Get the requested tasks (parent tasks or subtasks of a specific parent) SELECT t.id, t.name, - COALESCE(t.total_minutes * 60, 0) as estimated_seconds, - COALESCE((SELECT SUM(time_spent) FROM task_work_log WHERE task_id = t.id), 0) as total_time_logged_seconds, + t.parent_task_id, t.project_id, t.status_id, t.priority_id, (SELECT phase_id FROM task_phase WHERE task_id = t.id) as phase_id, (SELECT get_task_assignees(t.id)) as assignees, t.billable, - COALESCE(t.fixed_cost, 0) as fixed_cost + COALESCE(t.fixed_cost, 0) as fixed_cost, + COALESCE(t.total_minutes * 60, 0) as estimated_seconds, + COALESCE((SELECT SUM(time_spent) FROM task_work_log WHERE task_id = t.id), 0) as total_time_logged_seconds, + (SELECT COUNT(*) FROM tasks WHERE parent_task_id = t.id AND archived = false) as sub_tasks_count, + 0 as level, + t.id as root_id FROM tasks t - WHERE t.project_id = $1 AND t.archived = false - ), - task_estimated_costs AS ( + WHERE t.project_id = $1 + AND t.archived = false + AND t.parent_task_id IS NULL -- Only load parent tasks initially + + UNION ALL + + -- Get all descendant tasks for aggregation SELECT - tc.*, - -- Calculate estimated cost based on estimated hours and assignee rates from project_members + t.id, + t.name, + t.parent_task_id, + t.project_id, + t.status_id, + t.priority_id, + (SELECT phase_id FROM task_phase WHERE task_id = t.id) as phase_id, + (SELECT get_task_assignees(t.id)) as assignees, + t.billable, + COALESCE(t.fixed_cost, 0) as fixed_cost, + COALESCE(t.total_minutes * 60, 0) as estimated_seconds, + COALESCE((SELECT SUM(time_spent) FROM task_work_log WHERE task_id = t.id), 0) as total_time_logged_seconds, + 0 as sub_tasks_count, + tt.level + 1 as level, + tt.root_id + FROM tasks t + INNER JOIN task_tree tt ON t.parent_task_id = tt.id + WHERE t.archived = false + ), + task_costs AS ( + SELECT + tt.*, + -- Calculate estimated cost based on estimated hours and assignee rates COALESCE(( - SELECT SUM((tc.estimated_seconds / 3600.0) * COALESCE(fprr.rate, 0)) - FROM json_array_elements(tc.assignees) AS assignee_json + SELECT SUM((tt.estimated_seconds / 3600.0) * COALESCE(fprr.rate, 0)) + FROM json_array_elements(tt.assignees) AS assignee_json LEFT JOIN project_members pm ON pm.team_member_id = (assignee_json->>'team_member_id')::uuid - AND pm.project_id = tc.project_id + AND pm.project_id = tt.project_id LEFT JOIN finance_project_rate_card_roles fprr ON fprr.id = pm.project_rate_card_role_id WHERE assignee_json->>'team_member_id' IS NOT NULL ), 0) as estimated_cost, - -- Calculate actual cost based on time logged and assignee rates from project_members + -- Calculate actual cost based on time logged and assignee rates COALESCE(( - SELECT SUM( - COALESCE(fprr.rate, 0) * (twl.time_spent / 3600.0) - ) + SELECT SUM(COALESCE(fprr.rate, 0) * (twl.time_spent / 3600.0)) FROM task_work_log twl LEFT JOIN users u ON twl.user_id = u.id LEFT JOIN team_members tm ON u.id = tm.user_id - LEFT JOIN project_members pm ON pm.team_member_id = tm.id AND pm.project_id = tc.project_id + LEFT JOIN project_members pm ON pm.team_member_id = tm.id AND pm.project_id = tt.project_id LEFT JOIN finance_project_rate_card_roles fprr ON fprr.id = pm.project_rate_card_role_id - WHERE twl.task_id = tc.id + WHERE twl.task_id = tt.id ), 0) as actual_cost_from_logs + FROM task_tree tt + ), + aggregated_tasks AS ( + SELECT + tc.id, + tc.name, + tc.parent_task_id, + tc.status_id, + tc.priority_id, + tc.phase_id, + tc.assignees, + tc.billable, + tc.fixed_cost, + tc.sub_tasks_count, + -- For parent tasks, sum values from all descendants including self + CASE + WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN ( + SELECT SUM(sub_tc.estimated_seconds) + FROM task_costs sub_tc + WHERE sub_tc.root_id = tc.id + ) + ELSE tc.estimated_seconds + END as estimated_seconds, + CASE + WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN ( + SELECT SUM(sub_tc.total_time_logged_seconds) + FROM task_costs sub_tc + WHERE sub_tc.root_id = tc.id + ) + ELSE tc.total_time_logged_seconds + END as total_time_logged_seconds, + CASE + WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN ( + SELECT SUM(sub_tc.estimated_cost) + FROM task_costs sub_tc + WHERE sub_tc.root_id = tc.id + ) + ELSE tc.estimated_cost + END as estimated_cost, + CASE + WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN ( + SELECT SUM(sub_tc.actual_cost_from_logs) + FROM task_costs sub_tc + WHERE sub_tc.root_id = tc.id + ) + ELSE tc.actual_cost_from_logs + END as actual_cost_from_logs FROM task_costs tc + WHERE tc.level = 0 -- Only return the requested level ) SELECT - tec.*, - (tec.estimated_cost + tec.fixed_cost) as total_budget, - (tec.actual_cost_from_logs + tec.fixed_cost) as total_actual, - ((tec.actual_cost_from_logs + tec.fixed_cost) - (tec.estimated_cost + tec.fixed_cost)) as variance - FROM task_estimated_costs tec; + at.*, + (at.estimated_cost + at.fixed_cost) as total_budget, + (at.actual_cost_from_logs + at.fixed_cost) as total_actual, + ((at.actual_cost_from_logs + at.fixed_cost) - (at.estimated_cost + at.fixed_cost)) as variance + FROM aggregated_tasks at; `; const result = await db.query(q, [projectId]); @@ -240,7 +316,8 @@ export default class ProjectfinanceController extends WorklenzControllerBase { total_actual: Number(task.total_actual) || 0, variance: Number(task.variance) || 0, members: task.assignees, - billable: task.billable + billable: task.billable, + sub_tasks_count: Number(task.sub_tasks_count) || 0 })) }; }); @@ -426,4 +503,138 @@ export default class ProjectfinanceController extends WorklenzControllerBase { return res.status(200).send(new ServerResponse(true, responseData)); } + + @HandleExceptions() + public static async getSubTasks( + req: IWorkLenzRequest, + res: IWorkLenzResponse + ): Promise { + const projectId = req.params.project_id; + const parentTaskId = req.params.parent_task_id; + + if (!parentTaskId) { + return res.status(400).send(new ServerResponse(false, null, "Parent task ID is required")); + } + + // Get subtasks with their financial data + const q = ` + WITH task_costs AS ( + SELECT + t.id, + t.name, + t.parent_task_id, + t.project_id, + t.status_id, + t.priority_id, + (SELECT phase_id FROM task_phase WHERE task_id = t.id) as phase_id, + (SELECT get_task_assignees(t.id)) as assignees, + t.billable, + COALESCE(t.fixed_cost, 0) as fixed_cost, + COALESCE(t.total_minutes * 60, 0) as estimated_seconds, + COALESCE((SELECT SUM(time_spent) FROM task_work_log WHERE task_id = t.id), 0) as total_time_logged_seconds, + (SELECT COUNT(*) FROM tasks WHERE parent_task_id = t.id AND archived = false) as sub_tasks_count + FROM tasks t + WHERE t.project_id = $1 + AND t.archived = false + AND t.parent_task_id = $2 + ), + task_estimated_costs AS ( + SELECT + tc.*, + -- Calculate estimated cost based on estimated hours and assignee rates + COALESCE(( + SELECT SUM((tc.estimated_seconds / 3600.0) * COALESCE(fprr.rate, 0)) + FROM json_array_elements(tc.assignees) AS assignee_json + LEFT JOIN project_members pm ON pm.team_member_id = (assignee_json->>'team_member_id')::uuid + AND pm.project_id = tc.project_id + LEFT JOIN finance_project_rate_card_roles fprr ON fprr.id = pm.project_rate_card_role_id + WHERE assignee_json->>'team_member_id' IS NOT NULL + ), 0) as estimated_cost, + -- Calculate actual cost based on time logged and assignee rates + COALESCE(( + SELECT SUM(COALESCE(fprr.rate, 0) * (twl.time_spent / 3600.0)) + FROM task_work_log twl + LEFT JOIN users u ON twl.user_id = u.id + LEFT JOIN team_members tm ON u.id = tm.user_id + LEFT JOIN project_members pm ON pm.team_member_id = tm.id AND pm.project_id = tc.project_id + LEFT JOIN finance_project_rate_card_roles fprr ON fprr.id = pm.project_rate_card_role_id + WHERE twl.task_id = tc.id + ), 0) as actual_cost_from_logs + FROM task_costs tc + ) + SELECT + tec.*, + (tec.estimated_cost + tec.fixed_cost) as total_budget, + (tec.actual_cost_from_logs + tec.fixed_cost) as total_actual, + ((tec.actual_cost_from_logs + tec.fixed_cost) - (tec.estimated_cost + tec.fixed_cost)) as variance + FROM task_estimated_costs tec; + `; + + const result = await db.query(q, [projectId, parentTaskId]); + const tasks = result.rows; + + // Add color_code to each assignee and include their rate information + for (const task of tasks) { + if (Array.isArray(task.assignees)) { + for (const assignee of task.assignees) { + assignee.color_code = getColor(assignee.name); + + // Get the rate for this assignee + const memberRateQuery = ` + SELECT + pm.project_rate_card_role_id, + fprr.rate, + fprr.job_title_id, + jt.name as job_title_name + FROM project_members pm + LEFT JOIN finance_project_rate_card_roles fprr ON fprr.id = pm.project_rate_card_role_id + LEFT JOIN job_titles jt ON fprr.job_title_id = jt.id + WHERE pm.team_member_id = $1 AND pm.project_id = $2 + `; + + try { + const memberRateResult = await db.query(memberRateQuery, [assignee.team_member_id, projectId]); + if (memberRateResult.rows.length > 0) { + const memberRate = memberRateResult.rows[0]; + assignee.project_rate_card_role_id = memberRate.project_rate_card_role_id; + assignee.rate = memberRate.rate ? Number(memberRate.rate) : 0; + assignee.job_title_id = memberRate.job_title_id; + assignee.job_title_name = memberRate.job_title_name; + } else { + assignee.project_rate_card_role_id = null; + assignee.rate = 0; + assignee.job_title_id = null; + assignee.job_title_name = null; + } + } catch (error) { + console.error("Error fetching member rate:", error); + assignee.project_rate_card_role_id = null; + assignee.rate = 0; + assignee.job_title_id = null; + assignee.job_title_name = null; + } + } + } + } + + // Format the response to match the expected structure + const formattedTasks = tasks.map(task => ({ + id: task.id, + name: task.name, + estimated_seconds: Number(task.estimated_seconds) || 0, + estimated_hours: formatTimeToHMS(Number(task.estimated_seconds) || 0), + total_time_logged_seconds: Number(task.total_time_logged_seconds) || 0, + total_time_logged: formatTimeToHMS(Number(task.total_time_logged_seconds) || 0), + estimated_cost: Number(task.estimated_cost) || 0, + fixed_cost: Number(task.fixed_cost) || 0, + total_budget: Number(task.total_budget) || 0, + total_actual: Number(task.total_actual) || 0, + variance: Number(task.variance) || 0, + members: task.assignees, + billable: task.billable, + sub_tasks_count: Number(task.sub_tasks_count) || 0 + })); + + return res.status(200).send(new ServerResponse(true, formattedTasks)); + } } diff --git a/worklenz-backend/src/routes/apis/project-finance-api-router.ts b/worklenz-backend/src/routes/apis/project-finance-api-router.ts index 6254baa0..9228705a 100644 --- a/worklenz-backend/src/routes/apis/project-finance-api-router.ts +++ b/worklenz-backend/src/routes/apis/project-finance-api-router.ts @@ -7,6 +7,7 @@ import safeControllerFunction from "../../shared/safe-controller-function"; const projectFinanceApiRouter = express.Router(); projectFinanceApiRouter.get("/project/:project_id/tasks", ProjectfinanceController.getTasks); +projectFinanceApiRouter.get("/project/:project_id/tasks/:parent_task_id/subtasks", ProjectfinanceController.getSubTasks); projectFinanceApiRouter.get( "/task/:id/breakdown", idParamValidator, diff --git a/worklenz-backend/src/socket.io/commands/on-quick-task.ts b/worklenz-backend/src/socket.io/commands/on-quick-task.ts index 859cbf58..80e9f381 100644 --- a/worklenz-backend/src/socket.io/commands/on-quick-task.ts +++ b/worklenz-backend/src/socket.io/commands/on-quick-task.ts @@ -1,11 +1,11 @@ -import {Server, Socket} from "socket.io"; +import { Server, Socket } from "socket.io"; import db from "../../config/db"; -import {getColor, toMinutes} from "../../shared/utils"; -import {SocketEvents} from "../events"; +import { getColor, toMinutes } from "../../shared/utils"; +import { SocketEvents } from "../events"; -import {log_error, notifyProjectUpdates} from "../util"; +import { log_error, notifyProjectUpdates } from "../util"; import TasksControllerV2 from "../../controllers/tasks-controller-v2"; -import {TASK_STATUS_COLOR_ALPHA, UNMAPPED} from "../../shared/constants"; +import { TASK_STATUS_COLOR_ALPHA, UNMAPPED } from "../../shared/constants"; import moment from "moment"; import momentTime from "moment-timezone"; import { logEndDateChange, logStartDateChange, logStatusChange } from "../../services/activity-logs/activity-logs.service"; @@ -18,8 +18,9 @@ export async function getTaskCompleteInfo(task: any) { const [d2] = result2.rows; task.completed_count = d2.res.total_completed || 0; - if (task.sub_tasks_count > 0) + if (task.sub_tasks_count > 0 && d2.res.total_tasks > 0) { task.sub_tasks_count = d2.res.total_tasks; + } return task; } @@ -97,8 +98,8 @@ export async function on_quick_task(_io: Server, socket: Socket, data?: string) logEndDateChange({ task_id: d.task.id, socket, - new_value: body.time_zone && d.task.end_date ? momentTime.tz(d.task.end_date, `${body.time_zone}`) : d.task.end_date, - old_value: null + new_value: body.time_zone && d.task.end_date ? momentTime.tz(d.task.end_date, `${body.time_zone}`) : d.task.end_date, + old_value: null }); } diff --git a/worklenz-frontend/public/locales/en/task-drawer/task-drawer.json b/worklenz-frontend/public/locales/en/task-drawer/task-drawer.json index 06575ee1..771eaad2 100644 --- a/worklenz-frontend/public/locales/en/task-drawer/task-drawer.json +++ b/worklenz-frontend/public/locales/en/task-drawer/task-drawer.json @@ -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", diff --git a/worklenz-frontend/public/locales/es/task-drawer/task-drawer.json b/worklenz-frontend/public/locales/es/task-drawer/task-drawer.json index c3980da8..96ea6362 100644 --- a/worklenz-frontend/public/locales/es/task-drawer/task-drawer.json +++ b/worklenz-frontend/public/locales/es/task-drawer/task-drawer.json @@ -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", diff --git a/worklenz-frontend/public/locales/pt/task-drawer/task-drawer.json b/worklenz-frontend/public/locales/pt/task-drawer/task-drawer.json index 6288af92..94862d03 100644 --- a/worklenz-frontend/public/locales/pt/task-drawer/task-drawer.json +++ b/worklenz-frontend/public/locales/pt/task-drawer/task-drawer.json @@ -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", diff --git a/worklenz-frontend/src/api/project-finance-ratecard/project-finance.api.service.ts b/worklenz-frontend/src/api/project-finance-ratecard/project-finance.api.service.ts index 99139070..6a614778 100644 --- a/worklenz-frontend/src/api/project-finance-ratecard/project-finance.api.service.ts +++ b/worklenz-frontend/src/api/project-finance-ratecard/project-finance.api.service.ts @@ -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> => { + const response = await apiClient.get>( + `${rootUrl}/project/${projectId}/tasks/${parentTaskId}/subtasks` + ); + return response.data; + }, + getTaskBreakdown: async ( taskId: string ): Promise> => { diff --git a/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-estimation/task-drawer-estimation.tsx b/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-estimation/task-drawer-estimation.tsx index f3732d42..cf43f4e0 100644 --- a/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-estimation/task-drawer-estimation.tsx +++ b/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-estimation/task-drawer-estimation.tsx @@ -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; + 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) => { - 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 ( - - - {t('taskInfoTab.details.hours')} - - } - style={{ marginBottom: 36 }} - labelCol={{ style: { paddingBlock: 0 } }} - layout="vertical" - > - setHours(value || 0)} - /> - - - {t('taskInfoTab.details.minutes')} - - } - style={{ marginBottom: 36 }} - labelCol={{ style: { paddingBlock: 0 } }} - layout="vertical" - > - setMinutes(value || 0)} - /> - - + + + + {t('taskInfoTab.details.hours')} + + } + style={{ marginBottom: 36 }} + labelCol={{ style: { paddingBlock: 0 } }} + layout="vertical" + > + !hasSubTasks && setHours(value || 0)} + disabled={hasSubTasks} + value={displayHours} + style={{ + cursor: hasSubTasks ? 'not-allowed' : 'default', + opacity: hasSubTasks ? 0.6 : 1 + }} + /> + + + {t('taskInfoTab.details.minutes')} + + } + style={{ marginBottom: 36 }} + labelCol={{ style: { paddingBlock: 0 } }} + layout="vertical" + > + !hasSubTasks && setMinutes(value || 0)} + disabled={hasSubTasks} + value={displayMinutes} + style={{ + cursor: hasSubTasks ? 'not-allowed' : 'default', + opacity: hasSubTasks ? 0.6 : 1 + }} + /> + + + ); }; diff --git a/worklenz-frontend/src/components/task-drawer/shared/info-tab/task-details-form.tsx b/worklenz-frontend/src/components/task-drawer/shared/info-tab/task-details-form.tsx index a2dcaef1..f01aa3da 100644 --- a/worklenz-frontend/src/components/task-drawer/shared/info-tab/task-details-form.tsx +++ b/worklenz-frontend/src/components/task-drawer/shared/info-tab/task-details-form.tsx @@ -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) => - + {taskFormViewModel?.task && ( { { key: 'details', label: {t('taskInfoTab.details.title')}, - children: , + children: , style: panelStyle, className: 'custom-task-drawer-info-collapse', }, diff --git a/worklenz-frontend/src/features/projects/finance/project-finance.slice.ts b/worklenz-frontend/src/features/projects/finance/project-finance.slice.ts index 1bcbaa94..0f47b755 100644 --- a/worklenz-frontend/src/features/projects/finance/project-finance.slice.ts +++ b/worklenz-frontend/src/features/projects/finance/project-finance.slice.ts @@ -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; diff --git a/worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-table/finance-table.tsx b/worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-table/finance-table.tsx index 67f4209b..1cbfe8a3 100644 --- a/worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-table/finance-table.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-table/finance-table.tsx @@ -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 ( + {/* Indentation for subtasks */} + {task.is_sub_task &&
} + + {/* Expand/collapse icon for parent tasks */} + {task.sub_tasks_count > 0 && ( +
{ + e.stopPropagation(); + handleTaskExpansion(task); + }} + > + {task.show_sub_tasks ? : } +
+ )} + + {/* Task name */} 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 ( <> @@ -388,7 +443,7 @@ const FinanceTable = ({ {/* task rows */} - {!isCollapse && tasks.map((task, idx) => ( + {!isCollapse && flattenedTasks.map((task, idx) => (