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:
@@ -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<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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,8 +53,16 @@ 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')}>
|
||||
<Tooltip title={tooltipTitle} trigger={hasSubTasks ? 'hover' : []}>
|
||||
<Flex gap={8}>
|
||||
<Form.Item
|
||||
name={'hours'}
|
||||
@@ -54,7 +80,13 @@ const TaskDrawerEstimation = ({ t, task, form }: TaskDrawerEstimationProps) => {
|
||||
max={24}
|
||||
placeholder={t('taskInfoTab.details.hours')}
|
||||
onBlur={handleTimeEstimationBlur}
|
||||
onChange={value => setHours(value || 0)}
|
||||
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
|
||||
@@ -73,10 +105,17 @@ const TaskDrawerEstimation = ({ t, task, form }: TaskDrawerEstimationProps) => {
|
||||
max={60}
|
||||
placeholder={t('taskInfoTab.details.minutes')}
|
||||
onBlur={handleTimeEstimationBlur}
|
||||
onChange={value => setMinutes(value || 0)}
|
||||
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