diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 2def3d08..f577b438 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -6,7 +6,8 @@ "Bash(npm run type-check:*)", "Bash(npm run:*)", "Bash(mkdir:*)", - "Bash(cp:*)" + "Bash(cp:*)", + "Bash(ls:*)" ], "deny": [] } diff --git a/worklenz-backend/src/controllers/project-finance-controller.ts b/worklenz-backend/src/controllers/project-finance-controller.ts new file mode 100644 index 00000000..4c27ed73 --- /dev/null +++ b/worklenz-backend/src/controllers/project-finance-controller.ts @@ -0,0 +1,1860 @@ +import { IWorkLenzRequest } from "../interfaces/worklenz-request"; +import { IWorkLenzResponse } from "../interfaces/worklenz-response"; + +import db from "../config/db"; +import { ServerResponse } from "../models/server-response"; +import WorklenzControllerBase from "./worklenz-controller-base"; +import HandleExceptions from "../decorators/handle-exceptions"; +import { TASK_STATUS_COLOR_ALPHA } from "../shared/constants"; +import { getColor } from "../shared/utils"; +import moment from "moment"; +import Excel from "exceljs"; + +// Utility function to format time in hours, minutes, seconds format +const formatTimeToHMS = (totalSeconds: number): string => { + if (!totalSeconds || totalSeconds === 0) return "0s"; + + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + const parts = []; + if (hours > 0) parts.push(`${hours}h`); + if (minutes > 0) parts.push(`${minutes}m`); + if (seconds > 0 || parts.length === 0) parts.push(`${seconds}s`); + + return parts.join(" "); +}; + +// Utility function to parse time string back to seconds for calculations +const parseTimeToSeconds = (timeString: string): number => { + if (!timeString || timeString === "0s") return 0; + + let totalSeconds = 0; + const hourMatch = timeString.match(/(\d+)h/); + const minuteMatch = timeString.match(/(\d+)m/); + const secondMatch = timeString.match(/(\d+)s/); + + if (hourMatch) totalSeconds += parseInt(hourMatch[1]) * 3600; + if (minuteMatch) totalSeconds += parseInt(minuteMatch[1]) * 60; + if (secondMatch) totalSeconds += parseInt(secondMatch[1]); + + return totalSeconds; +}; + +export default class ProjectfinanceController extends WorklenzControllerBase { + @HandleExceptions() + public static async getTasks( + req: IWorkLenzRequest, + res: IWorkLenzResponse + ): Promise { + const projectId = req.params.project_id; + const groupBy = req.query.group_by || "status"; + const billableFilter = req.query.billable_filter || "billable"; + + // Get project information including currency and organization calculation method + const projectQuery = ` + SELECT + p.id, + p.name, + p.currency, + o.calculation_method, + o.hours_per_day + FROM projects p + JOIN teams t ON p.team_id = t.id + JOIN organizations o ON t.organization_id = o.id + WHERE p.id = $1 + `; + const projectResult = await db.query(projectQuery, [projectId]); + + if (projectResult.rows.length === 0) { + return res.status(404).send(new ServerResponse(false, null, "Project not found")); + } + + const project = projectResult.rows[0]; + + // First, get the project rate cards for this project + const rateCardQuery = ` + SELECT + fprr.id, + fprr.project_id, + fprr.job_title_id, + fprr.rate, + fprr.man_day_rate, + jt.name as job_title_name + FROM finance_project_rate_card_roles fprr + LEFT JOIN job_titles jt ON fprr.job_title_id = jt.id + WHERE fprr.project_id = $1 + ORDER BY jt.name; + `; + + const rateCardResult = await db.query(rateCardQuery, [projectId]); + const projectRateCards = rateCardResult.rows; + + // Build billable filter condition + let billableCondition = ""; + if (billableFilter === "billable") { + billableCondition = "AND t.billable = true"; + } else if (billableFilter === "non-billable") { + billableCondition = "AND t.billable = false"; + } + + // Get tasks with their financial data - support hierarchical loading + const q = ` + WITH RECURSIVE task_tree AS ( + -- Get the requested tasks (parent tasks or subtasks of a specific parent) + 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(t.total_minutes, 0) as total_minutes, + 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 + AND t.parent_task_id IS NULL -- Only load parent tasks initially + ${billableCondition} + + UNION ALL + + -- Get all descendant tasks for aggregation + 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(t.total_minutes, 0) as total_minutes, + 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 + ), + -- Identify leaf tasks (tasks with no children) for proper aggregation + leaf_tasks AS ( + SELECT + tt.*, + CASE + WHEN NOT EXISTS ( + SELECT 1 FROM task_tree child_tt + WHERE child_tt.parent_task_id = tt.id + AND child_tt.root_id = tt.root_id + ) THEN true + ELSE false + END as is_leaf + FROM task_tree tt + ), + task_costs AS ( + SELECT + tt.*, + -- Calculate estimated cost based on organization calculation method + CASE + WHEN $2 = 'man_days' THEN + -- Man days calculation: use estimated_man_days * man_day_rate + COALESCE(( + SELECT SUM( + CASE + WHEN COALESCE(fprr.man_day_rate, 0) > 0 THEN + -- Use total_minutes if available, otherwise use estimated_seconds + CASE + WHEN tt.total_minutes > 0 THEN ((tt.total_minutes / 60.0) / $3) * COALESCE(fprr.man_day_rate, 0) + ELSE ((tt.estimated_seconds / 3600.0) / $3) * COALESCE(fprr.man_day_rate, 0) + END + ELSE + -- Fallback to hourly rate if man_day_rate is 0 + CASE + WHEN tt.total_minutes > 0 THEN (tt.total_minutes / 60.0) * COALESCE(fprr.rate, 0) + ELSE (tt.estimated_seconds / 3600.0) * COALESCE(fprr.rate, 0) + END + END + ) + 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 = 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) + ELSE + -- Hourly calculation: use estimated_hours * hourly_rate + COALESCE(( + SELECT SUM( + CASE + WHEN tt.total_minutes > 0 THEN (tt.total_minutes / 60.0) * COALESCE(fprr.rate, 0) + ELSE (tt.estimated_seconds / 3600.0) * COALESCE(fprr.rate, 0) + END + ) + 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 = 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) + END as estimated_cost, + -- Calculate actual cost based on organization calculation method + CASE + WHEN $2 = 'man_days' THEN + -- Man days calculation: convert actual time to man days and multiply by man day rates + COALESCE(( + SELECT SUM( + CASE + WHEN COALESCE(fprr.man_day_rate, 0) > 0 THEN + COALESCE(fprr.man_day_rate, 0) * ((twl.time_spent / 3600.0) / $3) + ELSE + -- Fallback to hourly rate if man_day_rate is 0 + COALESCE(fprr.rate, 0) * (twl.time_spent / 3600.0) + END + ) + 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 = tt.project_id + LEFT JOIN finance_project_rate_card_roles fprr ON fprr.id = pm.project_rate_card_role_id + WHERE twl.task_id = tt.id + ), 0) + ELSE + -- Hourly calculation: use actual time logged * hourly 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 = tt.project_id + LEFT JOIN finance_project_rate_card_roles fprr ON fprr.id = pm.project_rate_card_role_id + WHERE twl.task_id = tt.id + ), 0) + END as actual_cost_from_logs + FROM leaf_tasks 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, + -- Fixed cost aggregation: sum from leaf tasks only + CASE + WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN ( + SELECT COALESCE(SUM(leaf_tc.fixed_cost), 0) + FROM task_costs leaf_tc + WHERE leaf_tc.root_id = tc.id + AND leaf_tc.is_leaf = true + ) + ELSE tc.fixed_cost + END as fixed_cost, + tc.sub_tasks_count, + -- For parent tasks, sum values from leaf tasks only + CASE + WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN ( + SELECT COALESCE(SUM(leaf_tc.estimated_seconds), 0) + FROM task_costs leaf_tc + WHERE leaf_tc.root_id = tc.id + AND leaf_tc.is_leaf = true + ) + ELSE tc.estimated_seconds + END as estimated_seconds, + -- Sum total_minutes from leaf tasks only + CASE + WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN ( + SELECT COALESCE(SUM(leaf_tc.total_minutes), 0) + FROM task_costs leaf_tc + WHERE leaf_tc.root_id = tc.id + AND leaf_tc.is_leaf = true + ) + ELSE tc.total_minutes + END as total_minutes, + -- Sum time logged from leaf tasks only + CASE + WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN ( + SELECT COALESCE(SUM(leaf_tc.total_time_logged_seconds), 0) + FROM task_costs leaf_tc + WHERE leaf_tc.root_id = tc.id + AND leaf_tc.is_leaf = true + ) + ELSE tc.total_time_logged_seconds + END as total_time_logged_seconds, + -- Sum estimated cost from leaf tasks only + CASE + WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN ( + SELECT COALESCE(SUM(leaf_tc.estimated_cost), 0) + FROM task_costs leaf_tc + WHERE leaf_tc.root_id = tc.id + AND leaf_tc.is_leaf = true + ) + ELSE tc.estimated_cost + END as estimated_cost, + -- Sum actual cost from leaf tasks only + CASE + WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN ( + SELECT COALESCE(SUM(leaf_tc.actual_cost_from_logs), 0) + FROM task_costs leaf_tc + WHERE leaf_tc.root_id = tc.id + AND leaf_tc.is_leaf = true + ) + 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 + at.*, + (at.estimated_cost + at.fixed_cost) as total_budget, + (at.actual_cost_from_logs + at.fixed_cost) as total_actual, + ((at.estimated_cost + at.fixed_cost) - (at.actual_cost_from_logs + at.fixed_cost)) as variance, + -- Add effort variance for man days calculation + CASE + WHEN $2 = 'man_days' THEN + -- Effort variance in man days: actual man days - estimated man days + ((at.total_time_logged_seconds / 3600.0) / $3) - + ((at.estimated_seconds / 3600.0) / $3) + ELSE + NULL -- No effort variance for hourly projects + END as effort_variance_man_days, + -- Add actual man days for man days calculation + CASE + WHEN $2 = 'man_days' THEN + (at.total_time_logged_seconds / 3600.0) / $3 + ELSE + NULL + END as actual_man_days + FROM aggregated_tasks at; + `; + + const result = await db.query(q, [projectId, project.calculation_method, project.hours_per_day]); + const tasks = result.rows; + + // Add color_code to each assignee and include their rate information using project_members + 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 using project_members.project_rate_card_role_id + 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 { + // Member doesn't have a rate card role assigned + 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 from project_members:", + error + ); + assignee.project_rate_card_role_id = null; + assignee.rate = 0; + assignee.job_title_id = null; + assignee.job_title_name = null; + } + } + } + } + + // Get groups based on groupBy parameter + let groups: Array<{ + id: string; + group_name: string; + color_code: string; + color_code_dark: string; + }> = []; + + if (groupBy === "status") { + const q = ` + SELECT + ts.id, + ts.name as group_name, + stsc.color_code::text, + stsc.color_code_dark::text + FROM task_statuses ts + INNER JOIN sys_task_status_categories stsc ON ts.category_id = stsc.id + WHERE ts.project_id = $1 + ORDER BY ts.sort_order; + `; + groups = (await db.query(q, [projectId])).rows; + } else if (groupBy === "priority") { + const q = ` + SELECT + id, + name as group_name, + color_code::text, + color_code_dark::text + FROM task_priorities + ORDER BY value; + `; + groups = (await db.query(q)).rows; + } else if (groupBy === "phases") { + const q = ` + SELECT + id, + name as group_name, + color_code::text, + color_code::text as color_code_dark + FROM project_phases + WHERE project_id = $1 + ORDER BY sort_index; + `; + groups = (await db.query(q, [projectId])).rows; + + // Add TASK_STATUS_COLOR_ALPHA to color codes + for (const group of groups) { + group.color_code = group.color_code + TASK_STATUS_COLOR_ALPHA; + group.color_code_dark = group.color_code_dark + TASK_STATUS_COLOR_ALPHA; + } + } + + // Group tasks by the selected criteria + const groupedTasks = groups.map((group) => { + const groupTasks = tasks.filter((task) => { + if (groupBy === "status") return task.status_id === group.id; + if (groupBy === "priority") return task.priority_id === group.id; + if (groupBy === "phases") return task.phase_id === group.id; + return false; + }); + + return { + group_id: group.id, + group_name: group.group_name, + color_code: group.color_code, + color_code_dark: group.color_code_dark, + tasks: groupTasks.map((task) => ({ + id: task.id, + name: task.name, + estimated_seconds: Number(task.estimated_seconds) || 0, + estimated_hours: formatTimeToHMS(Number(task.estimated_seconds) || 0), + total_minutes: Number(task.total_minutes) || 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, + actual_cost_from_logs: Number(task.actual_cost_from_logs) || 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, + effort_variance_man_days: task.effort_variance_man_days ? Number(task.effort_variance_man_days) : null, + actual_man_days: task.actual_man_days ? Number(task.actual_man_days) : null, + members: task.assignees, + billable: task.billable, + sub_tasks_count: Number(task.sub_tasks_count) || 0, + })), + }; + }); + + // Include project rate cards and currency in the response for reference + const responseData = { + groups: groupedTasks, + project_rate_cards: projectRateCards, + project: { + id: project.id, + name: project.name, + currency: project.currency || "USD", + calculation_method: project.calculation_method || "hourly", + hours_per_day: Number(project.hours_per_day) || 8 + } + }; + + return res.status(200).send(new ServerResponse(true, responseData)); + } + + @HandleExceptions() + public static async updateTaskFixedCost( + req: IWorkLenzRequest, + res: IWorkLenzResponse + ): Promise { + const taskId = req.params.task_id; + const { fixed_cost } = req.body; + + if (typeof fixed_cost !== "number" || fixed_cost < 0) { + return res + .status(400) + .send(new ServerResponse(false, null, "Invalid fixed cost value")); + } + + // Check if the task has subtasks - parent tasks should not have editable fixed costs + const checkParentQuery = ` + SELECT + t.id, + t.name, + (SELECT COUNT(*) FROM tasks st WHERE st.parent_task_id = t.id AND st.archived = false) as sub_tasks_count + FROM tasks t + WHERE t.id = $1 AND t.archived = false; + `; + + const checkResult = await db.query(checkParentQuery, [taskId]); + + if (checkResult.rows.length === 0) { + return res + .status(404) + .send(new ServerResponse(false, null, "Task not found")); + } + + const task = checkResult.rows[0]; + + // Prevent updating fixed cost for parent tasks + if (task.sub_tasks_count > 0) { + return res + .status(400) + .send(new ServerResponse(false, null, "Cannot update fixed cost for parent tasks. Fixed cost is calculated from subtasks.")); + } + + // Update only the specific subtask's fixed cost + const updateQuery = ` + UPDATE tasks + SET fixed_cost = $1, updated_at = NOW() + WHERE id = $2 + RETURNING id, name, fixed_cost; + `; + + const result = await db.query(updateQuery, [fixed_cost, taskId]); + + if (result.rows.length === 0) { + return res + .status(404) + .send(new ServerResponse(false, null, "Task not found")); + } + + return res.status(200).send(new ServerResponse(true, { + updated_task: result.rows[0], + message: "Fixed cost updated successfully." + })); + } + + @HandleExceptions() + public static async getTaskBreakdown( + req: IWorkLenzRequest, + res: IWorkLenzResponse + ): Promise { + const taskId = req.params.id; + + // Get task basic information and financial data + const taskQuery = ` + SELECT + t.id, + t.name, + t.project_id, + COALESCE(t.total_minutes, 0) / 60.0 as estimated_hours, + COALESCE((SELECT SUM(time_spent) FROM task_work_log WHERE task_id = t.id), 0) / 3600.0 as total_time_logged, + COALESCE(t.fixed_cost, 0) as fixed_cost, + t.billable, + (SELECT get_task_assignees(t.id)) as assignees + FROM tasks t + WHERE t.id = $1 AND t.archived = false; + `; + + const taskResult = await db.query(taskQuery, [taskId]); + + if (taskResult.rows.length === 0) { + return res + .status(404) + .send(new ServerResponse(false, null, "Task not found")); + } + + const [task] = taskResult.rows; + + // Get detailed member information with rates and job titles + const membersWithRates = []; + if (Array.isArray(task.assignees)) { + for (const assignee of task.assignees) { + const memberRateQuery = ` + SELECT + tm.id as team_member_id, + u.name, + u.avatar_url, + pm.project_rate_card_role_id, + COALESCE(fprr.rate, 0) as hourly_rate, + fprr.job_title_id, + jt.name as job_title_name + FROM team_members tm + LEFT JOIN users u ON tm.user_id = u.id + LEFT JOIN project_members pm ON pm.team_member_id = tm.id AND pm.project_id = $1 + 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 tm.id = $2 + `; + + try { + const memberResult = await db.query(memberRateQuery, [ + task.project_id, + assignee.team_member_id, + ]); + if (memberResult.rows.length > 0) { + const [member] = memberResult.rows; + + // Get actual time logged by this member for this task + const timeLogQuery = ` + SELECT COALESCE(SUM(time_spent), 0) / 3600.0 as logged_hours + 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 + WHERE twl.task_id = $1 AND tm.id = $2 + `; + + const timeLogResult = await db.query(timeLogQuery, [ + taskId, + member.team_member_id, + ]); + const loggedHours = Number( + timeLogResult.rows[0]?.logged_hours || 0 + ); + + membersWithRates.push({ + team_member_id: member.team_member_id, + name: member.name || "Unknown User", + avatar_url: member.avatar_url, + hourly_rate: Number(member.hourly_rate || 0), + job_title_name: member.job_title_name || "Unassigned", + estimated_hours: + task.assignees.length > 0 + ? Number(task.estimated_hours) / task.assignees.length + : 0, + logged_hours: loggedHours, + estimated_cost: + (task.assignees.length > 0 + ? Number(task.estimated_hours) / task.assignees.length + : 0) * Number(member.hourly_rate || 0), + actual_cost: loggedHours * Number(member.hourly_rate || 0), + }); + } + } catch (error) { + console.error("Error fetching member details:", error); + } + } + } + + // Group members by job title and calculate totals + const groupedMembers = membersWithRates.reduce((acc: any, member: any) => { + const jobRole = member.job_title_name || "Unassigned"; + + if (!acc[jobRole]) { + acc[jobRole] = { + jobRole, + estimated_hours: 0, + logged_hours: 0, + estimated_cost: 0, + actual_cost: 0, + members: [], + }; + } + + acc[jobRole].estimated_hours += member.estimated_hours; + acc[jobRole].logged_hours += member.logged_hours; + acc[jobRole].estimated_cost += member.estimated_cost; + acc[jobRole].actual_cost += member.actual_cost; + acc[jobRole].members.push({ + team_member_id: member.team_member_id, + name: member.name, + avatar_url: member.avatar_url, + hourly_rate: member.hourly_rate, + estimated_hours: member.estimated_hours, + logged_hours: member.logged_hours, + estimated_cost: member.estimated_cost, + actual_cost: member.actual_cost, + }); + + return acc; + }, {}); + + // Calculate task totals + const taskTotals = { + estimated_hours: Number(task.estimated_hours || 0), + logged_hours: Number(task.total_time_logged || 0), + estimated_labor_cost: membersWithRates.reduce( + (sum, member) => sum + member.estimated_cost, + 0 + ), + actual_labor_cost: membersWithRates.reduce( + (sum, member) => sum + member.actual_cost, + 0 + ), + fixed_cost: Number(task.fixed_cost || 0), + total_estimated_cost: + membersWithRates.reduce( + (sum, member) => sum + member.estimated_cost, + 0 + ) + Number(task.fixed_cost || 0), + total_actual_cost: + membersWithRates.reduce((sum, member) => sum + member.actual_cost, 0) + + Number(task.fixed_cost || 0), + }; + + const responseData = { + task: { + id: task.id, + name: task.name, + project_id: task.project_id, + billable: task.billable, + ...taskTotals, + }, + grouped_members: Object.values(groupedMembers), + members: membersWithRates, + }; + + return res.status(200).send(new ServerResponse(true, responseData)); + } + + @HandleExceptions() + public static async getSubTasks( + req: IWorkLenzRequest, + res: IWorkLenzResponse + ): Promise { + const projectId = req.params.project_id; + const parentTaskId = req.params.parent_task_id; + const billableFilter = req.query.billable_filter || "billable"; + + if (!parentTaskId) { + return res + .status(400) + .send(new ServerResponse(false, null, "Parent task ID is required")); + } + + // Get project information including currency and organization calculation method + const projectQuery = ` + SELECT + p.id, + p.name, + p.currency, + o.calculation_method, + o.hours_per_day + FROM projects p + JOIN teams t ON p.team_id = t.id + JOIN organizations o ON t.organization_id = o.id + WHERE p.id = $1; + `; + const projectResult = await db.query(projectQuery, [projectId]); + if (projectResult.rows.length === 0) { + return res.status(404).send(new ServerResponse(false, null, "Project not found")); + } + const project = projectResult.rows[0]; + + // Build billable filter condition for subtasks + let billableCondition = ""; + if (billableFilter === "billable") { + billableCondition = "AND t.billable = true"; + } else if (billableFilter === "non-billable") { + billableCondition = "AND t.billable = false"; + } + + // Get subtasks with their financial data, including recursive aggregation for sub-subtasks + const q = ` + WITH RECURSIVE task_tree AS ( + -- Get the requested subtasks + 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(t.total_minutes, 0) as total_minutes, + 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 + AND t.parent_task_id = $2 + ${billableCondition} + + UNION ALL + + -- Get all descendant tasks for aggregation + 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(t.total_minutes, 0) as total_minutes, + 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 + ), + -- Identify leaf tasks (tasks with no children) for proper aggregation + leaf_tasks AS ( + SELECT + tt.*, + CASE + WHEN NOT EXISTS ( + SELECT 1 FROM task_tree child_tt + WHERE child_tt.parent_task_id = tt.id + AND child_tt.root_id = tt.root_id + ) THEN true + ELSE false + END as is_leaf + FROM task_tree tt + ), + task_costs AS ( + SELECT + tt.*, + -- Calculate estimated cost based on organization calculation method + CASE + WHEN $3 = 'man_days' THEN + -- Man days calculation: use estimated_man_days * man_day_rate + COALESCE(( + SELECT SUM( + CASE + WHEN COALESCE(fprr.man_day_rate, 0) > 0 THEN + -- Use total_minutes if available, otherwise use estimated_seconds + CASE + WHEN tt.total_minutes > 0 THEN ((tt.total_minutes / 60.0) / $4) * COALESCE(fprr.man_day_rate, 0) + ELSE ((tt.estimated_seconds / 3600.0) / $4) * COALESCE(fprr.man_day_rate, 0) + END + ELSE + -- Fallback to hourly rate if man_day_rate is 0 + CASE + WHEN tt.total_minutes > 0 THEN (tt.total_minutes / 60.0) * COALESCE(fprr.rate, 0) + ELSE (tt.estimated_seconds / 3600.0) * COALESCE(fprr.rate, 0) + END + END + ) + 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 = 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) + ELSE + -- Hourly calculation: use estimated_hours * hourly_rate + COALESCE(( + SELECT SUM( + CASE + WHEN tt.total_minutes > 0 THEN (tt.total_minutes / 60.0) * COALESCE(fprr.rate, 0) + ELSE (tt.estimated_seconds / 3600.0) * COALESCE(fprr.rate, 0) + END + ) + 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 = 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) + END as estimated_cost, + -- Calculate actual cost based on organization calculation method + CASE + WHEN $3 = 'man_days' THEN + -- Man days calculation: convert actual time to man days and multiply by man day rates + COALESCE(( + SELECT SUM( + CASE + WHEN COALESCE(fprr.man_day_rate, 0) > 0 THEN + COALESCE(fprr.man_day_rate, 0) * ((twl.time_spent / 3600.0) / $4) + ELSE + -- Fallback to hourly rate if man_day_rate is 0 + COALESCE(fprr.rate, 0) * (twl.time_spent / 3600.0) + END + ) + 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 = tt.project_id + LEFT JOIN finance_project_rate_card_roles fprr ON fprr.id = pm.project_rate_card_role_id + WHERE twl.task_id = tt.id + ), 0) + ELSE + -- Hourly calculation: use actual time logged * hourly 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 = tt.project_id + LEFT JOIN finance_project_rate_card_roles fprr ON fprr.id = pm.project_rate_card_role_id + WHERE twl.task_id = tt.id + ), 0) + END as actual_cost_from_logs + FROM leaf_tasks 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, + -- Fixed cost aggregation: sum from leaf tasks only + CASE + WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN ( + SELECT COALESCE(SUM(leaf_tc.fixed_cost), 0) + FROM task_costs leaf_tc + WHERE leaf_tc.root_id = tc.id + AND leaf_tc.is_leaf = true + ) + ELSE tc.fixed_cost + END as fixed_cost, + tc.sub_tasks_count, + -- For subtasks that have their own sub-subtasks, sum values from leaf tasks only + CASE + WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN ( + SELECT COALESCE(SUM(leaf_tc.estimated_seconds), 0) + FROM task_costs leaf_tc + WHERE leaf_tc.root_id = tc.id + AND leaf_tc.is_leaf = true + ) + ELSE tc.estimated_seconds + END as estimated_seconds, + -- Sum total_minutes from leaf tasks only + CASE + WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN ( + SELECT COALESCE(SUM(leaf_tc.total_minutes), 0) + FROM task_costs leaf_tc + WHERE leaf_tc.root_id = tc.id + AND leaf_tc.is_leaf = true + ) + ELSE tc.total_minutes + END as total_minutes, + -- Sum time logged from leaf tasks only + CASE + WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN ( + SELECT COALESCE(SUM(leaf_tc.total_time_logged_seconds), 0) + FROM task_costs leaf_tc + WHERE leaf_tc.root_id = tc.id + AND leaf_tc.is_leaf = true + ) + ELSE tc.total_time_logged_seconds + END as total_time_logged_seconds, + -- Sum estimated cost from leaf tasks only + CASE + WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN ( + SELECT COALESCE(SUM(leaf_tc.estimated_cost), 0) + FROM task_costs leaf_tc + WHERE leaf_tc.root_id = tc.id + AND leaf_tc.is_leaf = true + ) + ELSE tc.estimated_cost + END as estimated_cost, + -- Sum actual cost from leaf tasks only + CASE + WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN ( + SELECT COALESCE(SUM(leaf_tc.actual_cost_from_logs), 0) + FROM task_costs leaf_tc + WHERE leaf_tc.root_id = tc.id + AND leaf_tc.is_leaf = true + ) + 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 (subtasks) + ) + SELECT + 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, parentTaskId, project.calculation_method, project.hours_per_day]); + 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_minutes: Number(task.total_minutes) || 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, + actual_cost_from_logs: Number(task.actual_cost_from_logs) || 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, + effort_variance_man_days: task.effort_variance_man_days ? Number(task.effort_variance_man_days) : null, + actual_man_days: task.actual_man_days ? Number(task.actual_man_days) : null, + members: task.assignees, + billable: task.billable, + sub_tasks_count: Number(task.sub_tasks_count) || 0, + })); + + return res.status(200).send(new ServerResponse(true, formattedTasks)); + } + + @HandleExceptions() + public static async exportFinanceData( + req: IWorkLenzRequest, + res: IWorkLenzResponse + ): Promise { + const projectId = req.params.project_id; + const groupBy = (req.query.groupBy as string) || "status"; + const billableFilter = req.query.billable_filter || "billable"; + + // Get project information including currency and organization calculation method + const projectQuery = ` + SELECT + p.id, + p.name, + p.currency, + o.calculation_method, + o.hours_per_day + FROM projects p + JOIN teams t ON p.team_id = t.id + JOIN organizations o ON t.organization_id = o.id + WHERE p.id = $1 + `; + const projectResult = await db.query(projectQuery, [projectId]); + + if (projectResult.rows.length === 0) { + res.status(404).send(new ServerResponse(false, null, "Project not found")); + return; + } + + const project = projectResult.rows[0]; + const projectName = project?.name || "Unknown Project"; + const projectCurrency = project?.currency || "USD"; + + // First, get the project rate cards for this project + const rateCardQuery = ` + SELECT + fprr.id, + fprr.project_id, + fprr.job_title_id, + fprr.rate, + jt.name as job_title_name + FROM finance_project_rate_card_roles fprr + LEFT JOIN job_titles jt ON fprr.job_title_id = jt.id + WHERE fprr.project_id = $1 + ORDER BY jt.name; + `; + + const rateCardResult = await db.query(rateCardQuery, [projectId]); + const projectRateCards = rateCardResult.rows; + + // Build billable filter condition for export + let billableCondition = ""; + if (billableFilter === "billable") { + billableCondition = "AND t.billable = true"; + } else if (billableFilter === "non-billable") { + billableCondition = "AND t.billable = false"; + } + + // Get tasks with their financial data - support hierarchical loading + const q = ` + WITH RECURSIVE task_tree AS ( + -- Get the requested tasks (parent tasks or subtasks of a specific parent) + 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(t.total_minutes, 0) as total_minutes, + 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 + AND t.parent_task_id IS NULL -- Only load parent tasks initially + ${billableCondition} + + UNION ALL + + -- Get all descendant tasks for aggregation + 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(t.total_minutes, 0) as total_minutes, + 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 + ), + -- Identify leaf tasks (tasks with no children) for proper aggregation + leaf_tasks AS ( + SELECT + tt.*, + CASE + WHEN NOT EXISTS ( + SELECT 1 FROM task_tree child_tt + WHERE child_tt.parent_task_id = tt.id + AND child_tt.root_id = tt.root_id + ) THEN true + ELSE false + END as is_leaf + FROM task_tree tt + ), + task_costs AS ( + SELECT + tt.*, + -- Calculate estimated cost based on organization calculation method + CASE + WHEN $2 = 'man_days' THEN + -- Man days calculation: use estimated_man_days * man_day_rate + COALESCE(( + SELECT SUM( + CASE + WHEN COALESCE(fprr.man_day_rate, 0) > 0 THEN + -- Use total_minutes if available, otherwise use estimated_seconds + CASE + WHEN tt.total_minutes > 0 THEN ((tt.total_minutes / 60.0) / $3) * COALESCE(fprr.man_day_rate, 0) + ELSE ((tt.estimated_seconds / 3600.0) / $3) * COALESCE(fprr.man_day_rate, 0) + END + ELSE + -- Fallback to hourly rate if man_day_rate is 0 + CASE + WHEN tt.total_minutes > 0 THEN (tt.total_minutes / 60.0) * COALESCE(fprr.rate, 0) + ELSE (tt.estimated_seconds / 3600.0) * COALESCE(fprr.rate, 0) + END + END + ) + 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 = 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) + ELSE + -- Hourly calculation: use estimated_hours * hourly_rate + COALESCE(( + SELECT SUM( + CASE + WHEN tt.total_minutes > 0 THEN (tt.total_minutes / 60.0) * COALESCE(fprr.rate, 0) + ELSE (tt.estimated_seconds / 3600.0) * COALESCE(fprr.rate, 0) + END + ) + 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 = 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) + END as estimated_cost, + -- Calculate actual cost based on organization calculation method + CASE + WHEN $2 = 'man_days' THEN + -- Man days calculation: convert actual time to man days and multiply by man day rates + COALESCE(( + SELECT SUM( + CASE + WHEN COALESCE(fprr.man_day_rate, 0) > 0 THEN + COALESCE(fprr.man_day_rate, 0) * ((twl.time_spent / 3600.0) / $3) + ELSE + -- Fallback to hourly rate if man_day_rate is 0 + COALESCE(fprr.rate, 0) * (twl.time_spent / 3600.0) + END + ) + 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 = tt.project_id + LEFT JOIN finance_project_rate_card_roles fprr ON fprr.id = pm.project_rate_card_role_id + WHERE twl.task_id = tt.id + ), 0) + ELSE + -- Hourly calculation: use actual time logged * hourly 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 = tt.project_id + LEFT JOIN finance_project_rate_card_roles fprr ON fprr.id = pm.project_rate_card_role_id + WHERE twl.task_id = tt.id + ), 0) + END as actual_cost_from_logs + FROM leaf_tasks 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, + -- Fixed cost aggregation: sum from leaf tasks only + CASE + WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN ( + SELECT COALESCE(SUM(leaf_tc.fixed_cost), 0) + FROM task_costs leaf_tc + WHERE leaf_tc.root_id = tc.id + AND leaf_tc.is_leaf = true + ) + ELSE tc.fixed_cost + END as fixed_cost, + tc.sub_tasks_count, + -- For parent tasks, sum values from leaf tasks only + CASE + WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN ( + SELECT COALESCE(SUM(leaf_tc.estimated_seconds), 0) + FROM task_costs leaf_tc + WHERE leaf_tc.root_id = tc.id + AND leaf_tc.is_leaf = true + ) + ELSE tc.estimated_seconds + END as estimated_seconds, + -- Sum total_minutes from leaf tasks only + CASE + WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN ( + SELECT COALESCE(SUM(leaf_tc.total_minutes), 0) + FROM task_costs leaf_tc + WHERE leaf_tc.root_id = tc.id + AND leaf_tc.is_leaf = true + ) + ELSE tc.total_minutes + END as total_minutes, + -- Sum time logged from leaf tasks only + CASE + WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN ( + SELECT COALESCE(SUM(leaf_tc.total_time_logged_seconds), 0) + FROM task_costs leaf_tc + WHERE leaf_tc.root_id = tc.id + AND leaf_tc.is_leaf = true + ) + ELSE tc.total_time_logged_seconds + END as total_time_logged_seconds, + -- Sum estimated cost from leaf tasks only + CASE + WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN ( + SELECT COALESCE(SUM(leaf_tc.estimated_cost), 0) + FROM task_costs leaf_tc + WHERE leaf_tc.root_id = tc.id + AND leaf_tc.is_leaf = true + ) + ELSE tc.estimated_cost + END as estimated_cost, + -- Sum actual cost from leaf tasks only + CASE + WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN ( + SELECT COALESCE(SUM(leaf_tc.actual_cost_from_logs), 0) + FROM task_costs leaf_tc + WHERE leaf_tc.root_id = tc.id + AND leaf_tc.is_leaf = true + ) + 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 + 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, + -- Add effort variance for man days calculation + CASE + WHEN $2 = 'man_days' THEN + -- Effort variance in man days: actual man days - estimated man days + ((at.total_time_logged_seconds / 3600.0) / $3) - + ((at.estimated_seconds / 3600.0) / $3) + ELSE + NULL -- No effort variance for hourly projects + END as effort_variance_man_days, + -- Add actual man days for man days calculation + CASE + WHEN $2 = 'man_days' THEN + (at.total_time_logged_seconds / 3600.0) / $3 + ELSE + NULL + END as actual_man_days + FROM aggregated_tasks at; + `; + + const result = await db.query(q, [projectId, project.calculation_method, project.hours_per_day]); + const tasks = result.rows; + + // Add color_code to each assignee and include their rate information using project_members + 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 using project_members.project_rate_card_role_id + 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 { + // Member doesn't have a rate card role assigned + 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 from project_members:", + error + ); + assignee.project_rate_card_role_id = null; + assignee.rate = 0; + assignee.job_title_id = null; + assignee.job_title_name = null; + } + } + } + } + + // Get groups based on groupBy parameter + let groups: Array<{ + id: string; + group_name: string; + color_code: string; + color_code_dark: string; + }> = []; + + if (groupBy === "status") { + const q = ` + SELECT + ts.id, + ts.name as group_name, + stsc.color_code::text, + stsc.color_code_dark::text + FROM task_statuses ts + INNER JOIN sys_task_status_categories stsc ON ts.category_id = stsc.id + WHERE ts.project_id = $1 + ORDER BY ts.sort_order; + `; + groups = (await db.query(q, [projectId])).rows; + } else if (groupBy === "priority") { + const q = ` + SELECT + id, + name as group_name, + color_code::text, + color_code_dark::text + FROM task_priorities + ORDER BY value; + `; + groups = (await db.query(q)).rows; + } else if (groupBy === "phases") { + const q = ` + SELECT + id, + name as group_name, + color_code::text, + color_code::text as color_code_dark + FROM project_phases + WHERE project_id = $1 + ORDER BY sort_index; + `; + groups = (await db.query(q, [projectId])).rows; + + // Add TASK_STATUS_COLOR_ALPHA to color codes + for (const group of groups) { + group.color_code = group.color_code + TASK_STATUS_COLOR_ALPHA; + group.color_code_dark = group.color_code_dark + TASK_STATUS_COLOR_ALPHA; + } + } + + // Group tasks by the selected criteria + const groupedTasks = groups.map((group) => { + const groupTasks = tasks.filter((task) => { + if (groupBy === "status") return task.status_id === group.id; + if (groupBy === "priority") return task.priority_id === group.id; + if (groupBy === "phases") return task.phase_id === group.id; + return false; + }); + + return { + group_id: group.id, + group_name: group.group_name, + color_code: group.color_code, + color_code_dark: group.color_code_dark, + tasks: groupTasks.map((task) => ({ + id: task.id, + name: task.name, + estimated_seconds: Number(task.estimated_seconds) || 0, + estimated_hours: formatTimeToHMS(Number(task.estimated_seconds) || 0), + total_minutes: Number(task.total_minutes) || 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, + actual_cost_from_logs: Number(task.actual_cost_from_logs) || 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, + effort_variance_man_days: task.effort_variance_man_days ? Number(task.effort_variance_man_days) : null, + actual_man_days: task.actual_man_days ? Number(task.actual_man_days) : null, + members: task.assignees, + billable: task.billable, + sub_tasks_count: Number(task.sub_tasks_count) || 0, + })), + }; + }); + + // Include project rate cards in the response for reference + const responseData = { + groups: groupedTasks, + project_rate_cards: projectRateCards, + }; + + // Create Excel workbook and worksheet + const workbook = new Excel.Workbook(); + const worksheet = workbook.addWorksheet("Finance Data"); + + // Add headers to the worksheet + worksheet.columns = [ + { header: "Task Name", key: "task_name", width: 30 }, + { header: "Group", key: "group_name", width: 20 }, + { header: "Estimated Hours", key: "estimated_hours", width: 15 }, + { header: "Total Time Logged", key: "total_time_logged", width: 15 }, + { header: "Estimated Cost", key: "estimated_cost", width: 15 }, + { header: "Fixed Cost", key: "fixed_cost", width: 15 }, + { header: "Total Budget", key: "total_budget", width: 15 }, + { header: "Total Actual", key: "total_actual", width: 15 }, + { header: "Variance", key: "variance", width: 15 }, + { header: "Members", key: "members", width: 30 }, + { header: "Billable", key: "billable", width: 10 }, + { header: "Sub Tasks Count", key: "sub_tasks_count", width: 15 }, + ]; + + // Add title row + worksheet.getCell( + "A1" + ).value = `Finance Data Export - ${projectName} (${projectCurrency}) - ${moment().format( + "MMM DD, YYYY" + )}`; + worksheet.mergeCells("A1:L1"); + worksheet.getCell("A1").alignment = { horizontal: "center" }; + worksheet.getCell("A1").style.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "D9D9D9" }, + }; + worksheet.getCell("A1").font = { size: 16, bold: true }; + + // Add headers on row 3 + worksheet.getRow(3).values = [ + "Task Name", + "Group", + "Estimated Hours", + "Total Time Logged", + "Estimated Cost", + "Fixed Cost", + "Total Budget", + "Total Actual", + "Variance", + "Members", + "Billable", + "Sub Tasks Count", + ]; + worksheet.getRow(3).font = { bold: true }; + + // Add data to the worksheet + let currentRow = 4; + for (const group of responseData.groups) { + for (const task of group.tasks) { + worksheet.addRow({ + task_name: task.name, + group_name: group.group_name, + estimated_hours: task.estimated_hours, + total_time_logged: task.total_time_logged, + estimated_cost: task.estimated_cost.toFixed(2), + fixed_cost: task.fixed_cost.toFixed(2), + total_budget: task.total_budget.toFixed(2), + total_actual: task.total_actual.toFixed(2), + variance: task.variance.toFixed(2), + members: task.members.map((m: any) => m.name).join(", "), + billable: task.billable ? "Yes" : "No", + sub_tasks_count: task.sub_tasks_count, + }); + currentRow++; + } + } + + // Create a buffer to hold the Excel file + const buffer = await workbook.xlsx.writeBuffer(); + + // Create filename with project name, date and time + const sanitizedProjectName = projectName + .replace(/[^a-zA-Z0-9\s]/g, "") + .replace(/\s+/g, "_"); + const dateTime = moment().format("YYYY-MM-DD_HH-mm-ss"); + const filename = `${sanitizedProjectName}_Finance_Data_${dateTime}.xlsx`; + + // Set the response headers for the Excel file + res.setHeader("Content-Disposition", `attachment; filename=${filename}`); + res.setHeader( + "Content-Type", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + ); + + // Send the Excel file as a response + res.end(buffer); + } + + @HandleExceptions() + public static async updateProjectCurrency( + req: IWorkLenzRequest, + res: IWorkLenzResponse + ): Promise { + const projectId = req.params.project_id; + const { currency } = req.body; + + // Validate currency format (3-character uppercase code) + if (!currency || typeof currency !== "string" || !/^[A-Z]{3}$/.test(currency)) { + return res + .status(400) + .send(new ServerResponse(false, null, "Invalid currency format. Currency must be a 3-character uppercase code (e.g., USD, EUR, GBP)")); + } + + // Check if project exists and user has access + const projectCheckQuery = ` + SELECT p.id, p.name, p.currency as current_currency + FROM projects p + WHERE p.id = $1 AND p.team_id = $2 + `; + + const projectCheckResult = await db.query(projectCheckQuery, [projectId, req.user?.team_id]); + + if (projectCheckResult.rows.length === 0) { + return res + .status(404) + .send(new ServerResponse(false, null, "Project not found or access denied")); + } + + const project = projectCheckResult.rows[0]; + + // Update project currency + const updateQuery = ` + UPDATE projects + SET currency = $1, updated_at = NOW() + WHERE id = $2 AND team_id = $3 + RETURNING id, name, currency; + `; + + const result = await db.query(updateQuery, [currency, projectId, req.user?.team_id]); + + if (result.rows.length === 0) { + return res + .status(500) + .send(new ServerResponse(false, null, "Failed to update project currency")); + } + + const updatedProject = result.rows[0]; + + // Log the currency change for audit purposes + const logQuery = ` + INSERT INTO project_logs (team_id, project_id, description) + VALUES ($1, $2, $3) + `; + + const logDescription = `Project currency changed from ${project.current_currency || "USD"} to ${currency}`; + + try { + await db.query(logQuery, [req.user?.team_id, projectId, logDescription]); + } catch (error) { + console.error("Failed to log currency change:", error); + // Don't fail the request if logging fails + } + + return res.status(200).send(new ServerResponse(true, { + id: updatedProject.id, + name: updatedProject.name, + currency: updatedProject.currency, + message: `Project currency updated to ${currency}` + })); + } + + @HandleExceptions() + public static async updateProjectBudget( + req: IWorkLenzRequest, + res: IWorkLenzResponse + ): Promise { + const projectId = req.params.project_id; + const { budget } = req.body; + + // Validate budget format (must be a non-negative number) + if (budget === undefined || budget === null || isNaN(budget) || budget < 0) { + return res + .status(400) + .send(new ServerResponse(false, null, "Invalid budget amount. Budget must be a non-negative number")); + } + + // Check if project exists and user has access + const projectCheckQuery = ` + SELECT p.id, p.name, p.budget as current_budget, p.currency + FROM projects p + WHERE p.id = $1 AND p.team_id = $2 + `; + + const projectCheckResult = await db.query(projectCheckQuery, [projectId, req.user?.team_id]); + + if (projectCheckResult.rows.length === 0) { + return res + .status(404) + .send(new ServerResponse(false, null, "Project not found or access denied")); + } + + const project = projectCheckResult.rows[0]; + + // Update project budget + const updateQuery = ` + UPDATE projects + SET budget = $1, updated_at = NOW() + WHERE id = $2 AND team_id = $3 + RETURNING id, name, budget, currency; + `; + + const result = await db.query(updateQuery, [budget, projectId, req.user?.team_id]); + + if (result.rows.length === 0) { + return res + .status(500) + .send(new ServerResponse(false, null, "Failed to update project budget")); + } + + const updatedProject = result.rows[0]; + + // Log the budget change for audit purposes + const logQuery = ` + INSERT INTO project_logs (team_id, project_id, description) + VALUES ($1, $2, $3) + `; + + const logDescription = `Project budget changed from ${project.current_budget || 0} to ${budget} ${project.currency || "USD"}`; + + try { + await db.query(logQuery, [req.user?.team_id, projectId, logDescription]); + } catch (error) { + console.error("Failed to log budget change:", error); + // Don't fail the request if logging fails + } + + return res.status(200).send(new ServerResponse(true, { + id: updatedProject.id, + name: updatedProject.name, + budget: Number(updatedProject.budget), + currency: updatedProject.currency, + message: `Project budget updated to ${budget} ${project.currency || "USD"}` + })); + } + + @HandleExceptions() + public static async updateProjectCalculationMethod( + req: IWorkLenzRequest, + res: IWorkLenzResponse + ): Promise { + const projectId = req.params.project_id; + const { calculation_method, hours_per_day } = req.body; + + // Validate calculation method + if (!["hourly", "man_days"].includes(calculation_method)) { + return res.status(400).send(new ServerResponse(false, null, "Invalid calculation method. Must be \"hourly\" or \"man_days\"")); + } + + // Validate hours per day + if (hours_per_day && (typeof hours_per_day !== "number" || hours_per_day <= 0)) { + return res.status(400).send(new ServerResponse(false, null, "Invalid hours per day. Must be a positive number")); + } + + const updateQuery = ` + UPDATE projects + SET calculation_method = $1, + hours_per_day = COALESCE($2, hours_per_day), + updated_at = NOW() + WHERE id = $3 + RETURNING id, name, calculation_method, hours_per_day; + `; + + const result = await db.query(updateQuery, [calculation_method, hours_per_day, projectId]); + + if (result.rows.length === 0) { + return res.status(404).send(new ServerResponse(false, null, "Project not found")); + } + + return res.status(200).send(new ServerResponse(true, { + project: result.rows[0], + message: "Project calculation method updated successfully" + })); + } + + @HandleExceptions() + public static async updateRateCardManDayRate( + req: IWorkLenzRequest, + res: IWorkLenzResponse + ): Promise { + const { rate_card_role_id } = req.params; + const { man_day_rate } = req.body; + + // Validate man day rate + if (typeof man_day_rate !== "number" || man_day_rate < 0) { + return res.status(400).send(new ServerResponse(false, null, "Invalid man day rate. Must be a non-negative number")); + } + + const updateQuery = ` + UPDATE finance_project_rate_card_roles + SET man_day_rate = $1, updated_at = NOW() + WHERE id = $2 + RETURNING id, project_id, job_title_id, rate, man_day_rate; + `; + + const result = await db.query(updateQuery, [man_day_rate, rate_card_role_id]); + + if (result.rows.length === 0) { + return res.status(404).send(new ServerResponse(false, null, "Rate card role not found")); + } + + return res.status(200).send(new ServerResponse(true, { + rate_card_role: result.rows[0], + message: "Man day rate updated successfully" + })); + } +} \ No newline at end of file diff --git a/worklenz-backend/src/controllers/project-ratecard-controller.ts b/worklenz-backend/src/controllers/project-ratecard-controller.ts new file mode 100644 index 00000000..54d1cd75 --- /dev/null +++ b/worklenz-backend/src/controllers/project-ratecard-controller.ts @@ -0,0 +1,292 @@ +import db from "../config/db"; +import { IWorkLenzRequest } from "../interfaces/worklenz-request"; +import { IWorkLenzResponse } from "../interfaces/worklenz-response"; +import { ServerResponse } from "../models/server-response"; +import HandleExceptions from "../decorators/handle-exceptions"; +import WorklenzControllerBase from "./worklenz-controller-base"; + +export default class ProjectRateCardController extends WorklenzControllerBase { + + // Insert a single role for a project + @HandleExceptions() + public static async createOne(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + const { project_id, job_title_id, rate, man_day_rate } = req.body; + if (!project_id || !job_title_id || typeof rate !== "number") { + return res.status(400).send(new ServerResponse(false, null, "Invalid input")); + } + + // Handle both rate and man_day_rate fields + const columns = ["project_id", "job_title_id", "rate"]; + const values = [project_id, job_title_id, rate]; + + if (typeof man_day_rate !== "undefined") { + columns.push("man_day_rate"); + values.push(man_day_rate); + } + + const q = ` + INSERT INTO finance_project_rate_card_roles (${columns.join(", ")}) + VALUES (${values.map((_, i) => `$${i + 1}`).join(", ")}) + ON CONFLICT (project_id, job_title_id) DO UPDATE SET + rate = EXCLUDED.rate${typeof man_day_rate !== "undefined" ? ", man_day_rate = EXCLUDED.man_day_rate" : ""} + RETURNING *, + (SELECT name FROM job_titles jt WHERE jt.id = finance_project_rate_card_roles.job_title_id) AS jobtitle; + `; + const result = await db.query(q, values); + return res.status(200).send(new ServerResponse(true, result.rows[0])); + } + // Insert multiple roles for a project + @HandleExceptions() + public static async createMany(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + const { project_id, roles } = req.body; + if (!Array.isArray(roles) || !project_id) { + return res.status(400).send(new ServerResponse(false, null, "Invalid input")); + } + + // Handle both rate and man_day_rate fields for each role + const columns = ["project_id", "job_title_id", "rate", "man_day_rate"]; + const values = roles.map((role: any) => [ + project_id, + role.job_title_id, + typeof role.rate !== "undefined" ? role.rate : 0, + typeof role.man_day_rate !== "undefined" ? role.man_day_rate : 0 + ]); + + const q = ` + INSERT INTO finance_project_rate_card_roles (${columns.join(", ")}) + VALUES ${values.map((_, i) => `($${i * 4 + 1}, $${i * 4 + 2}, $${i * 4 + 3}, $${i * 4 + 4})`).join(",")} + ON CONFLICT (project_id, job_title_id) DO UPDATE SET + rate = EXCLUDED.rate, + man_day_rate = EXCLUDED.man_day_rate + RETURNING *, + (SELECT name FROM job_titles jt WHERE jt.id = finance_project_rate_card_roles.job_title_id) AS jobtitle; + `; + const flatValues = values.flat(); + const result = await db.query(q, flatValues); + return res.status(200).send(new ServerResponse(true, result.rows)); + } + + // Get all roles for a project + @HandleExceptions() + public static async getByProjectId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + const { project_id } = req.params; + const q = ` + SELECT + fprr.*, + jt.name as jobtitle, + ( + SELECT COALESCE(json_agg(pm.id), '[]'::json) + FROM project_members pm + WHERE pm.project_rate_card_role_id = fprr.id + ) AS members + FROM finance_project_rate_card_roles fprr + LEFT JOIN job_titles jt ON fprr.job_title_id = jt.id + WHERE fprr.project_id = $1 + ORDER BY fprr.created_at; + `; + const result = await db.query(q, [project_id]); + return res.status(200).send(new ServerResponse(true, result.rows)); + } + + // Get a single role by id + @HandleExceptions() + public static async getById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + const { id } = req.params; + const q = ` + SELECT + fprr.*, + jt.name as jobtitle, + ( + SELECT COALESCE(json_agg(pm.id), '[]'::json) + FROM project_members pm + WHERE pm.project_rate_card_role_id = fprr.id + ) AS members + FROM finance_project_rate_card_roles fprr + LEFT JOIN job_titles jt ON fprr.job_title_id = jt.id + WHERE fprr.id = $1; + `; + const result = await db.query(q, [id]); + return res.status(200).send(new ServerResponse(true, result.rows[0])); + } + + // Update a single role by id + @HandleExceptions() + public static async updateById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + const { id } = req.params; + const { job_title_id, rate, man_day_rate } = req.body; + let setClause = "job_title_id = $1, updated_at = NOW()"; + const values = [job_title_id]; + if (typeof man_day_rate !== "undefined") { + setClause += ", man_day_rate = $2"; + values.push(man_day_rate); + } else { + setClause += ", rate = $2"; + values.push(rate); + } + values.push(id); + const q = ` + WITH updated AS ( + UPDATE finance_project_rate_card_roles + SET ${setClause} + WHERE id = $3 + RETURNING * + ), + jobtitles AS ( + SELECT u.*, jt.name AS jobtitle + FROM updated u + JOIN job_titles jt ON jt.id = u.job_title_id + ), + members AS ( + SELECT json_agg(pm.id) AS members, pm.project_rate_card_role_id + FROM project_members pm + WHERE pm.project_rate_card_role_id IN (SELECT id FROM jobtitles) + GROUP BY pm.project_rate_card_role_id + ) + SELECT jt.*, m.members + FROM jobtitles jt + LEFT JOIN members m ON m.project_rate_card_role_id = jt.id; + `; + const result = await db.query(q, values); + return res.status(200).send(new ServerResponse(true, result.rows[0])); + } + + // update project member rate for a project with members + @HandleExceptions() + public static async updateProjectMemberByProjectIdAndMemberId( + req: IWorkLenzRequest, + res: IWorkLenzResponse + ): Promise { + const { project_id, id } = req.params; + const { project_rate_card_role_id } = req.body; + + if (!project_id || !id || !project_rate_card_role_id) { + return res.status(400).send(new ServerResponse(false, null, "Missing values")); + } + + try { + // Step 1: Check current role assignment + const checkQuery = ` + SELECT project_rate_card_role_id + FROM project_members + WHERE id = $1 AND project_id = $2; + `; + const { rows: checkRows } = await db.query(checkQuery, [id, project_id]); + + const currentRoleId = checkRows[0]?.project_rate_card_role_id; + + if (currentRoleId !== null && currentRoleId !== project_rate_card_role_id) { + // Step 2: Fetch members with the requested role + const membersQuery = ` + SELECT COALESCE(json_agg(id), '[]'::json) AS members + FROM project_members + WHERE project_id = $1 AND project_rate_card_role_id = $2; + `; + const { rows: memberRows } = await db.query(membersQuery, [project_id, project_rate_card_role_id]); + + return res.status(200).send( + new ServerResponse(false, memberRows[0], "Already Assigned !") + ); + } + + // Step 3: Perform the update + const updateQuery = ` + UPDATE project_members + SET project_rate_card_role_id = CASE + WHEN project_rate_card_role_id = $1 THEN NULL + ELSE $1 + END + WHERE id = $2 + AND project_id = $3 + AND EXISTS ( + SELECT 1 + FROM finance_project_rate_card_roles + WHERE id = $1 AND project_id = $3 + ) + RETURNING project_rate_card_role_id; + `; + const { rows: updateRows } = await db.query(updateQuery, [project_rate_card_role_id, id, project_id]); + + if (updateRows.length === 0) { + return res.status(200).send(new ServerResponse(true, [], "Project member not found or invalid project_rate_card_role_id")); + } + + const updatedRoleId = updateRows[0].project_rate_card_role_id || project_rate_card_role_id; + + // Step 4: Fetch updated members list + const membersQuery = ` + SELECT COALESCE(json_agg(id), '[]'::json) AS members + FROM project_members + WHERE project_id = $1 AND project_rate_card_role_id = $2; + `; + const { rows: finalMembers } = await db.query(membersQuery, [project_id, updatedRoleId]); + + return res.status(200).send(new ServerResponse(true, finalMembers[0])); + } catch (error) { + return res.status(500).send(new ServerResponse(false, null, "Internal server error")); + } + } + // Update all roles for a project (delete then insert) + @HandleExceptions() + public static async updateByProjectId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + const { project_id, roles } = req.body; + if (!Array.isArray(roles) || !project_id) { + return res.status(400).send(new ServerResponse(false, null, "Invalid input")); + } + if (roles.length === 0) { + // If no roles provided, do nothing and return empty array + return res.status(200).send(new ServerResponse(true, [])); + } + // Build upsert query for all roles + const columns = ["project_id", "job_title_id", "rate", "man_day_rate"]; + const values = roles.map((role: any) => [ + project_id, + role.job_title_id, + typeof role.rate !== "undefined" ? role.rate : null, + typeof role.man_day_rate !== "undefined" ? role.man_day_rate : null + ]); + const q = ` + WITH upserted AS ( + INSERT INTO finance_project_rate_card_roles (${columns.join(", ")}) + VALUES ${values.map((_, i) => `($${i * 4 + 1}, $${i * 4 + 2}, $${i * 4 + 3}, $${i * 4 + 4})`).join(",")} + ON CONFLICT (project_id, job_title_id) + DO UPDATE SET rate = EXCLUDED.rate, man_day_rate = EXCLUDED.man_day_rate, updated_at = NOW() + RETURNING * + ), + jobtitles AS ( + SELECT upr.*, jt.name AS jobtitle + FROM upserted upr + JOIN job_titles jt ON jt.id = upr.job_title_id + ), + members AS ( + SELECT json_agg(pm.id) AS members, pm.project_rate_card_role_id + FROM project_members pm + WHERE pm.project_rate_card_role_id IN (SELECT id FROM jobtitles) + GROUP BY pm.project_rate_card_role_id + ) + SELECT jt.*, m.members + FROM jobtitles jt + LEFT JOIN members m ON m.project_rate_card_role_id = jt.id; + `; + const flatValues = values.flat(); + const result = await db.query(q, flatValues); + return res.status(200).send(new ServerResponse(true, result.rows)); + } + + // Delete a single role by id + @HandleExceptions() + public static async deleteById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + const { id } = req.params; + const q = `DELETE FROM finance_project_rate_card_roles WHERE id = $1 RETURNING *;`; + const result = await db.query(q, [id]); + return res.status(200).send(new ServerResponse(true, result.rows[0])); + } + + // Delete all roles for a project + @HandleExceptions() + public static async deleteByProjectId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + const { project_id } = req.params; + const q = `DELETE FROM finance_project_rate_card_roles WHERE project_id = $1 RETURNING *;`; + const result = await db.query(q, [project_id]); + return res.status(200).send(new ServerResponse(true, result.rows)); + } +} \ No newline at end of file diff --git a/worklenz-backend/src/controllers/ratecard-controller.ts b/worklenz-backend/src/controllers/ratecard-controller.ts new file mode 100644 index 00000000..ed035482 --- /dev/null +++ b/worklenz-backend/src/controllers/ratecard-controller.ts @@ -0,0 +1,198 @@ +import { IWorkLenzRequest } from "../interfaces/worklenz-request"; +import { IWorkLenzResponse } from "../interfaces/worklenz-response"; +import db from "../config/db"; +import { ServerResponse } from "../models/server-response"; +import WorklenzControllerBase from "./worklenz-controller-base"; +import HandleExceptions from "../decorators/handle-exceptions"; + +export default class RateCardController extends WorklenzControllerBase { + @HandleExceptions() + public static async create( + req: IWorkLenzRequest, + res: IWorkLenzResponse + ): Promise { + const q = ` + INSERT INTO finance_rate_cards (team_id, name) + VALUES ($1, $2) + RETURNING id, name, team_id, created_at, updated_at; + `; + const result = await db.query(q, [ + req.user?.team_id || null, + req.body.name, + ]); + const [data] = result.rows; + return res.status(200).send(new ServerResponse(true, data)); + } + + @HandleExceptions() + public static async get( + req: IWorkLenzRequest, + res: IWorkLenzResponse + ): Promise { + const { searchQuery, sortField, sortOrder, size, offset } = + this.toPaginationOptions(req.query, "name"); + + const q = ` + SELECT ROW_TO_JSON(rec) AS rate_cards + FROM ( + SELECT COUNT(*) AS total, + ( + SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(t))), '[]'::JSON) + FROM ( + SELECT id, name, team_id, currency, created_at, updated_at + FROM finance_rate_cards + WHERE team_id = $1 ${searchQuery} + ORDER BY ${sortField} ${sortOrder} + LIMIT $2 OFFSET $3 + ) t + ) AS data + FROM finance_rate_cards + WHERE team_id = $1 ${searchQuery} + ) rec; + `; + const result = await db.query(q, [req.user?.team_id || null, size, offset]); + const [data] = result.rows; + + return res + .status(200) + .send( + new ServerResponse( + true, + data.rate_cards || this.paginatedDatasetDefaultStruct + ) + ); + } + + @HandleExceptions() + public static async getById( + req: IWorkLenzRequest, + res: IWorkLenzResponse + ): Promise { + // 1. Fetch the rate card + const q = ` + SELECT id, name, team_id, currency, created_at, updated_at + FROM finance_rate_cards + WHERE id = $1 AND team_id = $2; + `; + const result = await db.query(q, [ + req.params.id, + req.user?.team_id || null, + ]); + const [data] = result.rows; + + if (!data) { + return res + .status(404) + .send(new ServerResponse(false, null, "Rate card not found")); + } + + // 2. Fetch job roles with job title names + const jobRolesQ = ` + SELECT + rcr.job_title_id, + jt.name AS jobTitle, + rcr.rate, + rcr.man_day_rate, + rcr.rate_card_id + FROM finance_rate_card_roles rcr + LEFT JOIN job_titles jt ON rcr.job_title_id = jt.id + WHERE rcr.rate_card_id = $1 + `; + const jobRolesResult = await db.query(jobRolesQ, [req.params.id]); + const jobRolesList = jobRolesResult.rows; + + // 3. Return the rate card with jobRolesList + return res.status(200).send( + new ServerResponse(true, { + ...data, + jobRolesList, + }) + ); + } + + @HandleExceptions() + public static async update( + req: IWorkLenzRequest, + res: IWorkLenzResponse + ): Promise { + // 1. Update the rate card + const updateRateCardQ = ` + UPDATE finance_rate_cards + SET name = $3, currency = $4, updated_at = NOW() + WHERE id = $1 AND team_id = $2 + RETURNING id, name, team_id, currency, created_at, updated_at; + `; + const result = await db.query(updateRateCardQ, [ + req.params.id, + req.user?.team_id || null, + req.body.name, + req.body.currency, + ]); + const [rateCardData] = result.rows; + + // 2. Update job roles (delete old, insert new) + if (Array.isArray(req.body.jobRolesList)) { + // Delete existing roles for this rate card + await db.query( + `DELETE FROM finance_rate_card_roles WHERE rate_card_id = $1;`, + [req.params.id] + ); + + // Insert new roles + for (const role of req.body.jobRolesList) { + if (role.job_title_id) { + await db.query( + `INSERT INTO finance_rate_card_roles (rate_card_id, job_title_id, rate, man_day_rate) + VALUES ($1, $2, $3, $4);`, + [ + req.params.id, + role.job_title_id, + role.rate ?? 0, + role.man_day_rate ?? 0, + ] + ); + } + } + } + + // 3. Get jobRolesList with job title names + const jobRolesQ = ` + SELECT + rcr.job_title_id, + jt.name AS jobTitle, + rcr.rate + FROM finance_rate_card_roles rcr + LEFT JOIN job_titles jt ON rcr.job_title_id = jt.id + WHERE rcr.rate_card_id = $1 + `; + const jobRolesResult = await db.query(jobRolesQ, [req.params.id]); + const jobRolesList = jobRolesResult.rows; + + // 4. Return the updated rate card with jobRolesList + return res.status(200).send( + new ServerResponse(true, { + ...rateCardData, + jobRolesList, + }) + ); + } + + @HandleExceptions() + public static async deleteById( + req: IWorkLenzRequest, + res: IWorkLenzResponse + ): Promise { + const q = ` + DELETE FROM finance_rate_cards + WHERE id = $1 AND team_id = $2 + RETURNING id; + `; + const result = await db.query(q, [ + req.params.id, + req.user?.team_id || null, + ]); + return res + .status(200) + .send(new ServerResponse(true, result.rows.length > 0)); + } +} diff --git a/worklenz-backend/src/routes/apis/index.ts b/worklenz-backend/src/routes/apis/index.ts index 5a2019c8..f5bcc5e1 100644 --- a/worklenz-backend/src/routes/apis/index.ts +++ b/worklenz-backend/src/routes/apis/index.ts @@ -1,120 +1,130 @@ -import express from "express"; - -import AccessControlsController from "../../controllers/access-controls-controller"; -import AuthController from "../../controllers/auth-controller"; -import LogsController from "../../controllers/logs-controller"; -import OverviewController from "../../controllers/overview-controller"; -import TaskPrioritiesController from "../../controllers/task-priorities-controller"; - -import attachmentsApiRouter from "./attachments-api-router"; -import clientsApiRouter from "./clients-api-router"; -import jobTitlesApiRouter from "./job-titles-api-router"; -import notificationsApiRouter from "./notifications-api-router"; -import personalOverviewApiRouter from "./personal-overview-api-router"; -import projectMembersApiRouter from "./project-members-api-router"; -import projectsApiRouter from "./projects-api-router"; -import settingsApiRouter from "./settings-api-router"; -import statusesApiRouter from "./statuses-api-router"; -import subTasksApiRouter from "./sub-tasks-api-router"; -import taskCommentsApiRouter from "./task-comments-api-router"; -import taskWorkLogApiRouter from "./task-work-log-api-router"; -import tasksApiRouter from "./tasks-api-router"; -import teamMembersApiRouter from "./team-members-api-router"; -import teamsApiRouter from "./teams-api-router"; -import timezonesApiRouter from "./timezones-api-router"; -import todoListApiRouter from "./todo-list-api-router"; -import projectStatusesApiRouter from "./project-statuses-api-router"; -import labelsApiRouter from "./labels-api-router"; -import sharedProjectsApiRouter from "./shared-projects-api-router"; -import resourceAllocationApiRouter from "./resource-allocation-api-router"; -import taskTemplatesApiRouter from "./task-templates-api-router"; -import projectInsightsApiRouter from "./project-insights-api-router"; -import passwordValidator from "../../middlewares/validators/password-validator"; -import adminCenterApiRouter from "./admin-center-api-router"; -import reportingApiRouter from "./reporting-api-router"; -import activityLogsApiRouter from "./activity-logs-api-router"; -import safeControllerFunction from "../../shared/safe-controller-function"; -import projectFoldersApiRouter from "./project-folders-api-router"; -import taskPhasesApiRouter from "./task-phases-api-router"; -import projectCategoriesApiRouter from "./project-categories-api-router"; -import homePageApiRouter from "./home-page-api-router"; -import ganttApiRouter from "./gantt-api-router"; -import projectCommentsApiRouter from "./project-comments-api-router"; -import reportingExportApiRouter from "./reporting-export-api-router"; -import projectHealthsApiRouter from "./project-healths-api-router"; -import ptTasksApiRouter from "./pt-tasks-api-router"; -import projectTemplatesApiRouter from "./project-templates-api"; -import ptTaskPhasesApiRouter from "./pt_task-phases-api-router"; -import ptStatusesApiRouter from "./pt-statuses-api-router"; -import workloadApiRouter from "./gannt-apis/workload-api-router"; -import roadmapApiRouter from "./gannt-apis/roadmap-api-router"; -import scheduleApiRouter from "./gannt-apis/schedule-api-router"; -import scheduleApiV2Router from "./gannt-apis/schedule-api-v2-router"; -import projectManagerApiRouter from "./project-managers-api-router"; - -import billingApiRouter from "./billing-api-router"; -import taskDependenciesApiRouter from "./task-dependencies-api-router"; - -import taskRecurringApiRouter from "./task-recurring-api-router"; - import customColumnsApiRouter from "./custom-columns-api-router"; +import express from "express"; + +import AccessControlsController from "../../controllers/access-controls-controller"; +import AuthController from "../../controllers/auth-controller"; +import LogsController from "../../controllers/logs-controller"; +import OverviewController from "../../controllers/overview-controller"; +import TaskPrioritiesController from "../../controllers/task-priorities-controller"; + +import attachmentsApiRouter from "./attachments-api-router"; +import clientsApiRouter from "./clients-api-router"; +import jobTitlesApiRouter from "./job-titles-api-router"; +import notificationsApiRouter from "./notifications-api-router"; +import personalOverviewApiRouter from "./personal-overview-api-router"; +import projectMembersApiRouter from "./project-members-api-router"; +import projectsApiRouter from "./projects-api-router"; +import settingsApiRouter from "./settings-api-router"; +import statusesApiRouter from "./statuses-api-router"; +import subTasksApiRouter from "./sub-tasks-api-router"; +import taskCommentsApiRouter from "./task-comments-api-router"; +import taskWorkLogApiRouter from "./task-work-log-api-router"; +import tasksApiRouter from "./tasks-api-router"; +import teamMembersApiRouter from "./team-members-api-router"; +import teamsApiRouter from "./teams-api-router"; +import timezonesApiRouter from "./timezones-api-router"; +import todoListApiRouter from "./todo-list-api-router"; +import projectStatusesApiRouter from "./project-statuses-api-router"; +import labelsApiRouter from "./labels-api-router"; +import sharedProjectsApiRouter from "./shared-projects-api-router"; +import resourceAllocationApiRouter from "./resource-allocation-api-router"; +import taskTemplatesApiRouter from "./task-templates-api-router"; +import projectInsightsApiRouter from "./project-insights-api-router"; +import passwordValidator from "../../middlewares/validators/password-validator"; +import adminCenterApiRouter from "./admin-center-api-router"; +import reportingApiRouter from "./reporting-api-router"; +import activityLogsApiRouter from "./activity-logs-api-router"; +import safeControllerFunction from "../../shared/safe-controller-function"; +import projectFoldersApiRouter from "./project-folders-api-router"; +import taskPhasesApiRouter from "./task-phases-api-router"; +import projectCategoriesApiRouter from "./project-categories-api-router"; +import homePageApiRouter from "./home-page-api-router"; +import ganttApiRouter from "./gantt-api-router"; +import projectCommentsApiRouter from "./project-comments-api-router"; +import reportingExportApiRouter from "./reporting-export-api-router"; +import projectHealthsApiRouter from "./project-healths-api-router"; +import ptTasksApiRouter from "./pt-tasks-api-router"; +import projectTemplatesApiRouter from "./project-templates-api"; +import ptTaskPhasesApiRouter from "./pt_task-phases-api-router"; +import ptStatusesApiRouter from "./pt-statuses-api-router"; +import workloadApiRouter from "./gannt-apis/workload-api-router"; +import roadmapApiRouter from "./gannt-apis/roadmap-api-router"; +import scheduleApiRouter from "./gannt-apis/schedule-api-router"; +import scheduleApiV2Router from "./gannt-apis/schedule-api-v2-router"; +import projectManagerApiRouter from "./project-managers-api-router"; + +import billingApiRouter from "./billing-api-router"; +import taskDependenciesApiRouter from "./task-dependencies-api-router"; + +import taskRecurringApiRouter from "./task-recurring-api-router"; + +import customColumnsApiRouter from "./custom-columns-api-router"; +import projectFinanceApiRouter from "./project-finance-api-router"; +import projectRatecardApiRouter from "./project-ratecard-api-router"; +import ratecardApiRouter from "./ratecard-api-router"; + +const api = express.Router(); + +api.use("/projects", projectsApiRouter); +api.use("/team-members", teamMembersApiRouter); +api.use("/job-titles", jobTitlesApiRouter); +api.use("/clients", clientsApiRouter); +api.use("/teams", teamsApiRouter); +api.use("/tasks", tasksApiRouter); +api.use("/settings", settingsApiRouter); +api.use("/personal-overview", personalOverviewApiRouter); +api.use("/statuses", statusesApiRouter); +api.use("/todo-list", todoListApiRouter); +api.use("/notifications", notificationsApiRouter); +api.use("/attachments", attachmentsApiRouter); +api.use("/sub-tasks", subTasksApiRouter); +api.use("/project-members", projectMembersApiRouter); +api.use("/task-time-log", taskWorkLogApiRouter); +api.use("/task-comments", taskCommentsApiRouter); +api.use("/timezones", timezonesApiRouter); +api.use("/project-statuses", projectStatusesApiRouter); +api.use("/labels", labelsApiRouter); +api.use("/resource-allocation", resourceAllocationApiRouter); +api.use("/shared/projects", sharedProjectsApiRouter); +api.use("/task-templates", taskTemplatesApiRouter); +api.use("/project-insights", projectInsightsApiRouter); +api.use("/admin-center", adminCenterApiRouter); +api.use("/reporting", reportingApiRouter); +api.use("/activity-logs", activityLogsApiRouter); +api.use("/projects-folders", projectFoldersApiRouter); +api.use("/task-phases", taskPhasesApiRouter); +api.use("/project-categories", projectCategoriesApiRouter); +api.use("/home", homePageApiRouter); +api.use("/gantt", ganttApiRouter); +api.use("/project-comments", projectCommentsApiRouter); +api.use("/reporting-export", reportingExportApiRouter); +api.use("/project-healths", projectHealthsApiRouter); +api.use("/project-templates", projectTemplatesApiRouter); +api.use("/pt-tasks", ptTasksApiRouter); +api.use("/pt-task-phases", ptTaskPhasesApiRouter); +api.use("/pt-statuses", ptStatusesApiRouter); +api.use("/workload-gannt", workloadApiRouter); +api.use("/roadmap-gannt", roadmapApiRouter); +api.use("/schedule-gannt", scheduleApiRouter); +api.use("/schedule-gannt-v2", scheduleApiV2Router); +api.use("/project-managers", projectManagerApiRouter); + +api.get("/overview/:id", safeControllerFunction(OverviewController.getById)); +api.get("/task-priorities", safeControllerFunction(TaskPrioritiesController.get)); +api.post("/change-password", passwordValidator, safeControllerFunction(AuthController.changePassword)); +api.get("/access-controls/roles", safeControllerFunction(AccessControlsController.getRoles)); +api.get("/logs/my-dashboard", safeControllerFunction(LogsController.getActivityLog)); + +api.use("/billing", billingApiRouter); +api.use("/task-dependencies", taskDependenciesApiRouter); + +api.use("/task-recurring", taskRecurringApiRouter); -const api = express.Router(); - -api.use("/projects", projectsApiRouter); -api.use("/team-members", teamMembersApiRouter); -api.use("/job-titles", jobTitlesApiRouter); -api.use("/clients", clientsApiRouter); -api.use("/teams", teamsApiRouter); -api.use("/tasks", tasksApiRouter); -api.use("/settings", settingsApiRouter); -api.use("/personal-overview", personalOverviewApiRouter); -api.use("/statuses", statusesApiRouter); -api.use("/todo-list", todoListApiRouter); -api.use("/notifications", notificationsApiRouter); -api.use("/attachments", attachmentsApiRouter); -api.use("/sub-tasks", subTasksApiRouter); -api.use("/project-members", projectMembersApiRouter); -api.use("/task-time-log", taskWorkLogApiRouter); -api.use("/task-comments", taskCommentsApiRouter); -api.use("/timezones", timezonesApiRouter); -api.use("/project-statuses", projectStatusesApiRouter); -api.use("/labels", labelsApiRouter); -api.use("/resource-allocation", resourceAllocationApiRouter); -api.use("/shared/projects", sharedProjectsApiRouter); -api.use("/task-templates", taskTemplatesApiRouter); -api.use("/project-insights", projectInsightsApiRouter); -api.use("/admin-center", adminCenterApiRouter); -api.use("/reporting", reportingApiRouter); -api.use("/activity-logs", activityLogsApiRouter); -api.use("/projects-folders", projectFoldersApiRouter); -api.use("/task-phases", taskPhasesApiRouter); -api.use("/project-categories", projectCategoriesApiRouter); -api.use("/home", homePageApiRouter); -api.use("/gantt", ganttApiRouter); -api.use("/project-comments", projectCommentsApiRouter); -api.use("/reporting-export", reportingExportApiRouter); -api.use("/project-healths", projectHealthsApiRouter); -api.use("/project-templates", projectTemplatesApiRouter); -api.use("/pt-tasks", ptTasksApiRouter); -api.use("/pt-task-phases", ptTaskPhasesApiRouter); -api.use("/pt-statuses", ptStatusesApiRouter); -api.use("/workload-gannt", workloadApiRouter); -api.use("/roadmap-gannt", roadmapApiRouter); -api.use("/schedule-gannt", scheduleApiRouter); -api.use("/schedule-gannt-v2", scheduleApiV2Router); -api.use("/project-managers", projectManagerApiRouter); - -api.get("/overview/:id", safeControllerFunction(OverviewController.getById)); -api.get("/task-priorities", safeControllerFunction(TaskPrioritiesController.get)); -api.post("/change-password", passwordValidator, safeControllerFunction(AuthController.changePassword)); -api.get("/access-controls/roles", safeControllerFunction(AccessControlsController.getRoles)); -api.get("/logs/my-dashboard", safeControllerFunction(LogsController.getActivityLog)); - -api.use("/billing", billingApiRouter); -api.use("/task-dependencies", taskDependenciesApiRouter); - -api.use("/task-recurring", taskRecurringApiRouter); - api.use("/custom-columns", customColumnsApiRouter); -export default api; +api.use("/project-finance", projectFinanceApiRouter); + +api.use("/project-ratecard", projectRatecardApiRouter); + +api.use("/ratecard", ratecardApiRouter); + +export default api; diff --git a/worklenz-backend/src/routes/apis/project-finance-api-router.ts b/worklenz-backend/src/routes/apis/project-finance-api-router.ts new file mode 100644 index 00000000..2436fd17 --- /dev/null +++ b/worklenz-backend/src/routes/apis/project-finance-api-router.ts @@ -0,0 +1,50 @@ +import express from "express"; + +import ProjectfinanceController from "../../controllers/project-finance-controller"; +import idParamValidator from "../../middlewares/validators/id-param-validator"; +import safeControllerFunction from "../../shared/safe-controller-function"; + +const projectFinanceApiRouter = express.Router(); + +projectFinanceApiRouter.get( + "/project/:project_id/tasks", + safeControllerFunction(ProjectfinanceController.getTasks) +); +projectFinanceApiRouter.get( + "/project/:project_id/tasks/:parent_task_id/subtasks", + safeControllerFunction(ProjectfinanceController.getSubTasks) +); +projectFinanceApiRouter.get( + "/task/:id/breakdown", + idParamValidator, + safeControllerFunction(ProjectfinanceController.getTaskBreakdown) +); +projectFinanceApiRouter.put( + "/task/:task_id/fixed-cost", + safeControllerFunction(ProjectfinanceController.updateTaskFixedCost) +); + +projectFinanceApiRouter.put( + "/project/:project_id/currency", + safeControllerFunction(ProjectfinanceController.updateProjectCurrency) +); +projectFinanceApiRouter.put( + "/project/:project_id/budget", + safeControllerFunction(ProjectfinanceController.updateProjectBudget) +); +projectFinanceApiRouter.put( + "/project/:project_id/calculation-method", + safeControllerFunction( + ProjectfinanceController.updateProjectCalculationMethod + ) +); +projectFinanceApiRouter.put( + "/rate-card-role/:rate_card_role_id/man-day-rate", + safeControllerFunction(ProjectfinanceController.updateRateCardManDayRate) +); +projectFinanceApiRouter.get( + "/project/:project_id/export", + safeControllerFunction(ProjectfinanceController.exportFinanceData) +); + +export default projectFinanceApiRouter; diff --git a/worklenz-backend/src/routes/apis/project-ratecard-api-router.ts b/worklenz-backend/src/routes/apis/project-ratecard-api-router.ts new file mode 100644 index 00000000..414e239f --- /dev/null +++ b/worklenz-backend/src/routes/apis/project-ratecard-api-router.ts @@ -0,0 +1,19 @@ +import express from "express"; +import ProjectRateCardController from "../../controllers/project-ratecard-controller"; +import idParamValidator from "../../middlewares/validators/id-param-validator"; +import safeControllerFunction from "../../shared/safe-controller-function"; +import projectManagerValidator from "../../middlewares/validators/project-manager-validator"; + +const projectRatecardApiRouter = express.Router(); + +projectRatecardApiRouter.post("/", projectManagerValidator, safeControllerFunction(ProjectRateCardController.createMany)); +projectRatecardApiRouter.post("/create-project-rate-card-role",projectManagerValidator,safeControllerFunction(ProjectRateCardController.createOne)); +projectRatecardApiRouter.get("/project/:project_id",safeControllerFunction(ProjectRateCardController.getByProjectId)); +projectRatecardApiRouter.get("/:id",idParamValidator,safeControllerFunction(ProjectRateCardController.getById)); +projectRatecardApiRouter.put("/:id",idParamValidator,safeControllerFunction(ProjectRateCardController.updateById)); +projectRatecardApiRouter.put("/project/:project_id",safeControllerFunction(ProjectRateCardController.updateByProjectId)); +projectRatecardApiRouter.put("/project/:project_id/members/:id/rate-card-role",idParamValidator,projectManagerValidator,safeControllerFunction( ProjectRateCardController.updateProjectMemberByProjectIdAndMemberId)); +projectRatecardApiRouter.delete("/:id",idParamValidator,safeControllerFunction(ProjectRateCardController.deleteById)); +projectRatecardApiRouter.delete("/project/:project_id",safeControllerFunction(ProjectRateCardController.deleteByProjectId)); + +export default projectRatecardApiRouter; \ No newline at end of file diff --git a/worklenz-backend/src/routes/apis/ratecard-api-router.ts b/worklenz-backend/src/routes/apis/ratecard-api-router.ts new file mode 100644 index 00000000..7df8da13 --- /dev/null +++ b/worklenz-backend/src/routes/apis/ratecard-api-router.ts @@ -0,0 +1,13 @@ +import express from "express"; + +import RatecardController from "../../controllers/ratecard-controller"; + +const ratecardApiRouter = express.Router(); + +ratecardApiRouter.post("/", RatecardController.create); +ratecardApiRouter.get("/", RatecardController.get); +ratecardApiRouter.get("/:id", RatecardController.getById); +ratecardApiRouter.put("/:id", RatecardController.update); +ratecardApiRouter.delete("/:id", RatecardController.deleteById); + +export default ratecardApiRouter; \ No newline at end of file diff --git a/worklenz-frontend/public/locales/alb/project-view-finance.json b/worklenz-frontend/public/locales/alb/project-view-finance.json new file mode 100644 index 00000000..54ab8095 --- /dev/null +++ b/worklenz-frontend/public/locales/alb/project-view-finance.json @@ -0,0 +1,114 @@ +{ + "financeText": "Finance", + "ratecardSingularText": "Rate Card", + "groupByText": "Group by", + "statusText": "Status", + "phaseText": "Phase", + "priorityText": "Priority", + "exportButton": "Export", + "currencyText": "Currency", + "importButton": "Import", + "filterText": "Filter", + "billableOnlyText": "Billable Only", + "nonBillableOnlyText": "Non-Billable Only", + "allTasksText": "All Tasks", + "projectBudgetOverviewText": "Project Budget Overview", + "taskColumn": "Task", + "membersColumn": "Members", + "hoursColumn": "Estimated Hours", + "manDaysColumn": "Estimated Man Days", + "actualManDaysColumn": "Actual Man Days", + "effortVarianceColumn": "Effort Variance", + "totalTimeLoggedColumn": "Total Time Logged", + "costColumn": "Actual Cost", + "estimatedCostColumn": "Estimated Cost", + "fixedCostColumn": "Fixed Cost", + "totalBudgetedCostColumn": "Total Budgeted Cost", + "totalActualCostColumn": "Total Actual Cost", + "varianceColumn": "Variance", + "totalText": "Total", + "noTasksFound": "No tasks found", + "addRoleButton": "+ Add Role", + "ratecardImportantNotice": "* This rate card is generated based on the company's standard job titles and rates. However, you have the flexibility to modify it according to the project. These changes will not impact the organization's standard job titles and rates.", + "saveButton": "Save", + "jobTitleColumn": "Job Title", + "ratePerHourColumn": "Rate per hour", + "ratePerManDayColumn": "Rate per man day", + "calculationMethodText": "Calculation Method", + "hourlyRatesText": "Hourly Rates", + "manDaysText": "Man Days", + "hoursPerDayText": "Hours per Day", + "ratecardPluralText": "Rate Cards", + "labourHoursColumn": "Labour Hours", + "actions": "Actions", + "selectJobTitle": "Select Job Title", + "ratecardsPluralText": "Rate Card Templates", + "deleteConfirm": "Are you sure ?", + "yes": "Yes", + "no": "No", + "alreadyImportedRateCardMessage": "A rate card has already been imported. Clear all imported rate cards to add a new one.", + "budgetOverviewTooltips": { + "manualBudget": "Manual project budget amount set by project manager", + "totalActualCost": "Total actual cost including fixed costs", + "variance": "Difference between manual budget and actual cost", + "utilization": "Percentage of manual budget utilized", + "estimatedHours": "Total estimated hours from all tasks", + "fixedCosts": "Total fixed costs from all tasks", + "timeBasedCost": "Actual cost from time tracking (excluding fixed costs)", + "remainingBudget": "Remaining budget amount" + }, + "budgetModal": { + "title": "Edit Project Budget", + "description": "Set a manual budget for this project. This budget will be used for all financial calculations and should include both time-based costs and fixed costs.", + "placeholder": "Enter budget amount", + "saveButton": "Save", + "cancelButton": "Cancel" + }, + "budgetStatistics": { + "manualBudget": "Manual Budget", + "totalActualCost": "Total Actual Cost", + "variance": "Variance", + "budgetUtilization": "Budget Utilization", + "estimatedHours": "Estimated Hours", + "fixedCosts": "Fixed Costs", + "timeBasedCost": "Time-based Cost", + "remainingBudget": "Remaining Budget", + "noManualBudgetSet": "(No Manual Budget Set)" + }, + "budgetSettingsDrawer": { + "title": "Project Budget Settings", + "budgetConfiguration": "Budget Configuration", + "projectBudget": "Project Budget", + "projectBudgetTooltip": "Total budget allocated for this project", + "currency": "Currency", + "costCalculationMethod": "Cost Calculation Method", + "calculationMethod": "Calculation Method", + "workingHoursPerDay": "Working Hours per Day", + "workingHoursPerDayTooltip": "Number of working hours in a day for man-day calculations", + "hourlyCalculationInfo": "Costs will be calculated using estimated hours × hourly rates", + "manDaysCalculationInfo": "Costs will be calculated using estimated man days × daily rates", + "importantNotes": "Important Notes", + "calculationMethodChangeNote": "• Changing the calculation method will affect how costs are calculated for all tasks in this project", + "immediateEffectNote": "• Changes take effect immediately and will recalculate all project totals", + "projectWideNote": "• Budget settings apply to the entire project and all its tasks", + "cancel": "Cancel", + "saveChanges": "Save Changes", + "budgetSettingsUpdated": "Budget settings updated successfully", + "budgetSettingsUpdateFailed": "Failed to update budget settings" + }, + "columnTooltips": { + "hours": "Total estimated hours for all tasks. Calculated from task time estimates.", + "manDays": "Total estimated man days for all tasks. Based on {{hoursPerDay}} hours per working day.", + "actualManDays": "Actual man days spent based on time logged. Calculated as: Total Time Logged ÷ {{hoursPerDay}} hours per day.", + "effortVariance": "Difference between estimated and actual man days. Positive values indicate over-estimation, negative values indicate under-estimation.", + "totalTimeLogged": "Total time actually logged by team members across all tasks.", + "estimatedCostHourly": "Estimated cost calculated as: Estimated Hours × Hourly Rates for assigned team members.", + "estimatedCostManDays": "Estimated cost calculated as: Estimated Man Days × Daily Rates for assigned team members.", + "actualCost": "Actual cost based on time logged. Calculated as: Time Logged × Hourly Rates for team members.", + "fixedCost": "Fixed costs that don't depend on time spent. Added manually per task.", + "totalBudgetHourly": "Total budgeted cost including estimated cost (Hours × Hourly Rates) + Fixed Costs.", + "totalBudgetManDays": "Total budgeted cost including estimated cost (Man Days × Daily Rates) + Fixed Costs.", + "totalActual": "Total actual cost including time-based cost + Fixed Costs.", + "variance": "Cost variance: Total Budgeted Costs - Total Actual Cost. Positive values indicate under-budget, negative values indicate over-budget." + } +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/alb/settings/ratecard-settings.json b/worklenz-frontend/public/locales/alb/settings/ratecard-settings.json new file mode 100644 index 00000000..252e7cca --- /dev/null +++ b/worklenz-frontend/public/locales/alb/settings/ratecard-settings.json @@ -0,0 +1,52 @@ +{ + "nameColumn": "Emri", + "createdColumn": "Krijuar", + "noProjectsAvailable": "Nuk ka projekte të disponueshme", + "deleteConfirmationTitle": "Jeni i sigurt që doni të fshini këtë rate card?", + "deleteConfirmationOk": "Po, fshij", + "deleteConfirmationCancel": "Anulo", + "searchPlaceholder": "Kërko rate cards sipas emrit", + "createRatecard": "Krijo Rate Card", + "editTooltip": "Redakto rate card", + "deleteTooltip": "Fshi rate card", + "fetchError": "Dështoi të merret rate card", + "createError": "Dështoi të krijohet rate card", + "deleteSuccess": "Rate card u fshi me sukses", + "deleteError": "Dështoi të fshihet rate card", + + "jobTitleColumn": "Titulli i punës", + "ratePerHourColumn": "Tarifa për orë", + "ratePerDayColumn": "Tarifa për ditë", + "ratePerManDayColumn": "Tarifa për ditë-njeri", + "saveButton": "Ruaj", + "addRoleButton": "Shto rol", + "createRatecardSuccessMessage": "Rate card u krijua me sukses", + "createRatecardErrorMessage": "Dështoi të krijohet rate card", + "updateRatecardSuccessMessage": "Rate card u përditësua me sukses", + "updateRatecardErrorMessage": "Dështoi të përditësohet rate card", + "currency": "Monedha", + "actionsColumn": "Veprime", + "addAllButton": "Shto të gjitha", + "removeAllButton": "Hiq të gjitha", + "selectJobTitle": "Zgjidh titullin e punës", + "unsavedChangesTitle": "Keni ndryshime të paruajtura", + "unsavedChangesMessage": "Dëshironi të ruani ndryshimet para se të largoheni?", + "unsavedChangesSave": "Ruaj", + "unsavedChangesDiscard": "Hidh poshtë", + "ratecardNameRequired": "Emri i rate card është i detyrueshëm", + "ratecardNamePlaceholder": "Shkruani emrin e rate card", + "noRatecardsFound": "Nuk u gjetën rate cards", + "loadingRateCards": "Duke ngarkuar rate cards...", + "noJobTitlesAvailable": "Nuk ka tituj pune të disponueshëm", + "noRolesAdded": "Ende nuk janë shtuar role", + "createFirstJobTitle": "Krijo titullin e parë të punës", + "jobRolesTitle": "Rolet e punës", + "noJobTitlesMessage": "Ju lutemi krijoni tituj pune së pari në cilësimet përpara se të shtoni role në rate cards.", + "createNewJobTitle": "Krijo titull të ri pune", + "jobTitleNamePlaceholder": "Shkruani emrin e titullit të punës", + "jobTitleNameRequired": "Emri i titullit të punës është i detyrueshëm", + "jobTitleCreatedSuccess": "Titulli i punës u krijua me sukses", + "jobTitleCreateError": "Dështoi të krijohet titulli i punës", + "createButton": "Krijo", + "cancelButton": "Anulo" +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/de/project-view-finance.json b/worklenz-frontend/public/locales/de/project-view-finance.json new file mode 100644 index 00000000..54ab8095 --- /dev/null +++ b/worklenz-frontend/public/locales/de/project-view-finance.json @@ -0,0 +1,114 @@ +{ + "financeText": "Finance", + "ratecardSingularText": "Rate Card", + "groupByText": "Group by", + "statusText": "Status", + "phaseText": "Phase", + "priorityText": "Priority", + "exportButton": "Export", + "currencyText": "Currency", + "importButton": "Import", + "filterText": "Filter", + "billableOnlyText": "Billable Only", + "nonBillableOnlyText": "Non-Billable Only", + "allTasksText": "All Tasks", + "projectBudgetOverviewText": "Project Budget Overview", + "taskColumn": "Task", + "membersColumn": "Members", + "hoursColumn": "Estimated Hours", + "manDaysColumn": "Estimated Man Days", + "actualManDaysColumn": "Actual Man Days", + "effortVarianceColumn": "Effort Variance", + "totalTimeLoggedColumn": "Total Time Logged", + "costColumn": "Actual Cost", + "estimatedCostColumn": "Estimated Cost", + "fixedCostColumn": "Fixed Cost", + "totalBudgetedCostColumn": "Total Budgeted Cost", + "totalActualCostColumn": "Total Actual Cost", + "varianceColumn": "Variance", + "totalText": "Total", + "noTasksFound": "No tasks found", + "addRoleButton": "+ Add Role", + "ratecardImportantNotice": "* This rate card is generated based on the company's standard job titles and rates. However, you have the flexibility to modify it according to the project. These changes will not impact the organization's standard job titles and rates.", + "saveButton": "Save", + "jobTitleColumn": "Job Title", + "ratePerHourColumn": "Rate per hour", + "ratePerManDayColumn": "Rate per man day", + "calculationMethodText": "Calculation Method", + "hourlyRatesText": "Hourly Rates", + "manDaysText": "Man Days", + "hoursPerDayText": "Hours per Day", + "ratecardPluralText": "Rate Cards", + "labourHoursColumn": "Labour Hours", + "actions": "Actions", + "selectJobTitle": "Select Job Title", + "ratecardsPluralText": "Rate Card Templates", + "deleteConfirm": "Are you sure ?", + "yes": "Yes", + "no": "No", + "alreadyImportedRateCardMessage": "A rate card has already been imported. Clear all imported rate cards to add a new one.", + "budgetOverviewTooltips": { + "manualBudget": "Manual project budget amount set by project manager", + "totalActualCost": "Total actual cost including fixed costs", + "variance": "Difference between manual budget and actual cost", + "utilization": "Percentage of manual budget utilized", + "estimatedHours": "Total estimated hours from all tasks", + "fixedCosts": "Total fixed costs from all tasks", + "timeBasedCost": "Actual cost from time tracking (excluding fixed costs)", + "remainingBudget": "Remaining budget amount" + }, + "budgetModal": { + "title": "Edit Project Budget", + "description": "Set a manual budget for this project. This budget will be used for all financial calculations and should include both time-based costs and fixed costs.", + "placeholder": "Enter budget amount", + "saveButton": "Save", + "cancelButton": "Cancel" + }, + "budgetStatistics": { + "manualBudget": "Manual Budget", + "totalActualCost": "Total Actual Cost", + "variance": "Variance", + "budgetUtilization": "Budget Utilization", + "estimatedHours": "Estimated Hours", + "fixedCosts": "Fixed Costs", + "timeBasedCost": "Time-based Cost", + "remainingBudget": "Remaining Budget", + "noManualBudgetSet": "(No Manual Budget Set)" + }, + "budgetSettingsDrawer": { + "title": "Project Budget Settings", + "budgetConfiguration": "Budget Configuration", + "projectBudget": "Project Budget", + "projectBudgetTooltip": "Total budget allocated for this project", + "currency": "Currency", + "costCalculationMethod": "Cost Calculation Method", + "calculationMethod": "Calculation Method", + "workingHoursPerDay": "Working Hours per Day", + "workingHoursPerDayTooltip": "Number of working hours in a day for man-day calculations", + "hourlyCalculationInfo": "Costs will be calculated using estimated hours × hourly rates", + "manDaysCalculationInfo": "Costs will be calculated using estimated man days × daily rates", + "importantNotes": "Important Notes", + "calculationMethodChangeNote": "• Changing the calculation method will affect how costs are calculated for all tasks in this project", + "immediateEffectNote": "• Changes take effect immediately and will recalculate all project totals", + "projectWideNote": "• Budget settings apply to the entire project and all its tasks", + "cancel": "Cancel", + "saveChanges": "Save Changes", + "budgetSettingsUpdated": "Budget settings updated successfully", + "budgetSettingsUpdateFailed": "Failed to update budget settings" + }, + "columnTooltips": { + "hours": "Total estimated hours for all tasks. Calculated from task time estimates.", + "manDays": "Total estimated man days for all tasks. Based on {{hoursPerDay}} hours per working day.", + "actualManDays": "Actual man days spent based on time logged. Calculated as: Total Time Logged ÷ {{hoursPerDay}} hours per day.", + "effortVariance": "Difference between estimated and actual man days. Positive values indicate over-estimation, negative values indicate under-estimation.", + "totalTimeLogged": "Total time actually logged by team members across all tasks.", + "estimatedCostHourly": "Estimated cost calculated as: Estimated Hours × Hourly Rates for assigned team members.", + "estimatedCostManDays": "Estimated cost calculated as: Estimated Man Days × Daily Rates for assigned team members.", + "actualCost": "Actual cost based on time logged. Calculated as: Time Logged × Hourly Rates for team members.", + "fixedCost": "Fixed costs that don't depend on time spent. Added manually per task.", + "totalBudgetHourly": "Total budgeted cost including estimated cost (Hours × Hourly Rates) + Fixed Costs.", + "totalBudgetManDays": "Total budgeted cost including estimated cost (Man Days × Daily Rates) + Fixed Costs.", + "totalActual": "Total actual cost including time-based cost + Fixed Costs.", + "variance": "Cost variance: Total Budgeted Costs - Total Actual Cost. Positive values indicate under-budget, negative values indicate over-budget." + } +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/de/settings/ratecard-settings.json b/worklenz-frontend/public/locales/de/settings/ratecard-settings.json new file mode 100644 index 00000000..f99927f4 --- /dev/null +++ b/worklenz-frontend/public/locales/de/settings/ratecard-settings.json @@ -0,0 +1,52 @@ +{ + "nameColumn": "Name", + "createdColumn": "Erstellt", + "noProjectsAvailable": "Keine Projekte verfügbar", + "deleteConfirmationTitle": "Sind Sie sicher, dass Sie diese Rate Card löschen möchten?", + "deleteConfirmationOk": "Ja, löschen", + "deleteConfirmationCancel": "Abbrechen", + "searchPlaceholder": "Rate Cards nach Name suchen", + "createRatecard": "Rate Card erstellen", + "editTooltip": "Rate Card bearbeiten", + "deleteTooltip": "Rate Card löschen", + "fetchError": "Rate Cards konnten nicht abgerufen werden", + "createError": "Rate Card konnte nicht erstellt werden", + "deleteSuccess": "Rate Card erfolgreich gelöscht", + "deleteError": "Rate Card konnte nicht gelöscht werden", + + "jobTitleColumn": "Berufsbezeichnung", + "ratePerHourColumn": "Stundensatz", + "ratePerDayColumn": "Tagessatz", + "ratePerManDayColumn": "Satz pro Manntag", + "saveButton": "Speichern", + "addRoleButton": "Rolle hinzufügen", + "createRatecardSuccessMessage": "Rate Card erfolgreich erstellt", + "createRatecardErrorMessage": "Rate Card konnte nicht erstellt werden", + "updateRatecardSuccessMessage": "Rate Card erfolgreich aktualisiert", + "updateRatecardErrorMessage": "Rate Card konnte nicht aktualisiert werden", + "currency": "Währung", + "actionsColumn": "Aktionen", + "addAllButton": "Alle hinzufügen", + "removeAllButton": "Alle entfernen", + "selectJobTitle": "Berufsbezeichnung auswählen", + "unsavedChangesTitle": "Sie haben ungespeicherte Änderungen", + "unsavedChangesMessage": "Möchten Sie Ihre Änderungen vor dem Verlassen speichern?", + "unsavedChangesSave": "Speichern", + "unsavedChangesDiscard": "Verwerfen", + "ratecardNameRequired": "Rate Card Name ist erforderlich", + "ratecardNamePlaceholder": "Rate Card Name eingeben", + "noRatecardsFound": "Keine Rate Cards gefunden", + "loadingRateCards": "Rate Cards werden geladen...", + "noJobTitlesAvailable": "Keine Berufsbezeichnungen verfügbar", + "noRolesAdded": "Noch keine Rollen hinzugefügt", + "createFirstJobTitle": "Erste Berufsbezeichnung erstellen", + "jobRolesTitle": "Job-Rollen", + "noJobTitlesMessage": "Bitte erstellen Sie zuerst Berufsbezeichnungen in den Einstellungen, bevor Sie Rollen zu Rate Cards hinzufügen.", + "createNewJobTitle": "Neue Berufsbezeichnung erstellen", + "jobTitleNamePlaceholder": "Name der Berufsbezeichnung eingeben", + "jobTitleNameRequired": "Name der Berufsbezeichnung ist erforderlich", + "jobTitleCreatedSuccess": "Berufsbezeichnung erfolgreich erstellt", + "jobTitleCreateError": "Berufsbezeichnung konnte nicht erstellt werden", + "createButton": "Erstellen", + "cancelButton": "Abbrechen" +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/en/project-view-finance.json b/worklenz-frontend/public/locales/en/project-view-finance.json new file mode 100644 index 00000000..f130fd43 --- /dev/null +++ b/worklenz-frontend/public/locales/en/project-view-finance.json @@ -0,0 +1,122 @@ +{ + "financeText": "Finance", + "ratecardSingularText": "Rate Card", + "groupByText": "Group by", + "statusText": "Status", + "phaseText": "Phase", + "priorityText": "Priority", + "exportButton": "Export", + "currencyText": "Currency", + "importButton": "Import", + "filterText": "Filter", + "billableOnlyText": "Billable Only", + "nonBillableOnlyText": "Non-Billable Only", + "allTasksText": "All Tasks", + "projectBudgetOverviewText": "Project Budget Overview", + + "taskColumn": "Task", + "membersColumn": "Members", + "hoursColumn": "Estimated Hours", + "manDaysColumn": "Estimated Man Days", + "actualManDaysColumn": "Actual Man Days", + "effortVarianceColumn": "Effort Variance", + "totalTimeLoggedColumn": "Total Time Logged", + "costColumn": "Actual Cost", + "estimatedCostColumn": "Estimated Cost", + "fixedCostColumn": "Fixed Cost", + "totalBudgetedCostColumn": "Total Budgeted Cost", + "totalActualCostColumn": "Total Actual Cost", + "varianceColumn": "Variance", + "totalText": "Total", + "noTasksFound": "No tasks found", + + "addRoleButton": "+ Add Role", + "ratecardImportantNotice": "* This rate card is generated based on the company's standard job titles and rates. However, you have the flexibility to modify it according to the project. These changes will not impact the organization's standard job titles and rates.", + "saveButton": "Save", + + "jobTitleColumn": "Job Title", + "ratePerHourColumn": "Rate per hour", + "ratePerManDayColumn": "Rate per man day", + "calculationMethodText": "Calculation Method", + "hourlyRatesText": "Hourly Rates", + "manDaysText": "Man Days", + "hoursPerDayText": "Hours per Day", + "ratecardPluralText": "Rate Cards", + "labourHoursColumn": "Labour Hours", + "actions": "Actions", + "selectJobTitle": "Select Job Title", + "ratecardsPluralText": "Rate Card Templates", + "deleteConfirm": "Are you sure ?", + "yes": "Yes", + "no": "No", + "alreadyImportedRateCardMessage": "A rate card has already been imported. Clear all imported rate cards to add a new one.", + + "budgetOverviewTooltips": { + "manualBudget": "Manual project budget amount set by project manager", + "totalActualCost": "Total actual cost including fixed costs", + "variance": "Difference between manual budget and actual cost", + "utilization": "Percentage of manual budget utilized", + "estimatedHours": "Total estimated hours from all tasks", + "fixedCosts": "Total fixed costs from all tasks", + "timeBasedCost": "Actual cost from time tracking (excluding fixed costs)", + "remainingBudget": "Remaining budget amount" + }, + + "budgetModal": { + "title": "Edit Project Budget", + "description": "Set a manual budget for this project. This budget will be used for all financial calculations and should include both time-based costs and fixed costs.", + "placeholder": "Enter budget amount", + "saveButton": "Save", + "cancelButton": "Cancel" + }, + + "budgetStatistics": { + "manualBudget": "Manual Budget", + "totalActualCost": "Total Actual Cost", + "variance": "Variance", + "budgetUtilization": "Budget Utilization", + "estimatedHours": "Estimated Hours", + "fixedCosts": "Fixed Costs", + "timeBasedCost": "Time-based Cost", + "remainingBudget": "Remaining Budget", + "noManualBudgetSet": "(No Manual Budget Set)" + }, + + "budgetSettingsDrawer": { + "title": "Project Budget Settings", + "budgetConfiguration": "Budget Configuration", + "projectBudget": "Project Budget", + "projectBudgetTooltip": "Total budget allocated for this project", + "currency": "Currency", + "costCalculationMethod": "Cost Calculation Method", + "calculationMethod": "Calculation Method", + "workingHoursPerDay": "Working Hours per Day", + "workingHoursPerDayTooltip": "Number of working hours in a day for man-day calculations", + "hourlyCalculationInfo": "Costs will be calculated using estimated hours × hourly rates", + "manDaysCalculationInfo": "Costs will be calculated using estimated man days × daily rates", + "importantNotes": "Important Notes", + "calculationMethodChangeNote": "• Changing the calculation method will affect how costs are calculated for all tasks in this project", + "immediateEffectNote": "• Changes take effect immediately and will recalculate all project totals", + "projectWideNote": "• Budget settings apply to the entire project and all its tasks", + "cancel": "Cancel", + "saveChanges": "Save Changes", + "budgetSettingsUpdated": "Budget settings updated successfully", + "budgetSettingsUpdateFailed": "Failed to update budget settings" + }, + + "columnTooltips": { + "hours": "Total estimated hours for all tasks. Calculated from task time estimates.", + "manDays": "Total estimated man days for all tasks. Based on {{hoursPerDay}} hours per working day.", + "actualManDays": "Actual man days spent based on time logged. Calculated as: Total Time Logged ÷ {{hoursPerDay}} hours per day.", + "effortVariance": "Difference between estimated and actual man days. Positive values indicate over-estimation, negative values indicate under-estimation.", + "totalTimeLogged": "Total time actually logged by team members across all tasks.", + "estimatedCostHourly": "Estimated cost calculated as: Estimated Hours × Hourly Rates for assigned team members.", + "estimatedCostManDays": "Estimated cost calculated as: Estimated Man Days × Daily Rates for assigned team members.", + "actualCost": "Actual cost based on time logged. Calculated as: Time Logged × Hourly Rates for team members.", + "fixedCost": "Fixed costs that don't depend on time spent. Added manually per task.", + "totalBudgetHourly": "Total budgeted cost including estimated cost (Hours × Hourly Rates) + Fixed Costs.", + "totalBudgetManDays": "Total budgeted cost including estimated cost (Man Days × Daily Rates) + Fixed Costs.", + "totalActual": "Total actual cost including time-based cost + Fixed Costs.", + "variance": "Cost variance: Total Budgeted Costs - Total Actual Cost. Positive values indicate under-budget, negative values indicate over-budget." + } +} diff --git a/worklenz-frontend/public/locales/en/settings/ratecard-settings.json b/worklenz-frontend/public/locales/en/settings/ratecard-settings.json new file mode 100644 index 00000000..1abfe013 --- /dev/null +++ b/worklenz-frontend/public/locales/en/settings/ratecard-settings.json @@ -0,0 +1,52 @@ +{ + "nameColumn": "Name", + "createdColumn": "Created", + "noProjectsAvailable": "No projects available", + "deleteConfirmationTitle": "Are you sure you want to delete this rate card?", + "deleteConfirmationOk": "Yes, delete", + "deleteConfirmationCancel": "Cancel", + "searchPlaceholder": "Search rate cards by name", + "createRatecard": "Create Rate Card", + "editTooltip": "Edit rate card", + "deleteTooltip": "Delete rate card", + "fetchError": "Failed to fetch rate cards", + "createError": "Failed to create rate card", + "deleteSuccess": "Rate card deleted successfully", + "deleteError": "Failed to delete rate card", + + "jobTitleColumn": "Job title", + "ratePerHourColumn": "Rate per hour", + "ratePerDayColumn": "Rate per day", + "ratePerManDayColumn": "Rate per man day", + "saveButton": "Save", + "addRoleButton": "Add Role", + "createRatecardSuccessMessage": "Rate card created successfully", + "createRatecardErrorMessage": "Failed to create rate card", + "updateRatecardSuccessMessage": "Rate card updated successfully", + "updateRatecardErrorMessage": "Failed to update rate card", + "currency": "Currency", + "actionsColumn": "Actions", + "addAllButton": "Add All", + "removeAllButton": "Remove All", + "selectJobTitle": "Select job title", + "unsavedChangesTitle": "You have unsaved changes", + "unsavedChangesMessage": "Do you want to save your changes before leaving?", + "unsavedChangesSave": "Save", + "unsavedChangesDiscard": "Discard", + "ratecardNameRequired": "Rate card name is required", + "ratecardNamePlaceholder": "Enter rate card name", + "noRatecardsFound": "No rate cards found", + "loadingRateCards": "Loading rate cards...", + "noJobTitlesAvailable": "No job titles available", + "noRolesAdded": "No roles added yet", + "createFirstJobTitle": "Create First Job Title", + "jobRolesTitle": "Job Roles", + "noJobTitlesMessage": "Please create job titles first in the Job Titles settings before adding roles to rate cards.", + "createNewJobTitle": "Create New Job Title", + "jobTitleNamePlaceholder": "Enter job title name", + "jobTitleNameRequired": "Job title name is required", + "jobTitleCreatedSuccess": "Job title created successfully", + "jobTitleCreateError": "Failed to create job title", + "createButton": "Create", + "cancelButton": "Cancel" +} diff --git a/worklenz-frontend/public/locales/es/project-view-finance.json b/worklenz-frontend/public/locales/es/project-view-finance.json new file mode 100644 index 00000000..54ab8095 --- /dev/null +++ b/worklenz-frontend/public/locales/es/project-view-finance.json @@ -0,0 +1,114 @@ +{ + "financeText": "Finance", + "ratecardSingularText": "Rate Card", + "groupByText": "Group by", + "statusText": "Status", + "phaseText": "Phase", + "priorityText": "Priority", + "exportButton": "Export", + "currencyText": "Currency", + "importButton": "Import", + "filterText": "Filter", + "billableOnlyText": "Billable Only", + "nonBillableOnlyText": "Non-Billable Only", + "allTasksText": "All Tasks", + "projectBudgetOverviewText": "Project Budget Overview", + "taskColumn": "Task", + "membersColumn": "Members", + "hoursColumn": "Estimated Hours", + "manDaysColumn": "Estimated Man Days", + "actualManDaysColumn": "Actual Man Days", + "effortVarianceColumn": "Effort Variance", + "totalTimeLoggedColumn": "Total Time Logged", + "costColumn": "Actual Cost", + "estimatedCostColumn": "Estimated Cost", + "fixedCostColumn": "Fixed Cost", + "totalBudgetedCostColumn": "Total Budgeted Cost", + "totalActualCostColumn": "Total Actual Cost", + "varianceColumn": "Variance", + "totalText": "Total", + "noTasksFound": "No tasks found", + "addRoleButton": "+ Add Role", + "ratecardImportantNotice": "* This rate card is generated based on the company's standard job titles and rates. However, you have the flexibility to modify it according to the project. These changes will not impact the organization's standard job titles and rates.", + "saveButton": "Save", + "jobTitleColumn": "Job Title", + "ratePerHourColumn": "Rate per hour", + "ratePerManDayColumn": "Rate per man day", + "calculationMethodText": "Calculation Method", + "hourlyRatesText": "Hourly Rates", + "manDaysText": "Man Days", + "hoursPerDayText": "Hours per Day", + "ratecardPluralText": "Rate Cards", + "labourHoursColumn": "Labour Hours", + "actions": "Actions", + "selectJobTitle": "Select Job Title", + "ratecardsPluralText": "Rate Card Templates", + "deleteConfirm": "Are you sure ?", + "yes": "Yes", + "no": "No", + "alreadyImportedRateCardMessage": "A rate card has already been imported. Clear all imported rate cards to add a new one.", + "budgetOverviewTooltips": { + "manualBudget": "Manual project budget amount set by project manager", + "totalActualCost": "Total actual cost including fixed costs", + "variance": "Difference between manual budget and actual cost", + "utilization": "Percentage of manual budget utilized", + "estimatedHours": "Total estimated hours from all tasks", + "fixedCosts": "Total fixed costs from all tasks", + "timeBasedCost": "Actual cost from time tracking (excluding fixed costs)", + "remainingBudget": "Remaining budget amount" + }, + "budgetModal": { + "title": "Edit Project Budget", + "description": "Set a manual budget for this project. This budget will be used for all financial calculations and should include both time-based costs and fixed costs.", + "placeholder": "Enter budget amount", + "saveButton": "Save", + "cancelButton": "Cancel" + }, + "budgetStatistics": { + "manualBudget": "Manual Budget", + "totalActualCost": "Total Actual Cost", + "variance": "Variance", + "budgetUtilization": "Budget Utilization", + "estimatedHours": "Estimated Hours", + "fixedCosts": "Fixed Costs", + "timeBasedCost": "Time-based Cost", + "remainingBudget": "Remaining Budget", + "noManualBudgetSet": "(No Manual Budget Set)" + }, + "budgetSettingsDrawer": { + "title": "Project Budget Settings", + "budgetConfiguration": "Budget Configuration", + "projectBudget": "Project Budget", + "projectBudgetTooltip": "Total budget allocated for this project", + "currency": "Currency", + "costCalculationMethod": "Cost Calculation Method", + "calculationMethod": "Calculation Method", + "workingHoursPerDay": "Working Hours per Day", + "workingHoursPerDayTooltip": "Number of working hours in a day for man-day calculations", + "hourlyCalculationInfo": "Costs will be calculated using estimated hours × hourly rates", + "manDaysCalculationInfo": "Costs will be calculated using estimated man days × daily rates", + "importantNotes": "Important Notes", + "calculationMethodChangeNote": "• Changing the calculation method will affect how costs are calculated for all tasks in this project", + "immediateEffectNote": "• Changes take effect immediately and will recalculate all project totals", + "projectWideNote": "• Budget settings apply to the entire project and all its tasks", + "cancel": "Cancel", + "saveChanges": "Save Changes", + "budgetSettingsUpdated": "Budget settings updated successfully", + "budgetSettingsUpdateFailed": "Failed to update budget settings" + }, + "columnTooltips": { + "hours": "Total estimated hours for all tasks. Calculated from task time estimates.", + "manDays": "Total estimated man days for all tasks. Based on {{hoursPerDay}} hours per working day.", + "actualManDays": "Actual man days spent based on time logged. Calculated as: Total Time Logged ÷ {{hoursPerDay}} hours per day.", + "effortVariance": "Difference between estimated and actual man days. Positive values indicate over-estimation, negative values indicate under-estimation.", + "totalTimeLogged": "Total time actually logged by team members across all tasks.", + "estimatedCostHourly": "Estimated cost calculated as: Estimated Hours × Hourly Rates for assigned team members.", + "estimatedCostManDays": "Estimated cost calculated as: Estimated Man Days × Daily Rates for assigned team members.", + "actualCost": "Actual cost based on time logged. Calculated as: Time Logged × Hourly Rates for team members.", + "fixedCost": "Fixed costs that don't depend on time spent. Added manually per task.", + "totalBudgetHourly": "Total budgeted cost including estimated cost (Hours × Hourly Rates) + Fixed Costs.", + "totalBudgetManDays": "Total budgeted cost including estimated cost (Man Days × Daily Rates) + Fixed Costs.", + "totalActual": "Total actual cost including time-based cost + Fixed Costs.", + "variance": "Cost variance: Total Budgeted Costs - Total Actual Cost. Positive values indicate under-budget, negative values indicate over-budget." + } +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/es/settings/ratecard-settings.json b/worklenz-frontend/public/locales/es/settings/ratecard-settings.json new file mode 100644 index 00000000..4289b24d --- /dev/null +++ b/worklenz-frontend/public/locales/es/settings/ratecard-settings.json @@ -0,0 +1,52 @@ +{ + "nameColumn": "Nombre", + "createdColumn": "Creado", + "noProjectsAvailable": "No hay proyectos disponibles", + "deleteConfirmationTitle": "¿Está seguro de que desea eliminar esta rate card?", + "deleteConfirmationOk": "Sí, eliminar", + "deleteConfirmationCancel": "Cancelar", + "searchPlaceholder": "Buscar rate cards por nombre", + "createRatecard": "Crear Rate Card", + "editTooltip": "Editar rate card", + "deleteTooltip": "Eliminar rate card", + "fetchError": "No se pudieron obtener las rate cards", + "createError": "No se pudo crear la rate card", + "deleteSuccess": "Rate card eliminada con éxito", + "deleteError": "No se pudo eliminar la rate card", + + "jobTitleColumn": "Título del trabajo", + "ratePerHourColumn": "Tarifa por hora", + "ratePerDayColumn": "Tarifa por día", + "ratePerManDayColumn": "Tarifa por día-hombre", + "saveButton": "Guardar", + "addRoleButton": "Agregar rol", + "createRatecardSuccessMessage": "Rate card creada con éxito", + "createRatecardErrorMessage": "No se pudo crear la rate card", + "updateRatecardSuccessMessage": "Rate card actualizada con éxito", + "updateRatecardErrorMessage": "No se pudo actualizar la rate card", + "currency": "Moneda", + "actionsColumn": "Acciones", + "addAllButton": "Agregar todo", + "removeAllButton": "Eliminar todo", + "selectJobTitle": "Seleccionar título del trabajo", + "unsavedChangesTitle": "Tiene cambios sin guardar", + "unsavedChangesMessage": "¿Desea guardar los cambios antes de salir?", + "unsavedChangesSave": "Guardar", + "unsavedChangesDiscard": "Descartar", + "ratecardNameRequired": "El nombre de la rate card es obligatorio", + "ratecardNamePlaceholder": "Ingrese el nombre de la rate card", + "noRatecardsFound": "No se encontraron rate cards", + "loadingRateCards": "Cargando rate cards...", + "noJobTitlesAvailable": "No hay títulos de trabajo disponibles", + "noRolesAdded": "Aún no se han agregado roles", + "createFirstJobTitle": "Crear primer título de trabajo", + "jobRolesTitle": "Roles de trabajo", + "noJobTitlesMessage": "Por favor, cree primero títulos de trabajo en la configuración antes de agregar roles a las rate cards.", + "createNewJobTitle": "Crear nuevo título de trabajo", + "jobTitleNamePlaceholder": "Ingrese el nombre del título de trabajo", + "jobTitleNameRequired": "El nombre del título de trabajo es obligatorio", + "jobTitleCreatedSuccess": "Título de trabajo creado con éxito", + "jobTitleCreateError": "No se pudo crear el título de trabajo", + "createButton": "Crear", + "cancelButton": "Cancelar" +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/pt/project-view-finance.json b/worklenz-frontend/public/locales/pt/project-view-finance.json new file mode 100644 index 00000000..54ab8095 --- /dev/null +++ b/worklenz-frontend/public/locales/pt/project-view-finance.json @@ -0,0 +1,114 @@ +{ + "financeText": "Finance", + "ratecardSingularText": "Rate Card", + "groupByText": "Group by", + "statusText": "Status", + "phaseText": "Phase", + "priorityText": "Priority", + "exportButton": "Export", + "currencyText": "Currency", + "importButton": "Import", + "filterText": "Filter", + "billableOnlyText": "Billable Only", + "nonBillableOnlyText": "Non-Billable Only", + "allTasksText": "All Tasks", + "projectBudgetOverviewText": "Project Budget Overview", + "taskColumn": "Task", + "membersColumn": "Members", + "hoursColumn": "Estimated Hours", + "manDaysColumn": "Estimated Man Days", + "actualManDaysColumn": "Actual Man Days", + "effortVarianceColumn": "Effort Variance", + "totalTimeLoggedColumn": "Total Time Logged", + "costColumn": "Actual Cost", + "estimatedCostColumn": "Estimated Cost", + "fixedCostColumn": "Fixed Cost", + "totalBudgetedCostColumn": "Total Budgeted Cost", + "totalActualCostColumn": "Total Actual Cost", + "varianceColumn": "Variance", + "totalText": "Total", + "noTasksFound": "No tasks found", + "addRoleButton": "+ Add Role", + "ratecardImportantNotice": "* This rate card is generated based on the company's standard job titles and rates. However, you have the flexibility to modify it according to the project. These changes will not impact the organization's standard job titles and rates.", + "saveButton": "Save", + "jobTitleColumn": "Job Title", + "ratePerHourColumn": "Rate per hour", + "ratePerManDayColumn": "Rate per man day", + "calculationMethodText": "Calculation Method", + "hourlyRatesText": "Hourly Rates", + "manDaysText": "Man Days", + "hoursPerDayText": "Hours per Day", + "ratecardPluralText": "Rate Cards", + "labourHoursColumn": "Labour Hours", + "actions": "Actions", + "selectJobTitle": "Select Job Title", + "ratecardsPluralText": "Rate Card Templates", + "deleteConfirm": "Are you sure ?", + "yes": "Yes", + "no": "No", + "alreadyImportedRateCardMessage": "A rate card has already been imported. Clear all imported rate cards to add a new one.", + "budgetOverviewTooltips": { + "manualBudget": "Manual project budget amount set by project manager", + "totalActualCost": "Total actual cost including fixed costs", + "variance": "Difference between manual budget and actual cost", + "utilization": "Percentage of manual budget utilized", + "estimatedHours": "Total estimated hours from all tasks", + "fixedCosts": "Total fixed costs from all tasks", + "timeBasedCost": "Actual cost from time tracking (excluding fixed costs)", + "remainingBudget": "Remaining budget amount" + }, + "budgetModal": { + "title": "Edit Project Budget", + "description": "Set a manual budget for this project. This budget will be used for all financial calculations and should include both time-based costs and fixed costs.", + "placeholder": "Enter budget amount", + "saveButton": "Save", + "cancelButton": "Cancel" + }, + "budgetStatistics": { + "manualBudget": "Manual Budget", + "totalActualCost": "Total Actual Cost", + "variance": "Variance", + "budgetUtilization": "Budget Utilization", + "estimatedHours": "Estimated Hours", + "fixedCosts": "Fixed Costs", + "timeBasedCost": "Time-based Cost", + "remainingBudget": "Remaining Budget", + "noManualBudgetSet": "(No Manual Budget Set)" + }, + "budgetSettingsDrawer": { + "title": "Project Budget Settings", + "budgetConfiguration": "Budget Configuration", + "projectBudget": "Project Budget", + "projectBudgetTooltip": "Total budget allocated for this project", + "currency": "Currency", + "costCalculationMethod": "Cost Calculation Method", + "calculationMethod": "Calculation Method", + "workingHoursPerDay": "Working Hours per Day", + "workingHoursPerDayTooltip": "Number of working hours in a day for man-day calculations", + "hourlyCalculationInfo": "Costs will be calculated using estimated hours × hourly rates", + "manDaysCalculationInfo": "Costs will be calculated using estimated man days × daily rates", + "importantNotes": "Important Notes", + "calculationMethodChangeNote": "• Changing the calculation method will affect how costs are calculated for all tasks in this project", + "immediateEffectNote": "• Changes take effect immediately and will recalculate all project totals", + "projectWideNote": "• Budget settings apply to the entire project and all its tasks", + "cancel": "Cancel", + "saveChanges": "Save Changes", + "budgetSettingsUpdated": "Budget settings updated successfully", + "budgetSettingsUpdateFailed": "Failed to update budget settings" + }, + "columnTooltips": { + "hours": "Total estimated hours for all tasks. Calculated from task time estimates.", + "manDays": "Total estimated man days for all tasks. Based on {{hoursPerDay}} hours per working day.", + "actualManDays": "Actual man days spent based on time logged. Calculated as: Total Time Logged ÷ {{hoursPerDay}} hours per day.", + "effortVariance": "Difference between estimated and actual man days. Positive values indicate over-estimation, negative values indicate under-estimation.", + "totalTimeLogged": "Total time actually logged by team members across all tasks.", + "estimatedCostHourly": "Estimated cost calculated as: Estimated Hours × Hourly Rates for assigned team members.", + "estimatedCostManDays": "Estimated cost calculated as: Estimated Man Days × Daily Rates for assigned team members.", + "actualCost": "Actual cost based on time logged. Calculated as: Time Logged × Hourly Rates for team members.", + "fixedCost": "Fixed costs that don't depend on time spent. Added manually per task.", + "totalBudgetHourly": "Total budgeted cost including estimated cost (Hours × Hourly Rates) + Fixed Costs.", + "totalBudgetManDays": "Total budgeted cost including estimated cost (Man Days × Daily Rates) + Fixed Costs.", + "totalActual": "Total actual cost including time-based cost + Fixed Costs.", + "variance": "Cost variance: Total Budgeted Costs - Total Actual Cost. Positive values indicate under-budget, negative values indicate over-budget." + } +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/pt/settings/ratecard-settings.json b/worklenz-frontend/public/locales/pt/settings/ratecard-settings.json new file mode 100644 index 00000000..6a2d6022 --- /dev/null +++ b/worklenz-frontend/public/locales/pt/settings/ratecard-settings.json @@ -0,0 +1,52 @@ +{ + "nameColumn": "Nome", + "createdColumn": "Criado", + "noProjectsAvailable": "Nenhum projeto disponível", + "deleteConfirmationTitle": "Tem certeza de que deseja excluir este rate card?", + "deleteConfirmationOk": "Sim, excluir", + "deleteConfirmationCancel": "Cancelar", + "searchPlaceholder": "Pesquisar rate cards por nome", + "createRatecard": "Criar Rate Card", + "editTooltip": "Editar rate card", + "deleteTooltip": "Excluir rate card", + "fetchError": "Falha ao buscar rate cards", + "createError": "Falha ao criar rate card", + "deleteSuccess": "Rate card excluído com sucesso", + "deleteError": "Falha ao excluir rate card", + + "jobTitleColumn": "Cargo", + "ratePerHourColumn": "Taxa por hora", + "ratePerDayColumn": "Taxa por dia", + "ratePerManDayColumn": "Taxa por dia-homem", + "saveButton": "Salvar", + "addRoleButton": "Adicionar função", + "createRatecardSuccessMessage": "Rate card criado com sucesso", + "createRatecardErrorMessage": "Falha ao criar rate card", + "updateRatecardSuccessMessage": "Rate card atualizado com sucesso", + "updateRatecardErrorMessage": "Falha ao atualizar rate card", + "currency": "Moeda", + "actionsColumn": "Ações", + "addAllButton": "Adicionar todos", + "removeAllButton": "Remover todos", + "selectJobTitle": "Selecionar cargo", + "unsavedChangesTitle": "Você tem alterações não salvas", + "unsavedChangesMessage": "Deseja salvar as alterações antes de sair?", + "unsavedChangesSave": "Salvar", + "unsavedChangesDiscard": "Descartar", + "ratecardNameRequired": "O nome do rate card é obrigatório", + "ratecardNamePlaceholder": "Digite o nome do rate card", + "noRatecardsFound": "Nenhum rate card encontrado", + "loadingRateCards": "Carregando rate cards...", + "noJobTitlesAvailable": "Nenhum cargo disponível", + "noRolesAdded": "Nenhuma função adicionada ainda", + "createFirstJobTitle": "Criar primeiro cargo", + "jobRolesTitle": "Funções de trabalho", + "noJobTitlesMessage": "Por favor, crie cargos primeiro nas configurações antes de adicionar funções aos rate cards.", + "createNewJobTitle": "Criar novo cargo", + "jobTitleNamePlaceholder": "Digite o nome do cargo", + "jobTitleNameRequired": "O nome do cargo é obrigatório", + "jobTitleCreatedSuccess": "Cargo criado com sucesso", + "jobTitleCreateError": "Falha ao criar cargo", + "createButton": "Criar", + "cancelButton": "Cancelar" +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/zh/project-view-finance.json b/worklenz-frontend/public/locales/zh/project-view-finance.json new file mode 100644 index 00000000..54ab8095 --- /dev/null +++ b/worklenz-frontend/public/locales/zh/project-view-finance.json @@ -0,0 +1,114 @@ +{ + "financeText": "Finance", + "ratecardSingularText": "Rate Card", + "groupByText": "Group by", + "statusText": "Status", + "phaseText": "Phase", + "priorityText": "Priority", + "exportButton": "Export", + "currencyText": "Currency", + "importButton": "Import", + "filterText": "Filter", + "billableOnlyText": "Billable Only", + "nonBillableOnlyText": "Non-Billable Only", + "allTasksText": "All Tasks", + "projectBudgetOverviewText": "Project Budget Overview", + "taskColumn": "Task", + "membersColumn": "Members", + "hoursColumn": "Estimated Hours", + "manDaysColumn": "Estimated Man Days", + "actualManDaysColumn": "Actual Man Days", + "effortVarianceColumn": "Effort Variance", + "totalTimeLoggedColumn": "Total Time Logged", + "costColumn": "Actual Cost", + "estimatedCostColumn": "Estimated Cost", + "fixedCostColumn": "Fixed Cost", + "totalBudgetedCostColumn": "Total Budgeted Cost", + "totalActualCostColumn": "Total Actual Cost", + "varianceColumn": "Variance", + "totalText": "Total", + "noTasksFound": "No tasks found", + "addRoleButton": "+ Add Role", + "ratecardImportantNotice": "* This rate card is generated based on the company's standard job titles and rates. However, you have the flexibility to modify it according to the project. These changes will not impact the organization's standard job titles and rates.", + "saveButton": "Save", + "jobTitleColumn": "Job Title", + "ratePerHourColumn": "Rate per hour", + "ratePerManDayColumn": "Rate per man day", + "calculationMethodText": "Calculation Method", + "hourlyRatesText": "Hourly Rates", + "manDaysText": "Man Days", + "hoursPerDayText": "Hours per Day", + "ratecardPluralText": "Rate Cards", + "labourHoursColumn": "Labour Hours", + "actions": "Actions", + "selectJobTitle": "Select Job Title", + "ratecardsPluralText": "Rate Card Templates", + "deleteConfirm": "Are you sure ?", + "yes": "Yes", + "no": "No", + "alreadyImportedRateCardMessage": "A rate card has already been imported. Clear all imported rate cards to add a new one.", + "budgetOverviewTooltips": { + "manualBudget": "Manual project budget amount set by project manager", + "totalActualCost": "Total actual cost including fixed costs", + "variance": "Difference between manual budget and actual cost", + "utilization": "Percentage of manual budget utilized", + "estimatedHours": "Total estimated hours from all tasks", + "fixedCosts": "Total fixed costs from all tasks", + "timeBasedCost": "Actual cost from time tracking (excluding fixed costs)", + "remainingBudget": "Remaining budget amount" + }, + "budgetModal": { + "title": "Edit Project Budget", + "description": "Set a manual budget for this project. This budget will be used for all financial calculations and should include both time-based costs and fixed costs.", + "placeholder": "Enter budget amount", + "saveButton": "Save", + "cancelButton": "Cancel" + }, + "budgetStatistics": { + "manualBudget": "Manual Budget", + "totalActualCost": "Total Actual Cost", + "variance": "Variance", + "budgetUtilization": "Budget Utilization", + "estimatedHours": "Estimated Hours", + "fixedCosts": "Fixed Costs", + "timeBasedCost": "Time-based Cost", + "remainingBudget": "Remaining Budget", + "noManualBudgetSet": "(No Manual Budget Set)" + }, + "budgetSettingsDrawer": { + "title": "Project Budget Settings", + "budgetConfiguration": "Budget Configuration", + "projectBudget": "Project Budget", + "projectBudgetTooltip": "Total budget allocated for this project", + "currency": "Currency", + "costCalculationMethod": "Cost Calculation Method", + "calculationMethod": "Calculation Method", + "workingHoursPerDay": "Working Hours per Day", + "workingHoursPerDayTooltip": "Number of working hours in a day for man-day calculations", + "hourlyCalculationInfo": "Costs will be calculated using estimated hours × hourly rates", + "manDaysCalculationInfo": "Costs will be calculated using estimated man days × daily rates", + "importantNotes": "Important Notes", + "calculationMethodChangeNote": "• Changing the calculation method will affect how costs are calculated for all tasks in this project", + "immediateEffectNote": "• Changes take effect immediately and will recalculate all project totals", + "projectWideNote": "• Budget settings apply to the entire project and all its tasks", + "cancel": "Cancel", + "saveChanges": "Save Changes", + "budgetSettingsUpdated": "Budget settings updated successfully", + "budgetSettingsUpdateFailed": "Failed to update budget settings" + }, + "columnTooltips": { + "hours": "Total estimated hours for all tasks. Calculated from task time estimates.", + "manDays": "Total estimated man days for all tasks. Based on {{hoursPerDay}} hours per working day.", + "actualManDays": "Actual man days spent based on time logged. Calculated as: Total Time Logged ÷ {{hoursPerDay}} hours per day.", + "effortVariance": "Difference between estimated and actual man days. Positive values indicate over-estimation, negative values indicate under-estimation.", + "totalTimeLogged": "Total time actually logged by team members across all tasks.", + "estimatedCostHourly": "Estimated cost calculated as: Estimated Hours × Hourly Rates for assigned team members.", + "estimatedCostManDays": "Estimated cost calculated as: Estimated Man Days × Daily Rates for assigned team members.", + "actualCost": "Actual cost based on time logged. Calculated as: Time Logged × Hourly Rates for team members.", + "fixedCost": "Fixed costs that don't depend on time spent. Added manually per task.", + "totalBudgetHourly": "Total budgeted cost including estimated cost (Hours × Hourly Rates) + Fixed Costs.", + "totalBudgetManDays": "Total budgeted cost including estimated cost (Man Days × Daily Rates) + Fixed Costs.", + "totalActual": "Total actual cost including time-based cost + Fixed Costs.", + "variance": "Cost variance: Total Budgeted Costs - Total Actual Cost. Positive values indicate under-budget, negative values indicate over-budget." + } +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/zh/settings/ratecard-settings.json b/worklenz-frontend/public/locales/zh/settings/ratecard-settings.json new file mode 100644 index 00000000..5634a1e8 --- /dev/null +++ b/worklenz-frontend/public/locales/zh/settings/ratecard-settings.json @@ -0,0 +1,52 @@ +{ + "nameColumn": "名称", + "createdColumn": "创建时间", + "noProjectsAvailable": "没有可用的项目", + "deleteConfirmationTitle": "您确定要删除此费率卡吗?", + "deleteConfirmationOk": "是,删除", + "deleteConfirmationCancel": "取消", + "searchPlaceholder": "按名称搜索费率卡", + "createRatecard": "创建费率卡", + "editTooltip": "编辑费率卡", + "deleteTooltip": "删除费率卡", + "fetchError": "获取费率卡失败", + "createError": "创建费率卡失败", + "deleteSuccess": "费率卡删除成功", + "deleteError": "删除费率卡失败", + + "jobTitleColumn": "职位名称", + "ratePerHourColumn": "每小时费率", + "ratePerDayColumn": "每日费率", + "ratePerManDayColumn": "每人每日费率", + "saveButton": "保存", + "addRoleButton": "添加角色", + "createRatecardSuccessMessage": "费率卡创建成功", + "createRatecardErrorMessage": "创建费率卡失败", + "updateRatecardSuccessMessage": "费率卡更新成功", + "updateRatecardErrorMessage": "更新费率卡失败", + "currency": "货币", + "actionsColumn": "操作", + "addAllButton": "全部添加", + "removeAllButton": "全部移除", + "selectJobTitle": "选择职位名称", + "unsavedChangesTitle": "您有未保存的更改", + "unsavedChangesMessage": "您想在离开前保存更改吗?", + "unsavedChangesSave": "保存", + "unsavedChangesDiscard": "放弃", + "ratecardNameRequired": "费率卡名称为必填项", + "ratecardNamePlaceholder": "输入费率卡名称", + "noRatecardsFound": "未找到费率卡", + "loadingRateCards": "正在加载费率卡...", + "noJobTitlesAvailable": "没有可用的职位名称", + "noRolesAdded": "尚未添加角色", + "createFirstJobTitle": "创建第一个职位名称", + "jobRolesTitle": "职位角色", + "noJobTitlesMessage": "请先在职位名称设置中创建职位名称,然后再向费率卡添加角色。", + "createNewJobTitle": "创建新职位名称", + "jobTitleNamePlaceholder": "输入职位名称", + "jobTitleNameRequired": "职位名称为必填项", + "jobTitleCreatedSuccess": "职位名称创建成功", + "jobTitleCreateError": "职位名称创建失败", + "createButton": "创建", + "cancelButton": "取消" +} \ No newline at end of file diff --git a/worklenz-frontend/src/api/project-finance-ratecard/project-finance-rate-cards.api.service.ts b/worklenz-frontend/src/api/project-finance-ratecard/project-finance-rate-cards.api.service.ts new file mode 100644 index 00000000..28278cf9 --- /dev/null +++ b/worklenz-frontend/src/api/project-finance-ratecard/project-finance-rate-cards.api.service.ts @@ -0,0 +1,116 @@ +import apiClient from '@api/api-client'; +import { API_BASE_URL } from '@/shared/constants'; +import { IServerResponse } from '@/types/common.types'; +import { IJobType, JobRoleType } from '@/types/project/ratecard.types'; + +const rootUrl = `${API_BASE_URL}/project-ratecard`; + +export interface IProjectRateCardRole { + id?: string; + project_id: string; + job_title_id: string; + jobtitle?: string; + rate: number; + man_day_rate?: number; + data?: object; + roles?: IJobType[]; +} + +export const projectRateCardApiService = { + // Insert multiple roles for a project + async insertMany( + project_id: string, + roles: Omit[] + ): Promise> { + const response = await apiClient.post>(rootUrl, { + project_id, + roles, + }); + return response.data; + }, + // Insert a single role for a project + async insertOne({ + project_id, + job_title_id, + rate, + man_day_rate, + }: { + project_id: string; + job_title_id: string; + rate: number; + man_day_rate?: number; + }): Promise> { + const response = await apiClient.post>( + `${rootUrl}/create-project-rate-card-role`, + { project_id, job_title_id, rate, man_day_rate } + ); + return response.data; + }, + + // Get all roles for a project + async getFromProjectId(project_id: string): Promise> { + const response = await apiClient.get>( + `${rootUrl}/project/${project_id}` + ); + return response.data; + }, + + // Get a single role by id + async getFromId(id: string): Promise> { + const response = await apiClient.get>(`${rootUrl}/${id}`); + return response.data; + }, + + // Update a single role by id + async updateFromId( + id: string, + body: { job_title_id: string; rate?: string; man_day_rate?: string } + ): Promise> { + const response = await apiClient.put>( + `${rootUrl}/${id}`, + body + ); + return response.data; + }, + + // Update all roles for a project (delete then insert) + async updateFromProjectId( + project_id: string, + roles: Omit[] + ): Promise> { + const response = await apiClient.put>( + `${rootUrl}/project/${project_id}`, + { project_id, roles } + ); + return response.data; + }, + + // Update project member rate card role + async updateMemberRateCardRole( + project_id: string, + member_id: string, + project_rate_card_role_id: string + ): Promise> { + const response = await apiClient.put>( + `${rootUrl}/project/${project_id}/members/${member_id}/rate-card-role`, + { project_rate_card_role_id } + ); + return response.data; + }, + + // Delete a single role by id + async deleteFromId(id: string): Promise> { + const response = await apiClient.delete>( + `${rootUrl}/${id}` + ); + return response.data; + }, + + // Delete all roles for a project + async deleteFromProjectId(project_id: string): Promise> { + const response = await apiClient.delete>( + `${rootUrl}/project/${project_id}` + ); + return response.data; + }, +}; diff --git a/worklenz-frontend/src/api/project-finance-ratecard/project-finance.api.service.ts b/worklenz-frontend/src/api/project-finance-ratecard/project-finance.api.service.ts new file mode 100644 index 00000000..d8a0dd6e --- /dev/null +++ b/worklenz-frontend/src/api/project-finance-ratecard/project-finance.api.service.ts @@ -0,0 +1,133 @@ +import { API_BASE_URL } from '@/shared/constants'; +import { IServerResponse } from '@/types/common.types'; +import apiClient from '../api-client'; +import { + IProjectFinanceResponse, + ITaskBreakdownResponse, + IProjectFinanceTask, +} from '@/types/project/project-finance.types'; + +const rootUrl = `${API_BASE_URL}/project-finance`; + +type BillableFilterType = 'all' | 'billable' | 'non-billable'; + +export const projectFinanceApiService = { + getProjectTasks: async ( + projectId: string, + groupBy: 'status' | 'priority' | 'phases' = 'status', + billableFilter: BillableFilterType = 'billable' + ): Promise> => { + const response = await apiClient.get>( + `${rootUrl}/project/${projectId}/tasks`, + { + params: { + group_by: groupBy, + billable_filter: billableFilter, + }, + } + ); + return response.data; + }, + + getSubTasks: async ( + projectId: string, + parentTaskId: string, + billableFilter: BillableFilterType = 'billable' + ): Promise> => { + const response = await apiClient.get>( + `${rootUrl}/project/${projectId}/tasks/${parentTaskId}/subtasks`, + { + params: { + billable_filter: billableFilter, + }, + } + ); + return response.data; + }, + + getTaskBreakdown: async (taskId: string): Promise> => { + const response = await apiClient.get>( + `${rootUrl}/task/${taskId}/breakdown` + ); + return response.data; + }, + + updateTaskFixedCost: async (taskId: string, fixedCost: number): Promise> => { + const response = await apiClient.put>( + `${rootUrl}/task/${taskId}/fixed-cost`, + { fixed_cost: fixedCost } + ); + return response.data; + }, + + updateProjectCurrency: async ( + projectId: string, + currency: string + ): Promise> => { + const response = await apiClient.put>( + `${rootUrl}/project/${projectId}/currency`, + { currency } + ); + return response.data; + }, + + updateProjectBudget: async (projectId: string, budget: number): Promise> => { + const response = await apiClient.put>( + `${rootUrl}/project/${projectId}/budget`, + { budget } + ); + return response.data; + }, + + updateProjectCalculationMethod: async ( + projectId: string, + calculationMethod: 'hourly' | 'man_days', + hoursPerDay?: number + ): Promise> => { + const response = await apiClient.put>( + `${rootUrl}/project/${projectId}/calculation-method`, + { + calculation_method: calculationMethod, + hours_per_day: hoursPerDay, + } + ); + return response.data; + }, + + updateTaskEstimatedManDays: async ( + taskId: string, + estimatedManDays: number + ): Promise> => { + const response = await apiClient.put>( + `${rootUrl}/task/${taskId}/estimated-man-days`, + { estimated_man_days: estimatedManDays } + ); + return response.data; + }, + + updateRateCardManDayRate: async ( + rateCardRoleId: string, + manDayRate: number + ): Promise> => { + const response = await apiClient.put>( + `${rootUrl}/rate-card-role/${rateCardRoleId}/man-day-rate`, + { man_day_rate: manDayRate } + ); + return response.data; + }, + + exportFinanceData: async ( + projectId: string, + groupBy: 'status' | 'priority' | 'phases' = 'status', + billableFilter: BillableFilterType = 'billable' + ): Promise => { + const response = await apiClient.get(`${rootUrl}/project/${projectId}/export`, { + params: { + groupBy, + billable_filter: billableFilter, + }, + responseType: 'blob', + }); + return response.data; + }, +}; diff --git a/worklenz-frontend/src/api/settings/rate-cards/rate-cards.api.service.ts b/worklenz-frontend/src/api/settings/rate-cards/rate-cards.api.service.ts new file mode 100644 index 00000000..a42a006b --- /dev/null +++ b/worklenz-frontend/src/api/settings/rate-cards/rate-cards.api.service.ts @@ -0,0 +1,47 @@ +import apiClient from '@api/api-client'; +import { API_BASE_URL } from '@/shared/constants'; +import { IServerResponse } from '@/types/common.types'; +import { toQueryString } from '@/utils/toQueryString'; +import { RatecardType, IRatecardViewModel } from '@/types/project/ratecard.types'; + +type IRatecard = { + id: string; +}; + +const rootUrl = `${API_BASE_URL}/ratecard`; + +export const rateCardApiService = { + async getRateCards( + index: number, + size: number, + field: string | null, + order: string | null, + search?: string | null + ): Promise> { + const s = encodeURIComponent(search || ''); + const queryString = toQueryString({ index, size, field, order, search: s }); + const response = await apiClient.get>( + `${rootUrl}${queryString}` + ); + return response.data; + }, + async getRateCardById(id: string): Promise> { + const response = await apiClient.get>(`${rootUrl}/${id}`); + return response.data; + }, + + async createRateCard(body: RatecardType): Promise> { + const response = await apiClient.post>(rootUrl, body); + return response.data; + }, + + async updateRateCard(id: string, body: RatecardType): Promise> { + const response = await apiClient.put>(`${rootUrl}/${id}`, body); + return response.data; + }, + + async deleteRateCard(id: string): Promise> { + const response = await apiClient.delete>(`${rootUrl}/${id}`); + return response.data; + }, +}; diff --git a/worklenz-frontend/src/app/store.ts b/worklenz-frontend/src/app/store.ts index 63c738a0..7c35632f 100644 --- a/worklenz-frontend/src/app/store.ts +++ b/worklenz-frontend/src/app/store.ts @@ -86,6 +86,10 @@ import { projectsApi } from '@/api/projects/projects.v1.api.service'; import projectViewReducer from '@features/project/project-view-slice'; import taskManagementFieldsReducer from '@features/task-management/taskListFields.slice'; +import projectFinanceRateCardReducer from '@/features/finance/project-finance-slice'; +import projectFinancesReducer from '@/features/projects/finance/project-finance.slice'; +import financeReducer from '@/features/projects/finance/finance-slice'; + export const store = configureStore({ middleware: getDefaultMiddleware => getDefaultMiddleware({ @@ -174,6 +178,11 @@ export const store = configureStore({ grouping: groupingReducer, taskManagementSelection: selectionReducer, taskManagementFields: taskManagementFieldsReducer, + + // Finance + projectFinanceRateCardReducer: projectFinanceRateCardReducer, + projectFinancesReducer: projectFinancesReducer, + financeReducer: financeReducer, }, }); diff --git a/worklenz-frontend/src/components/avatars/avatars.tsx b/worklenz-frontend/src/components/avatars/avatars.tsx index f33cdf6c..610c7680 100644 --- a/worklenz-frontend/src/components/avatars/avatars.tsx +++ b/worklenz-frontend/src/components/avatars/avatars.tsx @@ -1,57 +1,46 @@ +import React from 'react'; import { Avatar, Tooltip } from '@/shared/antd-imports'; -import React, { useCallback, useMemo } from 'react'; import { InlineMember } from '@/types/teamMembers/inlineMember.types'; interface AvatarsProps { members: InlineMember[]; maxCount?: number; + allowClickThrough?: boolean; } -const Avatars: React.FC = React.memo(({ members, maxCount }) => { - const stopPropagation = useCallback((e: React.MouseEvent) => { - e.stopPropagation(); - }, []); - - const renderAvatar = useCallback( - (member: InlineMember, index: number) => ( - - {member.avatar_url ? ( - - - - ) : ( - - - {member.end && member.names ? member.name : member.name?.charAt(0).toUpperCase()} - - - )} - - ), - [stopPropagation] - ); - - const visibleMembers = useMemo(() => { - return maxCount ? members.slice(0, maxCount) : members; - }, [members, maxCount]); - - const avatarElements = useMemo(() => { - return visibleMembers.map((member, index) => renderAvatar(member, index)); - }, [visibleMembers, renderAvatar]); +const renderAvatar = (member: InlineMember, index: number, allowClickThrough: boolean = false) => ( + + {member.avatar_url ? ( + e.stopPropagation()}> + + + ) : ( + e.stopPropagation()}> + + {member.end && member.names ? member.name : member.name?.charAt(0).toUpperCase()} + + + )} + +); +const Avatars: React.FC = React.memo(({ members, maxCount, allowClickThrough = false }) => { + const visibleMembers = maxCount ? members.slice(0, maxCount) : members; return ( -
- {avatarElements} +
e.stopPropagation()}> + + {visibleMembers.map((member, index) => renderAvatar(member, index, allowClickThrough))} +
); }); diff --git a/worklenz-frontend/src/components/projects/import-ratecards-drawer/ImportRateCardsDrawer.tsx b/worklenz-frontend/src/components/projects/import-ratecards-drawer/ImportRateCardsDrawer.tsx new file mode 100644 index 00000000..b24098fe --- /dev/null +++ b/worklenz-frontend/src/components/projects/import-ratecards-drawer/ImportRateCardsDrawer.tsx @@ -0,0 +1,231 @@ +import { Drawer, Typography, Button, Table, Menu, Flex, Spin, Alert } from 'antd'; +import React, { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useAppSelector } from '../../../hooks/useAppSelector'; +import { useAppDispatch } from '../../../hooks/useAppDispatch'; +import { fetchRateCards, toggleImportRatecardsDrawer } from '@/features/finance/finance-slice'; +import { fetchRateCardById } from '@/features/finance/finance-slice'; +import { insertProjectRateCardRoles } from '@/features/finance/project-finance-slice'; +import { useParams } from 'react-router-dom'; +import { adminCenterApiService } from '@/api/admin-center/admin-center.api.service'; +import { IOrganization } from '@/types/admin-center/admin-center.types'; +import { hourlyRateToManDayRate } from '@/utils/man-days-utils'; +import { JobRoleType } from '@/types/project/ratecard.types'; + +const ImportRateCardsDrawer: React.FC = () => { + const dispatch = useAppDispatch(); + const { projectId } = useParams(); + const { t } = useTranslation('project-view-finance'); + + const drawerRatecard = useAppSelector(state => state.financeReducer.drawerRatecard); + const ratecardsList = useAppSelector(state => state.financeReducer.ratecardsList || []); + const isDrawerOpen = useAppSelector(state => state.financeReducer.isImportRatecardsDrawerOpen); + // Get project currency from project finances, fallback to finance reducer currency + const projectCurrency = useAppSelector(state => state.projectFinancesReducer.project?.currency); + const fallbackCurrency = useAppSelector(state => state.financeReducer.currency); + const currency = (projectCurrency || fallbackCurrency || 'USD').toUpperCase(); + + const rolesRedux = useAppSelector(state => state.projectFinanceRateCardReducer.rateCardRoles) || []; + + // Loading states + const isRatecardsLoading = useAppSelector(state => state.financeReducer.isRatecardsLoading); + + const [selectedRatecardId, setSelectedRatecardId] = useState(null); + const [organization, setOrganization] = useState(null); + + // Get calculation method from organization + const calculationMethod = organization?.calculation_method || 'hourly'; + + useEffect(() => { + if (selectedRatecardId) { + dispatch(fetchRateCardById(selectedRatecardId)); + } + }, [selectedRatecardId, dispatch]); + + // Fetch organization details to get calculation method + useEffect(() => { + const fetchOrganization = async () => { + try { + const response = await adminCenterApiService.getOrganizationDetails(); + if (response.done) { + setOrganization(response.body); + } + } catch (error) { + console.error('Failed to fetch organization details:', error); + } + }; + + if (isDrawerOpen) { + fetchOrganization(); + } + }, [isDrawerOpen]); + + useEffect(() => { + if (isDrawerOpen) { + dispatch( + fetchRateCards({ + index: 1, + size: 1000, + field: 'name', + order: 'asc', + search: '', + }) + ); + } + }, [isDrawerOpen, dispatch]); + + useEffect(() => { + if (ratecardsList.length > 0 && !selectedRatecardId) { + setSelectedRatecardId(ratecardsList[0].id || null); + } + }, [ratecardsList, selectedRatecardId]); + + const columns = [ + { + title: t('jobTitleColumn'), + dataIndex: 'jobtitle', + render: (text: string) => ( + {text} + ), + }, + { + title: `${calculationMethod === 'man_days' ? t('ratePerManDayColumn') : t('ratePerHourColumn')} (${currency})`, + dataIndex: 'rate', + render: (_: any, record: JobRoleType) => ( + + {calculationMethod === 'man_days' ? record.man_day_rate : record.rate} + + ), + }, + ]; + + return ( + + {t('ratecardsPluralText')} + + } + footer={ +
+ {/* Alert message */} + {rolesRedux.length !== 0 ? ( +
+ +
+ ) : ( +
+ +
+ )} +
+ } + open={isDrawerOpen} + onClose={() => dispatch(toggleImportRatecardsDrawer())} + width={1000} + > + + {/* Sidebar menu with loading */} + + setSelectedRatecardId(key)} + > + {ratecardsList.map(ratecard => ( + {ratecard.name} + ))} + + + + {/* Table for job roles with loading */} + record.job_title_id || record.id || Math.random().toString()} + onRow={() => ({ + className: 'group', + style: { cursor: 'pointer' }, + })} + pagination={false} + loading={isRatecardsLoading} + /> + + + ); +}; + +export default ImportRateCardsDrawer; diff --git a/worklenz-frontend/src/components/projects/project-budget-settings-drawer/ProjectBudgetSettingsDrawer.tsx b/worklenz-frontend/src/components/projects/project-budget-settings-drawer/ProjectBudgetSettingsDrawer.tsx new file mode 100644 index 00000000..d0a3c977 --- /dev/null +++ b/worklenz-frontend/src/components/projects/project-budget-settings-drawer/ProjectBudgetSettingsDrawer.tsx @@ -0,0 +1,268 @@ +import React, { useState, useEffect } from 'react'; +import { + Drawer, + Form, + Select, + InputNumber, + Button, + Space, + Typography, + Card, + Row, + Col, + Tooltip, + message, + Alert, + SettingOutlined, + InfoCircleOutlined, + DollarOutlined, + CalculatorOutlined, + SaveOutlined, + CloseOutlined, +} from '@/shared/antd-imports'; +import { useTranslation } from 'react-i18next'; +import { useAppDispatch } from '@/hooks/useAppDispatch'; +import { useAppSelector } from '@/hooks/useAppSelector'; +import { + updateProjectFinanceCurrency, + fetchProjectFinancesSilent, +} from '@/features/projects/finance/project-finance.slice'; +import { updateProjectCurrency, getProject } from '@/features/project/project.slice'; +import { projectFinanceApiService } from '@/api/project-finance-ratecard/project-finance.api.service'; +import { CURRENCY_OPTIONS } from '@/shared/currencies'; + +const { Text } = Typography; + +interface ProjectBudgetSettingsDrawerProps { + visible: boolean; + onClose: () => void; + projectId: string; +} + +const ProjectBudgetSettingsDrawer: React.FC = ({ + visible, + onClose, + projectId, +}) => { + const { t } = useTranslation('project-view-finance'); + const dispatch = useAppDispatch(); + const [form] = Form.useForm(); + const [loading, setLoading] = useState(false); + const [hasChanges, setHasChanges] = useState(false); + + // Get project data from Redux + const financeProject = useAppSelector(state => state.projectFinancesReducer.project); + const { project } = useAppSelector(state => state.projectReducer); + const activeGroup = useAppSelector(state => state.projectFinancesReducer.activeGroup); + const billableFilter = useAppSelector(state => state.projectFinancesReducer.billableFilter); + + // Form initial values + const initialValues = { + budget: project?.budget || 0, + currency: financeProject?.currency || 'USD', + }; + + // Set form values when drawer opens + useEffect(() => { + if (visible && (project || financeProject)) { + form.setFieldsValue(initialValues); + setHasChanges(false); + } + }, [visible, project, financeProject, form]); + + // Handle form value changes + const handleValuesChange = () => { + setHasChanges(true); + }; + + // Handle save + const handleSave = async () => { + try { + setLoading(true); + const values = await form.validateFields(); + + // Update budget if changed + if (values.budget !== project?.budget) { + await projectFinanceApiService.updateProjectBudget(projectId, values.budget); + } + + // Update currency if changed + if (values.currency !== financeProject?.currency) { + await projectFinanceApiService.updateProjectCurrency( + projectId, + values.currency.toUpperCase() + ); + dispatch(updateProjectCurrency(values.currency)); + dispatch(updateProjectFinanceCurrency(values.currency)); + } + + message.success('Project settings updated successfully'); + setHasChanges(false); + + // Reload project finances after save + dispatch( + fetchProjectFinancesSilent({ + projectId, + groupBy: activeGroup, + billableFilter, + resetExpansions: true, + }) + ); + + // Also refresh the main project data to update budget statistics + dispatch(getProject(projectId)); + + onClose(); + } catch (error) { + console.error('Failed to update project settings:', error); + message.error('Failed to update project settings'); + } finally { + setLoading(false); + } + }; + + // Handle cancel + const handleCancel = () => { + if (hasChanges) { + form.setFieldsValue(initialValues); + setHasChanges(false); + } + onClose(); + }; + + return ( + + + Project Budget Settings + + } + width={480} + open={visible} + onClose={handleCancel} + footer={ + + + + + } + > +
+ {/* Budget Configuration */} + + + Budget Configuration + + } + size="small" + style={{ marginBottom: 16 }} + > + +
+ + Project Budget + + + + + } + > + + + + + +
+ + + + + + + + + + + {taskBreakdown?.grouped_members?.map((group: any) => ( + + {/* Group Header */} + + + + + + + {/* Member Rows */} + {group.members?.map((member: any, index: number) => ( + + + + + + + ))} + + ))} + +
+ Role / Member + + Logged Hours + + Hourly Rate ({currency}) + + Actual Cost ({currency}) +
{group.jobRole} + {group.logged_hours?.toFixed(2) || '0.00'} + + - + + {group.actual_cost?.toFixed(2) || '0.00'} +
+ {member.name} + + {member.logged_hours?.toFixed(2) || '0.00'} + + {member.hourly_rate?.toFixed(2) || '0.00'} + + {member.actual_cost?.toFixed(2) || '0.00'} +
+ + )} +
+ + ); +}; + +export default FinanceDrawer; diff --git a/worklenz-frontend/src/components/projects/project-finance/finance-table-wrapper/FinanceTableWrapper.tsx b/worklenz-frontend/src/components/projects/project-finance/finance-table-wrapper/FinanceTableWrapper.tsx new file mode 100644 index 00000000..7ed275a6 --- /dev/null +++ b/worklenz-frontend/src/components/projects/project-finance/finance-table-wrapper/FinanceTableWrapper.tsx @@ -0,0 +1,370 @@ +import React, { useEffect, useState, useMemo } from 'react'; +import { Flex, Typography, Empty, Tooltip } from 'antd'; +import { themeWiseColor } from '@/utils/themeWiseColor'; +import { useAppSelector } from '@/hooks/useAppSelector'; +import { useTranslation } from 'react-i18next'; +import { useAppDispatch } from '@/hooks/useAppDispatch'; +import { openFinanceDrawer } from '@/features/finance/finance-slice'; +import { + FinanceTableColumnKeys, + getFinanceTableColumns, +} from '@/lib/project/project-view-finance-table-columns'; +import { formatManDays } from '@/utils/man-days-utils'; +import { IProjectFinanceGroup, IProjectFinanceTask } from '@/types/project/project-finance.types'; +import { createPortal } from 'react-dom'; +import FinanceTable from '../finance-table/FinanceTable'; +import FinanceDrawer from '../finance-drawer/FinanceDrawer'; + +interface FinanceTableWrapperProps { + activeTablesList: IProjectFinanceGroup[]; + loading: boolean; +} + +// Utility function to format seconds to time string +const formatSecondsToTimeString = (totalSeconds: number): string => { + if (!totalSeconds || totalSeconds === 0) return '0s'; + + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + const parts = []; + if (hours > 0) parts.push(`${hours}h`); + if (minutes > 0) parts.push(`${minutes}m`); + if (seconds > 0 || parts.length === 0) parts.push(`${seconds}s`); + + return parts.join(' '); +}; + +const FinanceTableWrapper: React.FC = ({ activeTablesList, loading }) => { + const [isScrolling, setIsScrolling] = useState(false); + + const { t } = useTranslation('project-view-finance'); + const dispatch = useAppDispatch(); + + const onTaskClick = (task: any) => { + dispatch(openFinanceDrawer(task)); + }; + + useEffect(() => { + const tableContainer = document.querySelector('.tasklist-container'); + const handleScroll = () => { + if (tableContainer) { + setIsScrolling(tableContainer.scrollLeft > 0); + } + }; + + tableContainer?.addEventListener('scroll', handleScroll); + return () => { + tableContainer?.removeEventListener('scroll', handleScroll); + }; + }, []); + + const themeMode = useAppSelector(state => state.themeReducer.mode); + const currency = useAppSelector( + state => state.projectFinancesReducer.project?.currency || '' + ).toUpperCase(); + const taskGroups = useAppSelector(state => state.projectFinancesReducer.taskGroups); + const financeProject = useAppSelector(state => state.projectFinancesReducer.project); + + // Get calculation method and hours per day from project + const calculationMethod = financeProject?.calculation_method || 'hourly'; + const hoursPerDay = financeProject?.hours_per_day || 8; + + // Get dynamic columns based on calculation method + const activeColumns = useMemo( + () => getFinanceTableColumns(calculationMethod), + [calculationMethod] + ); + + // Function to get tooltip text for column headers + const getColumnTooltip = (columnKey: FinanceTableColumnKeys): string => { + switch (columnKey) { + case FinanceTableColumnKeys.HOURS: + return t('columnTooltips.hours'); + case FinanceTableColumnKeys.MAN_DAYS: + return t('columnTooltips.manDays', { hoursPerDay }); + case FinanceTableColumnKeys.TOTAL_TIME_LOGGED: + return t('columnTooltips.totalTimeLogged'); + case FinanceTableColumnKeys.ESTIMATED_COST: + return calculationMethod === 'man_days' + ? t('columnTooltips.estimatedCostManDays') + : t('columnTooltips.estimatedCostHourly'); + case FinanceTableColumnKeys.COST: + return t('columnTooltips.actualCost'); + case FinanceTableColumnKeys.FIXED_COST: + return t('columnTooltips.fixedCost'); + case FinanceTableColumnKeys.TOTAL_BUDGET: + return calculationMethod === 'man_days' + ? t('columnTooltips.totalBudgetManDays') + : t('columnTooltips.totalBudgetHourly'); + case FinanceTableColumnKeys.TOTAL_ACTUAL: + return t('columnTooltips.totalActual'); + case FinanceTableColumnKeys.VARIANCE: + return t('columnTooltips.variance'); + default: + return ''; + } + }; + + // Use Redux store data for totals calculation to ensure reactivity + const totals = useMemo(() => { + // Recursive function to calculate totals from task hierarchy without double counting + const calculateTaskTotalsRecursively = (tasks: IProjectFinanceTask[]): any => { + return tasks.reduce( + (acc, task) => { + // For parent tasks with subtasks, aggregate values from subtasks only + // For leaf tasks, use their individual values + if (task.sub_tasks && task.sub_tasks.length > 0) { + // Parent task - only use aggregated values from subtasks (no parent's own values) + const subtaskTotals = calculateTaskTotalsRecursively(task.sub_tasks); + return { + hours: acc.hours + subtaskTotals.hours, + manDays: acc.manDays + subtaskTotals.manDays, + cost: acc.cost + subtaskTotals.cost, + fixedCost: acc.fixedCost + subtaskTotals.fixedCost, + totalBudget: acc.totalBudget + subtaskTotals.totalBudget, + totalActual: acc.totalActual + subtaskTotals.totalActual, + variance: acc.variance + subtaskTotals.variance, + total_time_logged: acc.total_time_logged + subtaskTotals.total_time_logged, + estimated_cost: acc.estimated_cost + subtaskTotals.estimated_cost, + }; + } else { + // Leaf task - use backend-provided calculated values + const leafTotalActual = task.total_actual || 0; + const leafTotalBudget = task.total_budget || 0; + return { + hours: acc.hours + (task.estimated_seconds || 0), + // Calculate man days from total_minutes, fallback to estimated_seconds if total_minutes is 0 + manDays: + acc.manDays + + (task.total_minutes > 0 + ? task.total_minutes / 60 / (hoursPerDay || 8) + : task.estimated_seconds / 3600 / (hoursPerDay || 8)), + cost: acc.cost + (task.actual_cost_from_logs || 0), + fixedCost: acc.fixedCost + (task.fixed_cost || 0), + totalBudget: acc.totalBudget + leafTotalBudget, + totalActual: acc.totalActual + leafTotalActual, + variance: acc.variance + (leafTotalBudget - leafTotalActual), + total_time_logged: acc.total_time_logged + (task.total_time_logged_seconds || 0), + estimated_cost: acc.estimated_cost + (task.estimated_cost || 0), + }; + } + }, + { + hours: 0, + manDays: 0, + cost: 0, + fixedCost: 0, + totalBudget: 0, + totalActual: 0, + variance: 0, + total_time_logged: 0, + estimated_cost: 0, + } + ); + }; + + return activeTablesList.reduce( + (acc, table: IProjectFinanceGroup) => { + const groupTotals = calculateTaskTotalsRecursively(table.tasks); + return { + hours: acc.hours + groupTotals.hours, + manDays: acc.manDays + groupTotals.manDays, + cost: acc.cost + groupTotals.cost, + fixedCost: acc.fixedCost + groupTotals.fixedCost, + totalBudget: acc.totalBudget + groupTotals.totalBudget, + totalActual: acc.totalActual + groupTotals.totalActual, + variance: acc.variance + groupTotals.variance, + total_time_logged: acc.total_time_logged + groupTotals.total_time_logged, + estimated_cost: acc.estimated_cost + groupTotals.estimated_cost, + }; + }, + { + hours: 0, + manDays: 0, + cost: 0, + fixedCost: 0, + totalBudget: 0, + totalActual: 0, + variance: 0, + total_time_logged: 0, + estimated_cost: 0, + } + ); + }, [activeTablesList, hoursPerDay]); + + const renderFinancialTableHeaderContent = (columnKey: FinanceTableColumnKeys) => { + switch (columnKey) { + case FinanceTableColumnKeys.HOURS: + return ( + + {formatSecondsToTimeString(totals.hours)} + + ); + case FinanceTableColumnKeys.MAN_DAYS: + return ( + + {formatManDays(totals.manDays, 1, hoursPerDay)} + + ); + case FinanceTableColumnKeys.COST: + return ( + {`${totals.cost?.toFixed(2)}`} + ); + case FinanceTableColumnKeys.FIXED_COST: + return ( + {totals.fixedCost?.toFixed(2)} + ); + case FinanceTableColumnKeys.TOTAL_BUDGET: + return ( + + {totals.totalBudget?.toFixed(2)} + + ); + case FinanceTableColumnKeys.TOTAL_ACTUAL: + return ( + + {totals.totalActual?.toFixed(2)} + + ); + case FinanceTableColumnKeys.VARIANCE: + return ( + + {totals.variance?.toFixed(2)} + + ); + case FinanceTableColumnKeys.TOTAL_TIME_LOGGED: + return ( + + {formatSecondsToTimeString(totals.total_time_logged)} + + ); + case FinanceTableColumnKeys.ESTIMATED_COST: + return ( + + {`${totals.estimated_cost?.toFixed(2)}`} + + ); + default: + return null; + } + }; + + const customColumnHeaderStyles = (key: FinanceTableColumnKeys) => + `px-2 text-left ${key === FinanceTableColumnKeys.TASK && 'sticky left-0 z-10'} ${key === FinanceTableColumnKeys.MEMBERS && `sticky left-[240px] z-10 ${isScrolling ? 'after:content after:absolute after:top-0 after:-right-1 after:-z-10 after:h-[68px] after:w-1.5 after:bg-transparent after:bg-gradient-to-r after:from-[rgba(0,0,0,0.12)] after:to-transparent' : ''}`} ${themeMode === 'dark' ? 'bg-[#1d1d1d] border-[#303030]' : 'bg-[#fafafa]'}`; + + const customColumnStyles = (key: FinanceTableColumnKeys) => + `px-2 text-left ${key === FinanceTableColumnKeys.TASK && `sticky left-0 z-10 ${isScrolling ? 'after:content after:absolute after:top-0 after:-right-1 after:-z-10 after:h-[56px] after:w-1.5 after:bg-transparent after:bg-gradient-to-r after:from-[rgba(0,0,0,0.12)] after:to-transparent' : ''}`} ${key === FinanceTableColumnKeys.MEMBERS && `sticky left-[240px] z-10 ${isScrolling ? 'after:content after:absolute after:top-0 after:-right-1 after:-z-10 after:h-[56px] after:w-1.5 after:bg-transparent after:bg-gradient-to-r after:from-[rgba(0,0,0,0.12)] after:to-transparent' : ''}`} ${themeMode === 'dark' ? 'bg-[#141414]' : 'bg-[#fbfbfb]'}`; + + // Check if there are any tasks across all groups + const hasAnyTasks = activeTablesList.some(table => table.tasks && table.tasks.length > 0); + + return ( + <> + + + + + {activeColumns.map(col => ( + + ))} + + + {hasAnyTasks && ( + + {activeColumns.map((col, index) => ( + + ))} + + )} + + {hasAnyTasks ? ( + activeTablesList.map(table => ( + + )) + ) : ( + + + + )} + +
+ + + {t(`${col.name}`)} {col.type === 'currency' && `(${currency.toUpperCase()})`} + + +
+ {col.key === FinanceTableColumnKeys.TASK ? ( + {t('totalText')} + ) : col.key === FinanceTableColumnKeys.MEMBERS ? null : ( + (col.type === 'hours' || + col.type === 'currency' || + col.type === 'man_days') && + renderFinancialTableHeaderContent(col.key) + )} +
+ {t('noTasksFound')} + } + image={Empty.PRESENTED_IMAGE_SIMPLE} + /> +
+
+ + {createPortal(, document.body)} + + ); +}; + +export default FinanceTableWrapper; diff --git a/worklenz-frontend/src/components/projects/project-finance/finance-table/FinanceTable.tsx b/worklenz-frontend/src/components/projects/project-finance/finance-table/FinanceTable.tsx new file mode 100644 index 00000000..c2386fbe --- /dev/null +++ b/worklenz-frontend/src/components/projects/project-finance/finance-table/FinanceTable.tsx @@ -0,0 +1,769 @@ +import { Flex, InputNumber, Skeleton, Tooltip, Typography } from 'antd'; +import React, { useEffect, useMemo, useState, useRef } from 'react'; +import { useAppSelector } from '@/hooks/useAppSelector'; +import { DollarCircleOutlined, DownOutlined, RightOutlined } from '@ant-design/icons'; +import { themeWiseColor } from '@/utils/themeWiseColor'; +import { colors } from '@/styles/colors'; +import { + financeTableColumns, + FinanceTableColumnKeys, + getFinanceTableColumns, +} from '@/lib/project/project-view-finance-table-columns'; +import { formatManDays } from '@/utils/man-days-utils'; +import Avatars from '@/components/avatars/avatars'; +import { IProjectFinanceGroup, IProjectFinanceTask } from '@/types/project/project-finance.types'; +import { + updateTaskFixedCostAsync, + toggleTaskExpansion, + fetchSubTasks, + fetchProjectFinancesSilent, +} 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'; + +import { useAuthService } from '@/hooks/useAuth'; +import { canEditFixedCost } from '@/utils/finance-permissions'; +import './finance-table.css'; +import { fetchPhasesByProjectId } from '@/features/projects/singleProject/phase/phases.slice'; +import { fetchPriorities } from '@/features/taskAttributes/taskPrioritySlice'; + +type FinanceTableProps = { + table: IProjectFinanceGroup; + loading: boolean; + onTaskClick: (task: any) => void; + columns?: any[]; +}; + +const FinanceTable = ({ table, loading, onTaskClick, columns }: FinanceTableProps) => { + const [isCollapse, setIsCollapse] = useState(false); + const [isScrolling, setIsScrolling] = useState(false); + const [selectedTask, setSelectedTask] = useState(null); + const [editingFixedCostValue, setEditingFixedCostValue] = useState(null); + const [tasks, setTasks] = useState(table.tasks); + const [hoveredTaskId, setHoveredTaskId] = useState(null); + const saveTimeoutRef = useRef(null); + const dispatch = useAppDispatch(); + + // Get the latest task groups from Redux store + const taskGroups = useAppSelector(state => state.projectFinancesReducer.taskGroups); + const { + activeGroup, + billableFilter, + project: financeProject, + } = useAppSelector(state => state.projectFinancesReducer); + + // Get calculation method and dynamic columns + const calculationMethod = financeProject?.calculation_method || 'hourly'; + const hoursPerDay = financeProject?.hours_per_day || 8; + const activeColumns = useMemo( + () => columns || getFinanceTableColumns(calculationMethod), + [columns, calculationMethod] + ); + + // Auth and permissions + const auth = useAuthService(); + const currentSession = auth.getCurrentSession(); + const { project } = useAppSelector(state => state.projectReducer); + const hasEditPermission = canEditFixedCost(currentSession, project); + + // Update local state when table.tasks or Redux store changes + useEffect(() => { + const updatedGroup = taskGroups.find(g => g.group_id === table.group_id); + if (updatedGroup) { + setTasks(updatedGroup.tasks); + } else { + setTasks(table.tasks); + } + }, [table.tasks, taskGroups, table.group_id]); + + // Handle click outside to close editing + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (selectedTask && !(event.target as Element)?.closest('.fixed-cost-input')) { + // Save current value before closing if it has changed + if (editingFixedCostValue !== null) { + immediateSaveFixedCost(editingFixedCostValue, selectedTask.id); + } else { + setSelectedTask(null); + setEditingFixedCostValue(null); + } + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [selectedTask, editingFixedCostValue, tasks]); + + // Cleanup timeout on unmount + useEffect(() => { + return () => { + if (saveTimeoutRef.current) { + clearTimeout(saveTimeoutRef.current); + } + }; + }, []); + + // get theme data from theme reducer + const themeMode = useAppSelector(state => state.themeReducer.mode); + + const formatNumber = (value: number | undefined | null) => { + if (value === undefined || value === null) return '0.00'; + return value.toFixed(2); + }; + + // Custom column styles for sticky positioning + const customColumnStyles = (key: FinanceTableColumnKeys) => + `px-2 text-left ${key === FinanceTableColumnKeys.TASK && 'sticky left-0 z-10'} ${key === FinanceTableColumnKeys.MEMBERS && `sticky left-[240px] z-10 ${isScrolling ? 'after:content after:absolute after:top-0 after:-right-1 after:-z-10 after:h-[40px] after:w-1.5 after:bg-transparent after:bg-gradient-to-r after:from-[rgba(0,0,0,0.12)] after:to-transparent' : ''}`} ${themeMode === 'dark' ? 'bg-[#1d1d1d] border-[#303030]' : 'bg-white'}`; + + const customHeaderColumnStyles = (key: FinanceTableColumnKeys) => + `px-2 text-left ${key === FinanceTableColumnKeys.TASK && 'sticky left-0 z-10'} ${key === FinanceTableColumnKeys.MEMBERS && `sticky left-[240px] z-10 ${isScrolling ? 'after:content after:absolute after:top-0 after:-right-1 after:-z-10 after:h-[40px] after:w-1.5 after:bg-transparent after:bg-gradient-to-r after:from-[rgba(0,0,0,0.12)] after:to-transparent' : ''}`}`; + + const renderFinancialTableHeaderContent = (columnKey: FinanceTableColumnKeys) => { + switch (columnKey) { + case FinanceTableColumnKeys.HOURS: + return {formattedTotals.hours}; + case FinanceTableColumnKeys.MAN_DAYS: + return ( + + {formatManDays(formattedTotals.man_days || 0, 1, hoursPerDay)} + + ); + case FinanceTableColumnKeys.TOTAL_TIME_LOGGED: + return {formattedTotals.total_time_logged}; + case FinanceTableColumnKeys.ESTIMATED_COST: + return {formatNumber(formattedTotals.estimated_cost)}; + case FinanceTableColumnKeys.COST: + return ( + {formatNumber(formattedTotals.actual_cost_from_logs)} + ); + case FinanceTableColumnKeys.FIXED_COST: + return {formatNumber(formattedTotals.fixed_cost)}; + case FinanceTableColumnKeys.TOTAL_BUDGET: + return {formatNumber(formattedTotals.total_budget)}; + case FinanceTableColumnKeys.TOTAL_ACTUAL: + return {formatNumber(formattedTotals.total_actual)}; + case FinanceTableColumnKeys.VARIANCE: + return ( + + {formatNumber(formattedTotals.variance)} + + ); + default: + return null; + } + }; + + const handleFixedCostChange = async (value: number | null, taskId: string) => { + const fixedCost = value || 0; + + // Find the task to check if it's a parent task + const findTask = (tasks: IProjectFinanceTask[], id: string): IProjectFinanceTask | null => { + for (const task of tasks) { + if (task.id === id) return task; + if (task.sub_tasks) { + const found = findTask(task.sub_tasks, id); + if (found) return found; + } + } + return null; + }; + + const task = findTask(tasks, taskId); + if (!task) { + console.error('Task not found:', taskId); + return; + } + + // Prevent editing fixed cost for parent tasks + if (task.sub_tasks_count > 0) { + console.warn( + 'Cannot edit fixed cost for parent tasks. Fixed cost is calculated from subtasks.' + ); + return; + } + + try { + // Update the task fixed cost - this will automatically trigger hierarchical recalculation + // The Redux slice handles parent task updates through recalculateTaskHierarchy + await dispatch( + updateTaskFixedCostAsync({ taskId, groupId: table.group_id, fixedCost }) + ).unwrap(); + + // Trigger a silent refresh with expansion reset to show updated data clearly + if (projectId) { + dispatch( + fetchProjectFinancesSilent({ + projectId, + groupBy: activeGroup, + billableFilter, + resetExpansions: true, + }) + ); + } + + setSelectedTask(null); + setEditingFixedCostValue(null); + } catch (error) { + console.error('Failed to update fixed cost:', error); + } + }; + + const { projectId } = useParams<{ projectId: string }>(); + + const handleTaskNameClick = (taskId: string) => { + if (!taskId || !projectId) return; + + dispatch(setSelectedTaskId(taskId)); + dispatch(fetchPhasesByProjectId(projectId)); + dispatch(fetchPriorities()); + dispatch(fetchTask({ taskId, projectId })); + dispatch(setShowTaskDrawer(true)); + }; + + // 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 + if (saveTimeoutRef.current) { + clearTimeout(saveTimeoutRef.current); + } + + // Set new timeout + saveTimeoutRef.current = setTimeout(() => { + // Find the current task to check if value actually changed + const findTask = (tasks: IProjectFinanceTask[], id: string): IProjectFinanceTask | null => { + for (const task of tasks) { + if (task.id === id) return task; + if (task.sub_tasks) { + const found = findTask(task.sub_tasks, id); + if (found) return found; + } + } + return null; + }; + + const currentTask = findTask(tasks, taskId); + const currentFixedCost = currentTask?.fixed_cost || 0; + const newFixedCost = value || 0; + + // Only save if the value actually changed + if (newFixedCost !== currentFixedCost && value !== null) { + handleFixedCostChange(value, taskId); + // Don't close the input automatically - let user explicitly close it + } + }, 5000); // Save after 5 seconds of inactivity + }; + + // Immediate save function (for enter/blur) + const immediateSaveFixedCost = (value: number | null, taskId: string) => { + // Clear any pending debounced save + if (saveTimeoutRef.current) { + clearTimeout(saveTimeoutRef.current); + saveTimeoutRef.current = null; + } + + // Find the current task to check if value actually changed + const findTask = (tasks: IProjectFinanceTask[], id: string): IProjectFinanceTask | null => { + for (const task of tasks) { + if (task.id === id) return task; + if (task.sub_tasks) { + const found = findTask(task.sub_tasks, id); + if (found) return found; + } + } + return null; + }; + + const currentTask = findTask(tasks, taskId); + const currentFixedCost = currentTask?.fixed_cost || 0; + const newFixedCost = value || 0; + + // Only save if the value actually changed + if (newFixedCost !== currentFixedCost && value !== null) { + handleFixedCostChange(value, taskId); + } else { + // Just close the editor without saving + setSelectedTask(null); + setEditingFixedCostValue(null); + } + }; + + // Calculate indentation based on nesting level + const getTaskIndentation = (level: number) => level * 32; // 32px per level for better visibility + + // Recursive function to render task hierarchy + const renderTaskHierarchy = ( + task: IProjectFinanceTask, + level: number = 0 + ): React.ReactElement[] => { + const elements: React.ReactElement[] = []; + + // Add the current task + const isHovered = hoveredTaskId === task.id; + const rowIndex = elements.length; + const defaultBg = + rowIndex % 2 === 0 + ? themeWiseColor('#fafafa', '#232323', themeMode) + : themeWiseColor('#ffffff', '#181818', themeMode); + const hoverBg = themeMode === 'dark' ? 'rgba(64, 169, 255, 0.08)' : 'rgba(24, 144, 255, 0.04)'; + + elements.push( + 0 ? 'finance-table-nested-task' : ''} ${themeMode === 'dark' ? 'dark' : ''}`} + onMouseEnter={() => setHoveredTaskId(task.id)} + onMouseLeave={() => setHoveredTaskId(null)} + > + {activeColumns.map(col => ( + e.stopPropagation() : undefined + } + > + {renderFinancialTableColumnContent(col.key, task, level)} + + ))} + + ); + + // Add subtasks recursively if they are expanded and loaded + if (task.show_sub_tasks && task.sub_tasks) { + task.sub_tasks.forEach(subTask => { + elements.push(...renderTaskHierarchy(subTask, level + 1)); + }); + } + + return elements; + }; + + const renderFinancialTableColumnContent = ( + columnKey: FinanceTableColumnKeys, + task: IProjectFinanceTask, + level: number = 0 + ) => { + switch (columnKey) { + case FinanceTableColumnKeys.TASK: + return ( + + + {/* Expand/collapse icon for parent tasks */} + {task.sub_tasks_count > 0 && ( +
{ + e.stopPropagation(); + handleTaskExpansion(task); + }} + > + {task.show_sub_tasks ? ( + + ) : ( + + )} +
+ )} + + {/* Spacer for tasks without subtasks to align with those that have expand icons */} + {task.sub_tasks_count === 0 && level > 0 && ( +
+ )} + + {/* Task name */} + 0 ? 26 : 18) + ), + cursor: 'pointer', + color: '#1890ff', + fontSize: Math.max(12, 14 - level * 0.3), // Slightly smaller font for deeper levels + opacity: Math.max(0.85, 1 - level * 0.03), // Slightly faded for deeper levels + fontWeight: level > 0 ? 400 : 500, // Slightly lighter weight for nested tasks + }} + onClick={e => { + e.stopPropagation(); + handleTaskNameClick(task.id); + }} + onMouseEnter={e => { + e.currentTarget.style.textDecoration = 'underline'; + }} + onMouseLeave={e => { + e.currentTarget.style.textDecoration = 'none'; + }} + > + {task.name} + + {task.billable && } + + + ); + case FinanceTableColumnKeys.MEMBERS: + return ( + task.members && ( +
{ + e.stopPropagation(); + onTaskClick(task); + }} + style={{ + cursor: 'pointer', + width: '100%', + }} + > + ({ + ...member, + avatar_url: member.avatar_url || undefined, + }))} + allowClickThrough={true} + /> +
+ ) + ); + case FinanceTableColumnKeys.HOURS: + return ( + + {task.estimated_hours} + + ); + case FinanceTableColumnKeys.MAN_DAYS: + // Backend now provides correct recursive aggregation for parent tasks + const taskManDays = + task.total_minutes > 0 + ? task.total_minutes / 60 / (hoursPerDay || 8) + : task.estimated_seconds / 3600 / (hoursPerDay || 8); + + return ( + + {formatManDays(taskManDays, 1, hoursPerDay)} + + ); + case FinanceTableColumnKeys.TOTAL_TIME_LOGGED: + return ( + + {task.total_time_logged} + + ); + case FinanceTableColumnKeys.ESTIMATED_COST: + return ( + + {formatNumber(task.estimated_cost)} + + ); + case FinanceTableColumnKeys.FIXED_COST: + // Parent tasks with subtasks should not be editable - they aggregate from subtasks + const isParentTask = task.sub_tasks_count > 0; + const canEditThisTask = hasEditPermission && !isParentTask; + + return selectedTask?.id === task.id && canEditThisTask ? ( + { + setEditingFixedCostValue(value); + // Trigger debounced save for up/down arrow clicks + debouncedSaveFixedCost(value, task.id); + }} + onBlur={() => { + // Immediate save on blur + immediateSaveFixedCost(editingFixedCostValue, task.id); + }} + onPressEnter={() => { + // Immediate save on enter + immediateSaveFixedCost(editingFixedCostValue, task.id); + }} + onFocus={e => { + // Select all text when input is focused + e.target.select(); + }} + autoFocus + style={{ width: '100%', textAlign: 'right', fontSize: Math.max(12, 14 - level * 0.5) }} + formatter={value => `${value}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',')} + parser={value => Number(value!.replace(/\$\s?|(,*)/g, ''))} + min={0} + precision={2} + className="fixed-cost-input" + /> + ) : ( + { + e.stopPropagation(); + setSelectedTask(task); + setEditingFixedCostValue(task.fixed_cost); + } + : undefined + } + title={isParentTask ? 'Fixed cost is calculated from subtasks' : undefined} + > + {formatNumber(task.fixed_cost)} + + ); + case FinanceTableColumnKeys.VARIANCE: + // Calculate variance as Budget - Actual (positive = under budget = good) + const varianceBudget = (task.estimated_cost || 0) + (task.fixed_cost || 0); + const varianceActual = (task.actual_cost_from_logs || 0) + (task.fixed_cost || 0); + const taskVariance = varianceBudget - varianceActual; + return ( + + {formatNumber(taskVariance)} + + ); + case FinanceTableColumnKeys.TOTAL_BUDGET: + const taskTotalBudget = (task.estimated_cost || 0) + (task.fixed_cost || 0); + return ( + + {formatNumber(taskTotalBudget)} + + ); + case FinanceTableColumnKeys.TOTAL_ACTUAL: + return ( + + {formatNumber(task.total_actual || 0)} + + ); + case FinanceTableColumnKeys.COST: + return ( + + {formatNumber(task.actual_cost_from_logs || 0)} + + ); + default: + return null; + } + }; + + // Utility function to format seconds to time string + const formatSecondsToTimeString = (totalSeconds: number): string => { + if (!totalSeconds || totalSeconds === 0) return '0s'; + + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + const parts = []; + if (hours > 0) parts.push(`${hours}h`); + if (minutes > 0) parts.push(`${minutes}m`); + if (seconds > 0 || parts.length === 0) parts.push(`${seconds}s`); + + return parts.join(' '); + }; + + // Generate flattened task list with all nested levels + const flattenedTasks = useMemo(() => { + const flattened: React.ReactElement[] = []; + + tasks.forEach(task => { + flattened.push(...renderTaskHierarchy(task, 0)); + }); + + return flattened; + }, [tasks, selectedTask, editingFixedCostValue, hasEditPermission, themeMode, hoveredTaskId]); + + // Calculate totals for the current table - backend provides correct aggregated values + const totals = useMemo(() => { + const calculateTaskTotals = (taskList: IProjectFinanceTask[]): any => { + let totals = { + hours: 0, + man_days: 0, + total_time_logged: 0, + estimated_cost: 0, + actual_cost_from_logs: 0, + fixed_cost: 0, + total_budget: 0, + total_actual: 0, + variance: 0, + }; + + for (const task of taskList) { + if (task.sub_tasks && task.sub_tasks.length > 0) { + // Parent task with loaded subtasks - only count subtasks recursively + const subtaskTotals = calculateTaskTotals(task.sub_tasks); + totals.hours += subtaskTotals.hours; + totals.man_days += subtaskTotals.man_days; + totals.total_time_logged += subtaskTotals.total_time_logged; + totals.estimated_cost += subtaskTotals.estimated_cost; + totals.actual_cost_from_logs += subtaskTotals.actual_cost_from_logs; + totals.fixed_cost += subtaskTotals.fixed_cost; + totals.total_budget += subtaskTotals.total_budget; + totals.total_actual += subtaskTotals.total_actual; + totals.variance += subtaskTotals.variance; + } else { + // Leaf task or parent task without loaded subtasks - use backend aggregated values + const leafTotalActual = task.total_actual || 0; + const leafTotalBudget = task.total_budget || 0; + totals.hours += task.estimated_seconds || 0; + // Use same calculation as individual task display - backend provides correct values + const taskManDays = + task.total_minutes > 0 + ? task.total_minutes / 60 / (hoursPerDay || 8) + : task.estimated_seconds / 3600 / (hoursPerDay || 8); + totals.man_days += taskManDays; + totals.total_time_logged += task.total_time_logged_seconds || 0; + totals.estimated_cost += task.estimated_cost || 0; + totals.actual_cost_from_logs += task.actual_cost_from_logs || 0; + totals.fixed_cost += task.fixed_cost || 0; + totals.total_budget += leafTotalBudget; + totals.total_actual += leafTotalActual; + totals.variance += leafTotalBudget - leafTotalActual; + } + } + + return totals; + }; + + return calculateTaskTotals(tasks); + }, [tasks, hoursPerDay]); + + // Format the totals for display + const formattedTotals = useMemo( + () => ({ + hours: formatSecondsToTimeString(totals.hours), + man_days: totals.man_days, + total_time_logged: formatSecondsToTimeString(totals.total_time_logged), + estimated_cost: totals.estimated_cost, + actual_cost_from_logs: totals.actual_cost_from_logs, + fixed_cost: totals.fixed_cost, + total_budget: totals.total_budget, + total_actual: totals.total_actual, + variance: totals.variance, + }), + [totals] + ); + + if (loading) { + return ( + + + + + + ); + } + + return ( + <> + {/* header row */} + + {activeColumns.map((col, index) => ( + setIsCollapse(prev => !prev) + : undefined + } + > + {col.key === FinanceTableColumnKeys.TASK ? ( + + {isCollapse ? : } + {table.group_name} ({tasks.length}) + + ) : col.key === FinanceTableColumnKeys.MEMBERS ? null : ( + renderFinancialTableHeaderContent(col.key) + )} + + ))} + + + {/* task rows with recursive hierarchy */} + {!isCollapse && flattenedTasks} + + ); +}; + +export default FinanceTable; diff --git a/worklenz-frontend/src/components/projects/project-finance/finance-table/finance-table.css b/worklenz-frontend/src/components/projects/project-finance/finance-table/finance-table.css new file mode 100644 index 00000000..62e89087 --- /dev/null +++ b/worklenz-frontend/src/components/projects/project-finance/finance-table/finance-table.css @@ -0,0 +1,63 @@ +/* Finance Table Styles */ + +/* Enhanced hierarchy visual indicators */ +.finance-table-task-row { + transition: all 0.2s ease-in-out; + border-bottom: 1px solid rgba(0, 0, 0, 0.06); +} + +.dark .finance-table-task-row { + border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} + +/* Hover effect is now handled by inline styles in the component for consistency */ + +/* Nested task styling */ +.finance-table-nested-task { + /* No visual connectors, just clean indentation */ +} + +/* Expand/collapse button styling */ +.finance-table-expand-btn { + transition: all 0.2s ease-in-out; + border-radius: 2px; + padding: 2px; +} + +.finance-table-expand-btn:hover { + background: rgba(0, 0, 0, 0.05); + transform: scale(1.1); +} + +.dark .finance-table-expand-btn:hover { + background: rgba(255, 255, 255, 0.05); +} + +/* Task name styling for different levels */ +.finance-table-task-name { + transition: all 0.2s ease-in-out; +} + +.finance-table-task-name:hover { + color: #40a9ff !important; +} + +/* Fixed cost input styling */ +.fixed-cost-input { + border-radius: 4px; +} + +.fixed-cost-input:focus { + box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2); +} + +/* Responsive adjustments for nested content */ +@media (max-width: 768px) { + .finance-table-nested-task { + padding-left: 12px; + } + + .finance-table-task-name { + font-size: 12px !important; + } +} diff --git a/worklenz-frontend/src/components/projects/project-finance/rate-card-drawer/RateCardDrawer.tsx b/worklenz-frontend/src/components/projects/project-finance/rate-card-drawer/RateCardDrawer.tsx new file mode 100644 index 00000000..ecae8bdd --- /dev/null +++ b/worklenz-frontend/src/components/projects/project-finance/rate-card-drawer/RateCardDrawer.tsx @@ -0,0 +1,586 @@ +import { + Drawer, + Select, + Typography, + Flex, + Button, + Input, + Table, + Tooltip, + Alert, + Space, + message, + Popconfirm, + DeleteOutlined, + ExclamationCircleFilled, + PlusOutlined, +} from '@/shared/antd-imports'; +import React, { useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useAppSelector } from '@/hooks/useAppSelector'; +import { useAppDispatch } from '@/hooks/useAppDispatch'; +import { + deleteRateCard, + fetchRateCardById, + fetchRateCards, + toggleRatecardDrawer, + updateRateCard, +} from '@/features/finance/finance-slice'; +import { RatecardType, IJobType } from '@/types/project/ratecard.types'; +import { IJobTitlesViewModel } from '@/types/job.types'; +import { jobTitlesApiService } from '@/api/settings/job-titles/job-titles.api.service'; +import { adminCenterApiService } from '@/api/admin-center/admin-center.api.service'; +import { colors } from '@/styles/colors'; +import CreateJobTitlesDrawer from '@/features/settings/job/CreateJobTitlesDrawer'; +import { CURRENCY_OPTIONS, DEFAULT_CURRENCY } from '@/shared/currencies'; +import { IOrganization } from '@/types/admin-center/admin-center.types'; + +interface PaginationType { + current: number; + pageSize: number; + field: string; + order: string; + total: number; + pageSizeOptions: string[]; + size: 'small' | 'default'; +} + +const RateCardDrawer = ({ + type, + ratecardId, + onSaved, +}: { + type: 'create' | 'update'; + ratecardId: string; + onSaved?: () => void; +}) => { + const [ratecardsList, setRatecardsList] = useState([]); + const [roles, setRoles] = useState([]); + const [initialRoles, setInitialRoles] = useState([]); + const [initialName, setInitialName] = useState('Untitled Rate Card'); + const [initialCurrency, setInitialCurrency] = useState(DEFAULT_CURRENCY); + const [addingRowIndex, setAddingRowIndex] = useState(null); + const [organization, setOrganization] = useState(null); + const { t } = useTranslation('settings/ratecard-settings'); + const drawerLoading = useAppSelector(state => state.financeReducer.isFinanceDrawerloading); + const drawerRatecard = useAppSelector(state => state.financeReducer.drawerRatecard); + const isDrawerOpen = useAppSelector(state => state.financeReducer.isRatecardDrawerOpen); + const dispatch = useAppDispatch(); + + const [isAddingRole, setIsAddingRole] = useState(false); + const [selectedJobTitleId, setSelectedJobTitleId] = useState(undefined); + const [searchQuery, setSearchQuery] = useState(''); + const [currency, setCurrency] = useState(DEFAULT_CURRENCY); + const [name, setName] = useState('Untitled Rate Card'); + const [jobTitles, setJobTitles] = useState({}); + const [pagination, setPagination] = useState({ + current: 1, + pageSize: 10000, + field: 'name', + order: 'desc', + total: 0, + pageSizeOptions: ['5', '10', '15', '20', '50', '100'], + size: 'small', + }); + const [editingRowIndex, setEditingRowIndex] = useState(null); + const [showUnsavedAlert, setShowUnsavedAlert] = useState(false); + const [messageApi, contextHolder] = message.useMessage(); + const [isCreatingJobTitle, setIsCreatingJobTitle] = useState(false); + const [newJobTitleName, setNewJobTitleName] = useState(''); + + // Determine if we're using man days calculation method + const isManDaysMethod = organization?.calculation_method === 'man_days'; + + // Detect changes + const hasChanges = useMemo(() => { + const rolesChanged = JSON.stringify(roles) !== JSON.stringify(initialRoles); + const nameChanged = name !== initialName; + const currencyChanged = currency !== initialCurrency; + return rolesChanged || nameChanged || currencyChanged; + }, [roles, name, currency, initialRoles, initialName, initialCurrency]); + + // Fetch organization details + useEffect(() => { + const fetchOrganization = async () => { + try { + const response = await adminCenterApiService.getOrganizationDetails(); + if (response.done) { + setOrganization(response.body); + } + } catch (error) { + console.error('Failed to fetch organization details:', error); + } + }; + + if (isDrawerOpen) { + fetchOrganization(); + } + }, [isDrawerOpen]); + + const getJobTitles = useMemo(() => { + return async () => { + const response = await jobTitlesApiService.getJobTitles( + pagination.current, + pagination.pageSize, + pagination.field, + pagination.order, + searchQuery + ); + if (response.done) { + setJobTitles(response.body); + setPagination(prev => ({ ...prev, total: response.body.total || 0 })); + } + }; + }, [pagination.current, pagination.pageSize, pagination.field, pagination.order, searchQuery]); + + useEffect(() => { + getJobTitles(); + }, []); + + const selectedRatecard = ratecardsList.find(ratecard => ratecard.id === ratecardId); + + useEffect(() => { + if (type === 'update' && ratecardId) { + dispatch(fetchRateCardById(ratecardId)); + } + }, [type, ratecardId, dispatch]); + + useEffect(() => { + if (type === 'update' && drawerRatecard) { + setRoles(drawerRatecard.jobRolesList || []); + setInitialRoles(drawerRatecard.jobRolesList || []); + setName(drawerRatecard.name || ''); + setInitialName(drawerRatecard.name || ''); + setCurrency(drawerRatecard.currency || DEFAULT_CURRENCY); + setInitialCurrency(drawerRatecard.currency || DEFAULT_CURRENCY); + } + }, [drawerRatecard, type]); + + const handleAddAllRoles = () => { + if (!jobTitles.data) return; + const existingIds = new Set(roles.map(r => r.job_title_id)); + const newRoles = jobTitles.data + .filter(jt => jt.id && !existingIds.has(jt.id)) + .map(jt => ({ + jobtitle: jt.name, + rate_card_id: ratecardId, + job_title_id: jt.id || '', + rate: 0, + man_day_rate: 0, + })); + const mergedRoles = [...roles, ...newRoles].filter( + (role, idx, arr) => arr.findIndex(r => r.job_title_id === role.job_title_id) === idx + ); + setRoles(mergedRoles); + }; + + const handleAddRole = () => { + if (Object.keys(jobTitles).length === 0) { + // Allow inline job title creation + setIsCreatingJobTitle(true); + } else { + // Add a new empty role to the table + const newRole = { + jobtitle: '', + rate_card_id: ratecardId, + job_title_id: '', + rate: 0, + man_day_rate: 0, + }; + setRoles([...roles, newRole]); + setAddingRowIndex(roles.length); + setIsAddingRole(true); + } + }; + + const handleCreateJobTitle = async () => { + if (!newJobTitleName.trim()) { + messageApi.warning(t('jobTitleNameRequired') || 'Job title name is required'); + return; + } + + try { + // Create the job title using the API + const response = await jobTitlesApiService.createJobTitle({ + name: newJobTitleName.trim(), + }); + + if (response.done) { + // Refresh job titles + await getJobTitles(); + + // Create a new role with the newly created job title + const newRole = { + jobtitle: newJobTitleName.trim(), + rate_card_id: ratecardId, + job_title_id: response.body.id, + rate: 0, + man_day_rate: 0, + }; + setRoles([...roles, newRole]); + + // Reset creation state + setIsCreatingJobTitle(false); + setNewJobTitleName(''); + + messageApi.success(t('jobTitleCreatedSuccess') || 'Job title created successfully'); + } else { + messageApi.error(t('jobTitleCreateError') || 'Failed to create job title'); + } + } catch (error) { + console.error('Failed to create job title:', error); + messageApi.error(t('jobTitleCreateError') || 'Failed to create job title'); + } + }; + + const handleCancelJobTitleCreation = () => { + setIsCreatingJobTitle(false); + setNewJobTitleName(''); + }; + + const handleDeleteRole = (index: number) => { + const updatedRoles = [...roles]; + updatedRoles.splice(index, 1); + setRoles(updatedRoles); + }; + + const handleSelectJobTitle = (jobTitleId: string) => { + if (roles.some(role => role.job_title_id === jobTitleId)) { + setIsAddingRole(false); + setSelectedJobTitleId(undefined); + return; + } + const jobTitle = jobTitles.data?.find(jt => jt.id === jobTitleId); + if (jobTitle) { + const newRole = { + jobtitle: jobTitle.name, + rate_card_id: ratecardId, + job_title_id: jobTitleId, + rate: 0, + man_day_rate: 0, + }; + setRoles([...roles, newRole]); + } + setIsAddingRole(false); + setSelectedJobTitleId(undefined); + }; + + const handleSave = async () => { + if (type === 'update' && ratecardId) { + try { + const filteredRoles = roles.filter(role => role.jobtitle && role.jobtitle.trim() !== ''); + await dispatch( + updateRateCard({ + id: ratecardId, + body: { + name, + currency, + jobRolesList: filteredRoles, + }, + }) as any + ); + await dispatch( + fetchRateCards({ + index: 1, + size: 10, + field: 'name', + order: 'desc', + search: '', + }) as any + ); + if (onSaved) onSaved(); + dispatch(toggleRatecardDrawer()); + // Reset initial states after save + setInitialRoles(filteredRoles); + setInitialName(name); + setInitialCurrency(currency); + setShowUnsavedAlert(false); + } catch (error) { + console.error('Failed to update rate card', error); + } + } + }; + + const columns = [ + { + title: t('jobTitleColumn'), + dataIndex: 'jobtitle', + render: (text: string, record: any, index: number) => { + if (index === addingRowIndex || index === editingRowIndex) { + return ( + + ); + } + return {record.jobtitle}; + }, + }, + { + title: isManDaysMethod + ? `${t('ratePerManDayColumn', { ns: 'project-view-finance' }) || 'Rate per day'} (${currency})` + : `${t('ratePerHourColumn')} (${currency})`, + dataIndex: isManDaysMethod ? 'man_day_rate' : 'rate', + align: 'right' as const, + render: (text: number, record: any, index: number) => ( + { + const newValue = parseInt(e.target.value, 10) || 0; + const updatedRoles = roles.map((role, idx) => + idx === index + ? { + ...role, + ...(isManDaysMethod ? { man_day_rate: newValue } : { rate: newValue }), + } + : role + ); + setRoles(updatedRoles); + }} + /> + ), + }, + { + title: t('actionsColumn') || 'Actions', + dataIndex: 'actions', + render: (_: any, __: any, index: number) => ( + } + okText={t('deleteConfirmationOk')} + cancelText={t('deleteConfirmationCancel')} + onConfirm={async () => { + handleDeleteRole(index); + }} + > + + + + + + ) : ( + + + {Object.keys(jobTitles).length === 0 + ? t('noJobTitlesAvailable') + : t('noRolesAdded')} + + + ), + }} + /> + + {organization && ( + + )} + + + + + ); +}; + +export default RateCardDrawer; diff --git a/worklenz-frontend/src/components/projects/project-finance/ratecard-table/RateCardTable.tsx b/worklenz-frontend/src/components/projects/project-finance/ratecard-table/RateCardTable.tsx new file mode 100644 index 00000000..ee5a80d4 --- /dev/null +++ b/worklenz-frontend/src/components/projects/project-finance/ratecard-table/RateCardTable.tsx @@ -0,0 +1,425 @@ +import React, { useEffect, useState } from 'react'; +import { + Avatar, + Button, + Input, + Popconfirm, + Table, + TableProps, + Select, + Flex, + InputRef, + DeleteOutlined, +} from '@/shared/antd-imports'; +import { useParams } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import CustomAvatar from '@/components/CustomAvatar'; +import { useAppSelector } from '@/hooks/useAppSelector'; +import { useAppDispatch } from '@/hooks/useAppDispatch'; +import { JobRoleType, RatecardType } from '@/types/project/ratecard.types'; +import { + assignMemberToRateCardRole, + deleteProjectRateCardRoleById, + fetchProjectRateCardRoles, + insertProjectRateCardRole, + updateProjectRateCardRoleById, + updateProjectRateCardRolesByProjectId, +} from '@/features/finance/project-finance-slice'; +import { jobTitlesApiService } from '@/api/settings/job-titles/job-titles.api.service'; +import { projectsApiService } from '@/api/projects/projects.api.service'; +import { IProjectMemberViewModel } from '@/types/projectMember.types'; +import { useAuthService } from '@/hooks/useAuth'; +import { canEditRateCard, canAddMembersToRateCard } from '@/utils/finance-permissions'; +import RateCardAssigneeSelector from '../../project-ratecard/RateCardAssigneeSelector'; + +const RateCardTable: React.FC = () => { + const dispatch = useAppDispatch(); + const { t } = useTranslation('project-view-finance'); + const { projectId } = useParams(); + + // Redux state + const rolesRedux = + useAppSelector(state => state.projectFinanceRateCardReducer.rateCardRoles) || []; + const isLoading = useAppSelector(state => state.projectFinanceRateCardReducer.isLoading); + const currency = useAppSelector( + state => state.projectFinancesReducer.project?.currency || 'USD' + ).toUpperCase(); + const financeProject = useAppSelector(state => state.projectFinancesReducer.project); + + // Get calculation method from project finance data + const calculationMethod = financeProject?.calculation_method || 'hourly'; + const rateInputRefs = React.useRef>([]); + + // Auth and permissions + const auth = useAuthService(); + const currentSession = auth.getCurrentSession(); + const { project } = useAppSelector(state => state.projectReducer); + const hasEditPermission = canEditRateCard(currentSession, project); + const canAddMembers = canAddMembersToRateCard(currentSession, project); + + // Local state for editing + const [roles, setRoles] = useState(rolesRedux); + const [addingRow, setAddingRow] = useState(false); + const [jobTitles, setJobTitles] = useState([]); + const [members, setMembers] = useState([]); + const [isLoadingMembers, setIsLoading] = useState(false); + const [focusRateIndex, setFocusRateIndex] = useState(null); + + const pagination = { + current: 1, + pageSize: 1000, + field: 'name', + order: 'asc', + }; + + const getProjectMembers = async () => { + if (!projectId) return; + setIsLoading(true); + try { + const res = await projectsApiService.getMembers( + projectId, + pagination.current, + pagination.pageSize, + pagination.field, + pagination.order, + null + ); + if (res.done) { + setMembers(res.body?.data || []); + } + } catch (error) { + console.error('Error fetching members:', error); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + getProjectMembers(); + }, [projectId]); + + // Fetch job titles for selection + useEffect(() => { + (async () => { + const res = await jobTitlesApiService.getJobTitles(1, 1000, 'name', 'asc', ''); + setJobTitles(res.body?.data || []); + })(); + }, []); + + // Sync local roles with redux roles + useEffect(() => { + setRoles(rolesRedux); + }, [rolesRedux]); + + // Fetch roles on mount + useEffect(() => { + if (projectId) { + dispatch(fetchProjectRateCardRoles(projectId)); + } + }, [dispatch, projectId]); + + useEffect(() => { + if (focusRateIndex !== null && rateInputRefs.current[focusRateIndex]) { + rateInputRefs.current[focusRateIndex]?.focus(); + setFocusRateIndex(null); + } + }, [roles, focusRateIndex]); + + // Add new role row + const handleAddRole = () => { + setAddingRow(true); + }; + + // Save all roles (bulk update) + const handleSaveAll = () => { + if (projectId) { + const filteredRoles = roles + .filter( + r => typeof r.job_title_id === 'string' && r.job_title_id && typeof r.rate !== 'undefined' + ) + .map(r => ({ + job_title_id: r.job_title_id as string, + jobtitle: r.jobtitle || r.name || '', + rate: Number(r.rate ?? 0), + man_day_rate: Number(r.man_day_rate ?? 0), + })); + dispatch( + updateProjectRateCardRolesByProjectId({ project_id: projectId, roles: filteredRoles }) + ); + } + }; + + // In handleSelectJobTitle, after successful insert, update the rate if needed + const handleSelectJobTitle = async (jobTitleId: string) => { + const jobTitle = jobTitles.find(jt => jt.id === jobTitleId); + if (!jobTitle || !projectId) return; + if (roles.some(r => r.job_title_id === jobTitleId)) return; + + // Set the appropriate rate based on calculation method + const isManDays = calculationMethod === 'man_days'; + const resultAction = await dispatch( + insertProjectRateCardRole({ + project_id: projectId, + job_title_id: jobTitleId, + rate: 0, // Always initialize rate as 0 + man_day_rate: isManDays ? 0 : undefined, // Only set man_day_rate for man_days mode + }) + ); + + if (insertProjectRateCardRole.fulfilled.match(resultAction)) { + // Re-fetch roles and focus the last one (newly added) + dispatch(fetchProjectRateCardRoles(projectId)).then(() => { + setFocusRateIndex(roles.length); // The new row will be at the end + }); + } + setAddingRow(false); + }; + + // Update handleRateChange to update the correct field + const handleRateChange = (value: string | number, index: number) => { + setRoles(prev => + prev.map((role, idx) => + idx === index + ? { + ...role, + ...(calculationMethod === 'man_days' + ? { man_day_rate: Number(value) } + : { rate: Number(value) }), + } + : role + ) + ); + }; + + // Handle delete + const handleDelete = (record: JobRoleType, index: number) => { + if (record.id) { + dispatch(deleteProjectRateCardRoleById(record.id)); + } else { + setRoles(roles.filter((_, idx) => idx !== index)); + } + }; + + // Handle member change + const handleMemberChange = async (memberId: string, rowIndex: number, record: JobRoleType) => { + if (!projectId || !record.id) return; // Ensure required IDs are present + try { + const resultAction = await dispatch( + assignMemberToRateCardRole({ + project_id: projectId, + member_id: memberId, + project_rate_card_role_id: record.id, + }) + ); + if (assignMemberToRateCardRole.fulfilled.match(resultAction)) { + const updatedMembers = resultAction.payload; // Array of member IDs + setRoles(prev => + prev.map((role, idx) => { + if (idx !== rowIndex) return role; + return { ...role, members: updatedMembers?.members || [] }; + }) + ); + } + } catch (error) { + console.error('Error assigning member:', error); + } + }; + // Separate function for updating rate if changed + const handleRateBlur = (value: string, index: number) => { + const isManDays = calculationMethod === 'man_days'; + // Compare with Redux value, not local state + const reduxRole = rolesRedux[index]; + const reduxValue = isManDays + ? String(reduxRole?.man_day_rate ?? 0) + : String(reduxRole?.rate ?? 0); + if (value !== reduxValue) { + const payload = { + id: roles[index].id!, + body: { + job_title_id: String(roles[index].job_title_id), + // Only update the field that corresponds to the current calculation method + ...(isManDays + ? { + rate: String(reduxRole?.rate ?? 0), // Keep existing rate value + man_day_rate: String(value), // Update man_day_rate with new value + } + : { + rate: String(value), // Update rate with new value + man_day_rate: String(reduxRole?.man_day_rate ?? 0), // Keep existing man_day_rate value + }), + }, + }; + dispatch(updateProjectRateCardRoleById(payload)); + } + }; + + const assignedMembers = roles + .flatMap(role => role.members || []) + .filter((memberId, index, self) => self.indexOf(memberId) === index); + + // Columns + const columns: TableProps['columns'] = [ + { + title: t('jobTitleColumn'), + dataIndex: 'jobtitle', + render: (text: string, record: JobRoleType, index: number) => { + if (addingRow && index === roles.length) { + return ( + + ); + } + return {text || record.name}; + }, + }, + { + title: `${calculationMethod === 'man_days' ? t('ratePerManDayColumn') : t('ratePerHourColumn')} (${currency})`, + dataIndex: 'rate', + align: 'right', + render: (value: number, record: JobRoleType, index: number) => ( + { + if (el) rateInputRefs.current[index] = el as unknown as HTMLInputElement; + }} + type="number" + value={ + calculationMethod === 'man_days' + ? (roles[index]?.man_day_rate ?? 0) + : (roles[index]?.rate ?? 0) + } + min={0} + disabled={!hasEditPermission} + style={{ + background: 'transparent', + border: 'none', + boxShadow: 'none', + padding: 0, + width: 80, + textAlign: 'right', + opacity: hasEditPermission ? 1 : 0.7, + cursor: hasEditPermission ? 'text' : 'not-allowed', + }} + onChange={ + hasEditPermission + ? e => handleRateChange((e.target as HTMLInputElement).value, index) + : undefined + } + onBlur={ + hasEditPermission + ? e => handleRateBlur((e.target as HTMLInputElement).value, index) + : undefined + } + onPressEnter={ + hasEditPermission + ? e => handleRateBlur((e.target as HTMLInputElement).value, index) + : undefined + } + /> + ), + }, + { + title: t('membersColumn'), + dataIndex: 'members', + render: (memberscol: string[] | null | undefined, record: JobRoleType, index: number) => ( +
+ + {memberscol?.map((memberId, i) => { + const member = members.find(m => m.id === memberId); + return member ? ( + + ) : null; + })} + + {canAddMembers && ( +
+ handleMemberChange(memberId, index, record)} + memberlist={members} + assignedMembers={assignedMembers} // Pass assigned members here + /> +
+ )} +
+ ), + }, + { + title: t('actions'), + key: 'actions', + align: 'center', + render: (_: any, record: JobRoleType, index: number) => + hasEditPermission ? ( + handleDelete(record, index)} + okText={t('yes')} + cancelText={t('no')} + > +