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:
chamiakJ
2025-05-29 00:59:59 +05:30
parent 5454c22bd1
commit a87ea46b97
13 changed files with 457 additions and 86 deletions

View File

@@ -66,56 +66,132 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
const rateCardResult = await db.query(rateCardQuery, [projectId]); const rateCardResult = await db.query(rateCardQuery, [projectId]);
const projectRateCards = rateCardResult.rows; 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 = ` const q = `
WITH task_costs AS ( WITH RECURSIVE task_tree AS (
-- Get the requested tasks (parent tasks or subtasks of a specific parent)
SELECT SELECT
t.id, t.id,
t.name, t.name,
COALESCE(t.total_minutes * 60, 0) as estimated_seconds, t.parent_task_id,
COALESCE((SELECT SUM(time_spent) FROM task_work_log WHERE task_id = t.id), 0) as total_time_logged_seconds,
t.project_id, t.project_id,
t.status_id, t.status_id,
t.priority_id, t.priority_id,
(SELECT phase_id FROM task_phase WHERE task_id = t.id) as phase_id, (SELECT phase_id FROM task_phase WHERE task_id = t.id) as phase_id,
(SELECT get_task_assignees(t.id)) as assignees, (SELECT get_task_assignees(t.id)) as assignees,
t.billable, 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 FROM tasks t
WHERE t.project_id = $1 AND t.archived = false WHERE t.project_id = $1
), AND t.archived = false
task_estimated_costs AS ( AND t.parent_task_id IS NULL -- Only load parent tasks initially
UNION ALL
-- Get all descendant tasks for aggregation
SELECT SELECT
tc.*, t.id,
-- Calculate estimated cost based on estimated hours and assignee rates from project_members 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(( COALESCE((
SELECT SUM((tc.estimated_seconds / 3600.0) * COALESCE(fprr.rate, 0)) SELECT SUM((tt.estimated_seconds / 3600.0) * COALESCE(fprr.rate, 0))
FROM json_array_elements(tc.assignees) AS assignee_json 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 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 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 WHERE assignee_json->>'team_member_id' IS NOT NULL
), 0) as estimated_cost, ), 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(( COALESCE((
SELECT SUM( SELECT SUM(COALESCE(fprr.rate, 0) * (twl.time_spent / 3600.0))
COALESCE(fprr.rate, 0) * (twl.time_spent / 3600.0)
)
FROM task_work_log twl FROM task_work_log twl
LEFT JOIN users u ON twl.user_id = u.id LEFT JOIN users u ON twl.user_id = u.id
LEFT JOIN team_members tm ON u.id = tm.user_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 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 ), 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 FROM task_costs tc
WHERE tc.level = 0 -- Only return the requested level
) )
SELECT SELECT
tec.*, at.*,
(tec.estimated_cost + tec.fixed_cost) as total_budget, (at.estimated_cost + at.fixed_cost) as total_budget,
(tec.actual_cost_from_logs + tec.fixed_cost) as total_actual, (at.actual_cost_from_logs + at.fixed_cost) as total_actual,
((tec.actual_cost_from_logs + tec.fixed_cost) - (tec.estimated_cost + tec.fixed_cost)) as variance ((at.actual_cost_from_logs + at.fixed_cost) - (at.estimated_cost + at.fixed_cost)) as variance
FROM task_estimated_costs tec; FROM aggregated_tasks at;
`; `;
const result = await db.query(q, [projectId]); const result = await db.query(q, [projectId]);
@@ -240,7 +316,8 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
total_actual: Number(task.total_actual) || 0, total_actual: Number(task.total_actual) || 0,
variance: Number(task.variance) || 0, variance: Number(task.variance) || 0,
members: task.assignees, 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)); return res.status(200).send(new ServerResponse(true, responseData));
} }
@HandleExceptions()
public static async getSubTasks(
req: IWorkLenzRequest,
res: IWorkLenzResponse
): Promise<IWorkLenzResponse> {
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));
}
} }

View File

