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 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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>> => {
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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={{
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user