@@ -7,6 +7,7 @@ import safeControllerFunction from "../../shared/safe-controller-function";
const projectFinanceApiRouter = express.Router(); const projectFinanceApiRouter = express.Router();
projectFinanceApiRouter.get("/project/:project_id/tasks", ProjectfinanceController.getTasks); projectFinanceApiRouter.get("/project/:project_id/tasks", ProjectfinanceController.getTasks);
projectFinanceApiRouter.get("/project/:project_id/tasks/:parent_task_id/subtasks", ProjectfinanceController.getSubTasks);
projectFinanceApiRouter.get( projectFinanceApiRouter.get(
"/task/:id/breakdown", "/task/:id/breakdown",
idParamValidator, idParamValidator,

View File

@@ -1,11 +1,11 @@
import {Server, Socket} from "socket.io"; import { Server, Socket } from "socket.io";
import db from "../../config/db"; import db from "../../config/db";
import {getColor, toMinutes} from "../../shared/utils"; import { getColor, toMinutes } from "../../shared/utils";
import {SocketEvents} from "../events"; import { SocketEvents } from "../events";
import {log_error, notifyProjectUpdates} from "../util"; import { log_error, notifyProjectUpdates } from "../util";
import TasksControllerV2 from "../../controllers/tasks-controller-v2"; 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 moment from "moment";
import momentTime from "moment-timezone"; import momentTime from "moment-timezone";
import { logEndDateChange, logStartDateChange, logStatusChange } from "../../services/activity-logs/activity-logs.service"; 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; const [d2] = result2.rows;
task.completed_count = d2.res.total_completed || 0; 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; task.sub_tasks_count = d2.res.total_tasks;
}
return task; return task;
} }
@@ -97,8 +98,8 @@ export async function on_quick_task(_io: Server, socket: Socket, data?: string)
logEndDateChange({ logEndDateChange({
task_id: d.task.id, task_id: d.task.id,
socket, socket,
new_value: body.time_zone && d.task.end_date ? momentTime.tz(d.task.end_date, `${body.time_zone}`) : d.task.end_date, 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 old_value: null
}); });
} }

View File

@@ -23,6 +23,7 @@
"show-start-date": "Show Start Date", "show-start-date": "Show Start Date",
"hours": "Hours", "hours": "Hours",
"minutes": "Minutes", "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", "progressValue": "Progress Value",
"progressValueTooltip": "Set the progress percentage (0-100%)", "progressValueTooltip": "Set the progress percentage (0-100%)",
"progressValueRequired": "Please enter a progress value", "progressValueRequired": "Please enter a progress value",

View File

@@ -23,6 +23,7 @@
"show-start-date": "Mostrar fecha de inicio", "show-start-date": "Mostrar fecha de inicio",
"hours": "Horas", "hours": "Horas",
"minutes": "Minutos", "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", "progressValue": "Valor de Progreso",
"progressValueTooltip": "Establecer el porcentaje de progreso (0-100%)", "progressValueTooltip": "Establecer el porcentaje de progreso (0-100%)",
"progressValueRequired": "Por favor, introduce un valor de progreso", "progressValueRequired": "Por favor, introduce un valor de progreso",

View File

@@ -23,7 +23,8 @@
"show-start-date": "Mostrar data de início", "show-start-date": "Mostrar data de início",
"hours": "Horas", "hours": "Horas",
"minutes": "Minutos", "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%)", "progressValueTooltip": "Definir a porcentagem de progresso (0-100%)",
"progressValueRequired": "Por favor, insira um valor de progresso", "progressValueRequired": "Por favor, insira um valor de progresso",
"progressValueRange": "O progresso deve estar entre 0 e 100", "progressValueRange": "O progresso deve estar entre 0 e 100",

View File

@@ -1,7 +1,7 @@
import { API_BASE_URL } from "@/shared/constants"; import { API_BASE_URL } from "@/shared/constants";
import { IServerResponse } from "@/types/common.types"; import { IServerResponse } from "@/types/common.types";
import apiClient from "../api-client"; 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`; const rootUrl = `${API_BASE_URL}/project-finance`;
@@ -20,6 +20,16 @@ export const projectFinanceApiService = {
return response.data; 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 ( getTaskBreakdown: async (
taskId: string taskId: string
): Promise<IServerResponse<ITaskBreakdownResponse>> => { ): Promise<IServerResponse<ITaskBreakdownResponse>> => {

View File

@@ -2,23 +2,41 @@ import { SocketEvents } from '@/shared/socket-events';
import { useSocket } from '@/socket/socketContext'; import { useSocket } from '@/socket/socketContext';
import { colors } from '@/styles/colors'; import { colors } from '@/styles/colors';
import { ITaskViewModel } from '@/types/tasks/task.types'; 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 { TFunction } from 'i18next';
import { useState } from 'react'; import { useState, useEffect } from 'react';
interface TaskDrawerEstimationProps { interface TaskDrawerEstimationProps {
t: TFunction; t: TFunction;
task: ITaskViewModel; task: ITaskViewModel;
form: FormInstance<any>; 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 { socket, connected } = useSocket();
const [hours, setHours] = useState(0); const [hours, setHours] = useState(0);
const [minutes, setMinutes] = 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>) => { 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 // Get current form values instead of using state
const currentHours = form.getFieldValue('hours') || 0; 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 ( return (
<Form.Item name="timeEstimation" label={t('taskInfoTab.details.time-estimation')}> <Form.Item name="timeEstimation" label={t('taskInfoTab.details.time-estimation')}>
<Flex gap={8}> <Tooltip title={tooltipTitle} trigger={hasSubTasks ? 'hover' : []}>
<Form.Item <Flex gap={8}>
name={'hours'} <Form.Item
label={ name={'hours'}
<Typography.Text style={{ color: colors.lightGray, fontSize: 12 }}> label={
{t('taskInfoTab.details.hours')} <Typography.Text style={{ color: colors.lightGray, fontSize: 12 }}>
</Typography.Text> {t('taskInfoTab.details.hours')}
} </Typography.Text>
style={{ marginBottom: 36 }} }
labelCol={{ style: { paddingBlock: 0 } }} style={{ marginBottom: 36 }}
layout="vertical" labelCol={{ style: { paddingBlock: 0 } }}
> layout="vertical"
<InputNumber >
min={0} <InputNumber
max={24} min={0}
placeholder={t('taskInfoTab.details.hours')} max={24}
onBlur={handleTimeEstimationBlur} placeholder={t('taskInfoTab.details.hours')}
onChange={value => setHours(value || 0)} onBlur={handleTimeEstimationBlur}
/> onChange={value => !hasSubTasks && setHours(value || 0)}
</Form.Item> disabled={hasSubTasks}
<Form.Item value={displayHours}
name={'minutes'} style={{
label={ cursor: hasSubTasks ? 'not-allowed' : 'default',
<Typography.Text style={{ color: colors.lightGray, fontSize: 12 }}> opacity: hasSubTasks ? 0.6 : 1
{t('taskInfoTab.details.minutes')} }}
</Typography.Text> />
} </Form.Item>
style={{ marginBottom: 36 }} <Form.Item
labelCol={{ style: { paddingBlock: 0 } }} name={'minutes'}
layout="vertical" label={
> <Typography.Text style={{ color: colors.lightGray, fontSize: 12 }}>
<InputNumber {t('taskInfoTab.details.minutes')}
min={0} </Typography.Text>
max={60} }
placeholder={t('taskInfoTab.details.minutes')} style={{ marginBottom: 36 }}
onBlur={handleTimeEstimationBlur} labelCol={{ style: { paddingBlock: 0 } }}
onChange={value => setMinutes(value || 0)} layout="vertical"
/> >
</Form.Item> <InputNumber
</Flex> 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> </Form.Item>
); );
}; };

View File

@@ -14,6 +14,7 @@ import { useTranslation } from 'react-i18next';
import { colors } from '@/styles/colors'; import { colors } from '@/styles/colors';
import { ITaskFormViewModel, ITaskViewModel } from '@/types/tasks/task.types'; import { ITaskFormViewModel, ITaskViewModel } from '@/types/tasks/task.types';
import { IProjectTask } from '@/types/project/projectTasksViewModel.types'; import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
import { ISubTask } from '@/types/tasks/subTask.types';
import { simpleDateFormat } from '@/utils/simpleDateFormat'; import { simpleDateFormat } from '@/utils/simpleDateFormat';
import NotifyMemberSelector from './notify-member-selector'; import NotifyMemberSelector from './notify-member-selector';
@@ -33,6 +34,7 @@ import TaskDrawerRecurringConfig from './details/task-drawer-recurring-config/ta
interface TaskDetailsFormProps { interface TaskDetailsFormProps {
taskFormViewModel?: ITaskFormViewModel | null; taskFormViewModel?: ITaskFormViewModel | null;
subTasks?: ISubTask[]; // Array of subtasks to calculate estimation sum
} }
// Custom wrapper that enforces stricter rules for displaying progress input // Custom wrapper that enforces stricter rules for displaying progress input
@@ -71,11 +73,20 @@ const ConditionalProgressInput = ({ task, form }: ConditionalProgressInputProps)
return null; return null;
}; };
const TaskDetailsForm = ({ taskFormViewModel = null }: TaskDetailsFormProps) => { const TaskDetailsForm = ({ taskFormViewModel = null, subTasks = [] }: TaskDetailsFormProps) => {
const { t } = useTranslation('task-drawer/task-drawer'); const { t } = useTranslation('task-drawer/task-drawer');
const [form] = Form.useForm(); const [form] = Form.useForm();
const { project } = useAppSelector(state => state.projectReducer); 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(() => { useEffect(() => {
if (!taskFormViewModel) { if (!taskFormViewModel) {
form.resetFields(); form.resetFields();
@@ -157,7 +168,7 @@ const TaskDetailsForm = ({ taskFormViewModel = null }: TaskDetailsFormProps) =>
<TaskDrawerDueDate task={taskFormViewModel?.task as ITaskViewModel} t={t} form={form} /> <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 && ( {taskFormViewModel?.task && (
<ConditionalProgressInput <ConditionalProgressInput

View File

@@ -100,7 +100,7 @@ const TaskDrawerInfoTab = ({ t }: TaskDrawerInfoTabProps) => {
{ {
key: 'details', key: 'details',
label: <Typography.Text strong>{t('taskInfoTab.details.title')}</Typography.Text>, label: <Typography.Text strong>{t('taskInfoTab.details.title')}</Typography.Text>,
children: <TaskDetailsForm taskFormViewModel={taskFormViewModel} />, children: <TaskDetailsForm taskFormViewModel={taskFormViewModel} subTasks={subTasks} />,
style: panelStyle, style: panelStyle,
className: 'custom-task-drawer-info-collapse', className: 'custom-task-drawer-info-collapse',
}, },

View File

@@ -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( export const updateTaskFixedCostAsync = createAsyncThunk(
'projectFinances/updateTaskFixedCostAsync', 'projectFinances/updateTaskFixedCostAsync',
async ({ taskId, groupId, fixedCost }: { taskId: string; groupId: string; fixedCost: number }) => { async ({ taskId, groupId, fixedCost }: { taskId: string; groupId: string; fixedCost: number }) => {
@@ -144,6 +152,16 @@ export const projectFinancesSlice = createSlice({
task.variance = variance; 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) => { extraReducers: (builder) => {
@@ -174,6 +192,22 @@ export const projectFinancesSlice = createSlice({
// Don't recalculate here - trigger a refresh instead for accuracy // 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, setActiveGroup,
updateTaskFixedCost, updateTaskFixedCost,
updateTaskEstimatedCost, updateTaskEstimatedCost,
updateTaskTimeLogged updateTaskTimeLogged,
toggleTaskExpansion
} = projectFinancesSlice.actions; } = projectFinancesSlice.actions;
export default projectFinancesSlice.reducer; export default projectFinancesSlice.reducer;

View File

@@ -11,7 +11,13 @@ import { colors } from '@/styles/colors';
import { financeTableColumns, FinanceTableColumnKeys } from '@/lib/project/project-view-finance-table-columns'; import { financeTableColumns, FinanceTableColumnKeys } from '@/lib/project/project-view-finance-table-columns';
import Avatars from '@/components/avatars/avatars'; import Avatars from '@/components/avatars/avatars';
import { IProjectFinanceGroup, IProjectFinanceTask } from '@/types/project/project-finance.types'; 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 { useAppDispatch } from '@/hooks/useAppDispatch';
import { setSelectedTaskId, setShowTaskDrawer, fetchTask } from '@/features/task-drawer/task-drawer.slice'; import { setSelectedTaskId, setShowTaskDrawer, fetchTask } from '@/features/task-drawer/task-drawer.slice';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
@@ -143,6 +149,19 @@ const FinanceTable = ({
dispatch(fetchTask({ taskId, projectId })); 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 // Debounced save function for fixed cost
const debouncedSaveFixedCost = (value: number | null, taskId: string) => { const debouncedSaveFixedCost = (value: number | null, taskId: string) => {
// Clear existing timeout // Clear existing timeout
@@ -181,10 +200,27 @@ const FinanceTable = ({
return ( return (
<Tooltip title={task.name}> <Tooltip title={task.name}>
<Flex gap={8} align="center"> <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 <Typography.Text
ellipsis={{ expanded: false }} ellipsis={{ expanded: false }}
style={{ style={{
maxWidth: 160, maxWidth: task.is_sub_task ? 140 : (task.sub_tasks_count > 0 ? 140 : 160),
cursor: 'pointer', cursor: 'pointer',
color: '#1890ff' color: '#1890ff'
}} }}
@@ -341,6 +377,25 @@ const FinanceTable = ({
variance: totals.variance variance: totals.variance
}), [totals]); }), [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 ( return (
<Skeleton active loading={loading}> <Skeleton active loading={loading}>
<> <>
@@ -388,7 +443,7 @@ const FinanceTable = ({
</tr> </tr>
{/* task rows */} {/* task rows */}
{!isCollapse && tasks.map((task, idx) => ( {!isCollapse && flattenedTasks.map((task, idx) => (
<tr <tr
key={task.id} key={task.id}
style={{ style={{

View File

@@ -40,6 +40,11 @@ export interface IProjectFinanceTask {
variance: number; variance: number;
total_budget: number; total_budget: number;
total_actual: 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 { export interface IProjectFinanceGroup {