diff --git a/docs/api/task-breakdown-api.md b/docs/api/task-breakdown-api.md new file mode 100644 index 00000000..50d79df8 --- /dev/null +++ b/docs/api/task-breakdown-api.md @@ -0,0 +1,195 @@ +# Task Breakdown API + +## Get Task Financial Breakdown + +**Endpoint:** `GET /api/project-finance/task/:id/breakdown` + +**Description:** Retrieves detailed financial breakdown for a single task, including members grouped by job roles with labor hours and costs. + +### Parameters + +- `id` (path parameter): UUID of the task + +### Response + +```json +{ + "success": true, + "body": { + "task": { + "id": "uuid", + "name": "Task Name", + "project_id": "uuid", + "billable": true, + "estimated_hours": 10.5, + "logged_hours": 8.25, + "estimated_labor_cost": 525.0, + "actual_labor_cost": 412.5, + "fixed_cost": 100.0, + "total_estimated_cost": 625.0, + "total_actual_cost": 512.5 + }, + "grouped_members": [ + { + "jobRole": "Frontend Developer", + "estimated_hours": 5.25, + "logged_hours": 4.0, + "estimated_cost": 262.5, + "actual_cost": 200.0, + "members": [ + { + "team_member_id": "uuid", + "name": "John Doe", + "avatar_url": "https://...", + "hourly_rate": 50.0, + "estimated_hours": 5.25, + "logged_hours": 4.0, + "estimated_cost": 262.5, + "actual_cost": 200.0 + } + ] + }, + { + "jobRole": "Backend Developer", + "estimated_hours": 5.25, + "logged_hours": 4.25, + "estimated_cost": 262.5, + "actual_cost": 212.5, + "members": [ + { + "team_member_id": "uuid", + "name": "Jane Smith", + "avatar_url": "https://...", + "hourly_rate": 50.0, + "estimated_hours": 5.25, + "logged_hours": 4.25, + "estimated_cost": 262.5, + "actual_cost": 212.5 + } + ] + } + ], + "members": [ + { + "team_member_id": "uuid", + "name": "John Doe", + "avatar_url": "https://...", + "hourly_rate": 50.0, + "job_title_name": "Frontend Developer", + "estimated_hours": 5.25, + "logged_hours": 4.0, + "estimated_cost": 262.5, + "actual_cost": 200.0 + }, + { + "team_member_id": "uuid", + "name": "Jane Smith", + "avatar_url": "https://...", + "hourly_rate": 50.0, + "job_title_name": "Backend Developer", + "estimated_hours": 5.25, + "logged_hours": 4.25, + "estimated_cost": 262.5, + "actual_cost": 212.5 + } + ] + } +} +``` + +### Error Responses + +- `404 Not Found`: Task not found +- `400 Bad Request`: Invalid task ID + +### Usage + +This endpoint is designed to work with the finance drawer component (`@finance-drawer.tsx`) to provide detailed cost breakdown information for individual tasks. The response includes: + +1. **Task Summary**: Overall task financial information +2. **Grouped Members**: Members organized by job role with aggregated costs +3. **Individual Members**: Detailed breakdown for each team member + +The data structure matches what the finance drawer expects, with members grouped by job roles and individual labor hours and costs calculated based on: +- Estimated hours divided equally among assignees +- Actual logged time per member +- Hourly rates from project rate cards +- Fixed costs added to the totals + +### Frontend Usage Example + +```typescript +import { projectFinanceApiService } from '@/api/project-finance-ratecard/project-finance.api.service'; + +// Fetch task breakdown +const fetchTaskBreakdown = async (taskId: string) => { + try { + const response = await projectFinanceApiService.getTaskBreakdown(taskId); + const breakdown = response.body; + + console.log('Task:', breakdown.task); + console.log('Grouped Members:', breakdown.grouped_members); + console.log('Individual Members:', breakdown.members); + + return breakdown; + } catch (error) { + console.error('Error fetching task breakdown:', error); + throw error; + } +}; + +// Usage in React component +const TaskBreakdownComponent = ({ taskId }: { taskId: string }) => { + const [breakdown, setBreakdown] = useState(null); + const [loading, setLoading] = useState(false); + + useEffect(() => { + const loadBreakdown = async () => { + setLoading(true); + try { + const data = await fetchTaskBreakdown(taskId); + setBreakdown(data); + } catch (error) { + // Handle error + } finally { + setLoading(false); + } + }; + + if (taskId) { + loadBreakdown(); + } + }, [taskId]); + + if (loading) return ; + if (!breakdown) return null; + + return ( +
+

{breakdown.task.name}

+

Total Estimated Cost: ${breakdown.task.total_estimated_cost}

+

Total Actual Cost: ${breakdown.task.total_actual_cost}

+ + {breakdown.grouped_members.map(group => ( +
+

{group.jobRole}

+

Hours: {group.estimated_hours} | Cost: ${group.estimated_cost}

+ {group.members.map(member => ( +
+ {member.name}: {member.estimated_hours}h @ ${member.hourly_rate}/h +
+ ))} +
+ ))} +
+ ); +}; +``` + +### Integration + +This API complements the existing finance endpoints: +- `GET /api/project-finance/project/:project_id/tasks` - Get all tasks for a project +- `PUT /api/project-finance/task/:task_id/fixed-cost` - Update task fixed cost + +The finance drawer component has been updated to automatically use this API when a task is selected, providing real-time financial breakdown data. \ No newline at end of file diff --git a/worklenz-backend/database/migrations/20250520000000-add-fixed-cost-to-tasks.sql b/worklenz-backend/database/migrations/20250520000000-add-fixed-cost-to-tasks.sql new file mode 100644 index 00000000..e43aaed4 --- /dev/null +++ b/worklenz-backend/database/migrations/20250520000000-add-fixed-cost-to-tasks.sql @@ -0,0 +1,6 @@ +-- Add fixed_cost column to tasks table for project finance functionality +ALTER TABLE tasks +ADD COLUMN fixed_cost DECIMAL(10, 2) DEFAULT 0 CHECK (fixed_cost >= 0); + +-- Add comment to explain the column +COMMENT ON COLUMN tasks.fixed_cost IS 'Fixed cost for the task in addition to hourly rate calculations'; \ No newline at end of file diff --git a/worklenz-backend/database/sql/4_functions.sql b/worklenz-backend/database/sql/4_functions.sql index 9c9cc820..8dbece0e 100644 --- a/worklenz-backend/database/sql/4_functions.sql +++ b/worklenz-backend/database/sql/4_functions.sql @@ -6372,3 +6372,44 @@ BEGIN ); END; $$; + +CREATE OR REPLACE VIEW project_finance_view AS +SELECT + t.id, + t.name, + t.total_minutes / 3600.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((SELECT SUM(rate * (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 tm.id = pm.team_member_id + LEFT JOIN finance_project_rate_card_roles pmr ON pm.project_rate_card_role_id = pmr.id + WHERE twl.task_id = t.id), 0) as estimated_cost, + 0 as fixed_cost, -- Default to 0 since the column doesn't exist + COALESCE(t.total_minutes / 3600.0 * + (SELECT rate FROM finance_project_rate_card_roles + WHERE project_id = t.project_id + AND id = (SELECT project_rate_card_role_id FROM project_members WHERE team_member_id = t.reporter_id LIMIT 1) + LIMIT 1), 0) as total_budgeted_cost, + COALESCE((SELECT SUM(rate * (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 tm.id = pm.team_member_id + LEFT JOIN finance_project_rate_card_roles pmr ON pm.project_rate_card_role_id = pmr.id + WHERE twl.task_id = t.id), 0) as total_actual_cost, + COALESCE((SELECT SUM(rate * (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 tm.id = pm.team_member_id + LEFT JOIN finance_project_rate_card_roles pmr ON pm.project_rate_card_role_id = pmr.id + WHERE twl.task_id = t.id), 0) - + COALESCE(t.total_minutes / 3600.0 * + (SELECT rate FROM finance_project_rate_card_roles + WHERE project_id = t.project_id + AND id = (SELECT project_rate_card_role_id FROM project_members WHERE team_member_id = t.reporter_id LIMIT 1) + LIMIT 1), 0) as variance, + t.project_id +FROM tasks t; diff --git a/worklenz-backend/src/controllers/project-finance-controller.ts b/worklenz-backend/src/controllers/project-finance-controller.ts index d5e3160b..5a9903be 100644 --- a/worklenz-backend/src/controllers/project-finance-controller.ts +++ b/worklenz-backend/src/controllers/project-finance-controller.ts @@ -5,6 +5,8 @@ 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"; export default class ProjectfinanceController extends WorklenzControllerBase { @HandleExceptions() @@ -12,124 +14,382 @@ export default class ProjectfinanceController extends WorklenzControllerBase { req: IWorkLenzRequest, res: IWorkLenzResponse ): Promise { - const { project_id } = req.params; - const { group_by = "status" } = req.query; + const projectId = req.params.project_id; + const groupBy = req.query.group || "status"; + // 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; + + // Get all tasks with their financial data - using project_members.project_rate_card_role_id const q = ` - WITH task_data AS ( + WITH task_costs AS ( SELECT t.id, t.name, + COALESCE(t.total_minutes, 0)::float as estimated_hours, + COALESCE((SELECT SUM(time_spent) FROM task_work_log WHERE task_id = t.id), 0) / 3600.0::float as total_time_logged, + t.project_id, t.status_id, t.priority_id, - tp.phase_id, - (t.total_minutes / 3600.0) as estimated_hours, - (COALESCE((SELECT SUM(time_spent) FROM task_work_log WHERE task_id = t.id), 0) / 3600.0) as actual_hours, - t.completed_at, - t.created_at, - t.updated_at, - t.billable, - s.name as status_name, - p.name as priority_name, - ph.name as phase_name, - (SELECT color_code FROM sys_task_status_categories WHERE id = s.category_id) as status_color, - (SELECT color_code_dark FROM sys_task_status_categories WHERE id = s.category_id) as status_color_dark, - (SELECT color_code FROM task_priorities WHERE id = t.priority_id) as priority_color, - (SELECT color_code FROM project_phases WHERE id = tp.phase_id) as phase_color, + (SELECT phase_id FROM task_phase WHERE task_id = t.id) as phase_id, (SELECT get_task_assignees(t.id)) as assignees, - json_agg( - json_build_object( - 'name', u.name, - 'avatar_url', u.avatar_url, - 'team_member_id', tm.id, - 'color_code', '#1890ff' - ) - ) FILTER (WHERE u.id IS NOT NULL) as members + t.billable, + COALESCE(t.fixed_cost, 0) as fixed_cost FROM tasks t - LEFT JOIN task_statuses s ON t.status_id = s.id - LEFT JOIN task_priorities p ON t.priority_id = p.id - LEFT JOIN task_phase tp ON t.id = tp.task_id - LEFT JOIN project_phases ph ON tp.phase_id = ph.id - LEFT JOIN tasks_assignees ta ON t.id = ta.task_id - LEFT JOIN project_members pm ON ta.project_member_id = pm.id - LEFT JOIN team_members tm ON pm.team_member_id = tm.id - LEFT JOIN finance_project_rate_card_roles pmr ON pm.project_rate_card_role_id = pmr.id - LEFT JOIN users u ON tm.user_id = u.id - LEFT JOIN job_titles jt ON tm.job_title_id = jt.id - WHERE t.project_id = $1 - GROUP BY - t.id, - s.name, - p.name, - ph.name, - tp.phase_id, - s.category_id, - t.priority_id + WHERE t.project_id = $1 AND t.archived = false + ), + task_estimated_costs AS ( + SELECT + tc.*, + -- Calculate estimated cost based on estimated hours and assignee rates from project_members + COALESCE(( + SELECT SUM(tc.estimated_hours * COALESCE(fprr.rate, 0)) + FROM json_array_elements(tc.assignees) AS assignee_json + LEFT JOIN project_members pm ON pm.team_member_id = (assignee_json->>'team_member_id')::uuid + AND pm.project_id = tc.project_id + LEFT JOIN finance_project_rate_card_roles fprr ON fprr.id = pm.project_rate_card_role_id + WHERE assignee_json->>'team_member_id' IS NOT NULL + ), 0) as estimated_cost, + -- Calculate actual cost based on time logged and assignee rates from project_members + COALESCE(( + SELECT SUM( + COALESCE(fprr.rate, 0) * (twl.time_spent / 3600.0) + ) + FROM task_work_log twl + LEFT JOIN users u ON twl.user_id = u.id + LEFT JOIN team_members tm ON u.id = tm.user_id + LEFT JOIN project_members pm ON pm.team_member_id = tm.id AND pm.project_id = tc.project_id + LEFT JOIN finance_project_rate_card_roles fprr ON fprr.id = pm.project_rate_card_role_id + WHERE twl.task_id = tc.id + ), 0) as actual_cost_from_logs + FROM task_costs tc ) SELECT - CASE - WHEN $2 = 'status' THEN status_id - WHEN $2 = 'priority' THEN priority_id - WHEN $2 = 'phases' THEN phase_id - END as group_id, - CASE - WHEN $2 = 'status' THEN status_name - WHEN $2 = 'priority' THEN priority_name - WHEN $2 = 'phases' THEN phase_name - END as group_name, - CASE - WHEN $2 = 'status' THEN status_color - WHEN $2 = 'priority' THEN priority_color - WHEN $2 = 'phases' THEN phase_color - END as color_code, - CASE - WHEN $2 = 'status' THEN status_color_dark - WHEN $2 = 'priority' THEN priority_color - WHEN $2 = 'phases' THEN phase_color - END as color_code_dark, - json_agg( - json_build_object( - 'id', id, - 'name', name, - 'status_id', status_id, - 'priority_id', priority_id, - 'phase_id', phase_id, - 'estimated_hours', estimated_hours, - 'actual_hours', actual_hours, - 'completed_at', completed_at, - 'created_at', created_at, - 'updated_at', updated_at, - 'billable', billable, - 'assignees', assignees, - 'members', members - ) - ) as tasks - FROM task_data - GROUP BY - CASE - WHEN $2 = 'status' THEN status_id - WHEN $2 = 'priority' THEN priority_id - WHEN $2 = 'phases' THEN phase_id - END, - CASE - WHEN $2 = 'status' THEN status_name - WHEN $2 = 'priority' THEN priority_name - WHEN $2 = 'phases' THEN phase_name - END, - CASE - WHEN $2 = 'status' THEN status_color - WHEN $2 = 'priority' THEN priority_color - WHEN $2 = 'phases' THEN phase_color - END, - CASE - WHEN $2 = 'status' THEN status_color_dark - WHEN $2 = 'priority' THEN priority_color - WHEN $2 = 'phases' THEN phase_color - END - ORDER BY group_name; + tec.*, + (tec.estimated_cost + tec.fixed_cost) as total_budget, + (tec.actual_cost_from_logs + tec.fixed_cost) as total_actual, + ((tec.actual_cost_from_logs + tec.fixed_cost) - (tec.estimated_cost + tec.fixed_cost)) as variance + FROM task_estimated_costs tec; `; - const result = await db.query(q, [project_id, group_by]); - return res.status(200).send(new ServerResponse(true, result.rows)); + const result = await db.query(q, [projectId]); + 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_hours: Number(task.estimated_hours) || 0, + total_time_logged: Number(task.total_time_logged) || 0, + estimated_cost: Number(task.estimated_cost) || 0, + fixed_cost: Number(task.fixed_cost) || 0, + total_budget: Number(task.total_budget) || 0, + total_actual: Number(task.total_actual) || 0, + variance: Number(task.variance) || 0, + members: task.assignees, + billable: task.billable + })) + }; + }); + + // Include project rate cards in the response for reference + const responseData = { + groups: groupedTasks, + project_rate_cards: projectRateCards + }; + + 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")); + } + + const q = ` + UPDATE tasks + SET fixed_cost = $1, updated_at = NOW() + WHERE id = $2 + RETURNING id, name, fixed_cost; + `; + + const result = await db.query(q, [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, result.rows[0])); + } + + @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)); } } diff --git a/worklenz-backend/src/routes/apis/project-finance-api-router.ts b/worklenz-backend/src/routes/apis/project-finance-api-router.ts index 9af6f11f..6254baa0 100644 --- a/worklenz-backend/src/routes/apis/project-finance-api-router.ts +++ b/worklenz-backend/src/routes/apis/project-finance-api-router.ts @@ -1,9 +1,17 @@ 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", ProjectfinanceController.getTasks); +projectFinanceApiRouter.get( + "/task/:id/breakdown", + idParamValidator, + safeControllerFunction(ProjectfinanceController.getTaskBreakdown) +); +projectFinanceApiRouter.put("/task/:task_id/fixed-cost", ProjectfinanceController.updateTaskFixedCost); export default projectFinanceApiRouter; \ 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 index 2b7e4d54..2c2d0325 100644 --- a/worklenz-frontend/public/locales/en/project-view-finance.json +++ b/worklenz-frontend/public/locales/en/project-view-finance.json @@ -11,13 +11,16 @@ "taskColumn": "Task", "membersColumn": "Members", - "hoursColumn": "Hours", + "hoursColumn": "Estimated Hours", + "totalTimeLoggedColumn": "Total Time Logged", "costColumn": "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.", diff --git a/worklenz-frontend/public/locales/es/project-view-finance.json b/worklenz-frontend/public/locales/es/project-view-finance.json index fdf9849d..bd2fa024 100644 --- a/worklenz-frontend/public/locales/es/project-view-finance.json +++ b/worklenz-frontend/public/locales/es/project-view-finance.json @@ -11,13 +11,16 @@ "taskColumn": "Tarea", "membersColumn": "Miembros", - "hoursColumn": "Horas", + "hoursColumn": "Horas Estimadas", + "totalTimeLoggedColumn": "Tiempo Total Registrado", "costColumn": "Costo", + "estimatedCostColumn": "Costo Estimado", "fixedCostColumn": "Costo Fijo", "totalBudgetedCostColumn": "Costo Total Presupuestado", "totalActualCostColumn": "Costo Total Real", "varianceColumn": "Diferencia", "totalText": "Total", + "noTasksFound": "No se encontraron tareas", "addRoleButton": "+ Agregar Rol", "ratecardImportantNotice": "* Esta tarifa se genera en función de los títulos de trabajo y tarifas estándar de la empresa. Sin embargo, tienes la flexibilidad de modificarla según el proyecto. Estos cambios no afectarán los títulos de trabajo y tarifas estándar de la organización.", diff --git a/worklenz-frontend/public/locales/pt/project-view-finance.json b/worklenz-frontend/public/locales/pt/project-view-finance.json index db5c67c6..be3d31f2 100644 --- a/worklenz-frontend/public/locales/pt/project-view-finance.json +++ b/worklenz-frontend/public/locales/pt/project-view-finance.json @@ -11,13 +11,16 @@ "taskColumn": "Tarefa", "membersColumn": "Membros", - "hoursColumn": "Horas", + "hoursColumn": "Horas Estimadas", + "totalTimeLoggedColumn": "Tempo Total Registrado", "costColumn": "Custo", + "estimatedCostColumn": "Custo Estimado", "fixedCostColumn": "Custo Fixo", "totalBudgetedCostColumn": "Custo Total Orçado", "totalActualCostColumn": "Custo Total Real", "varianceColumn": "Variação", "totalText": "Total", + "noTasksFound": "Nenhuma tarefa encontrada", "addRoleButton": "+ Adicionar Função", "ratecardImportantNotice": "* Esta tabela de taxas é gerada com base nos cargos e taxas padrão da empresa. No entanto, você tem a flexibilidade de modificá-la de acordo com o projeto. Essas alterações não impactarão os cargos e taxas padrão da organização.", diff --git a/worklenz-frontend/src/api/project-finance-ratecard/project-finance.api.service.ts b/worklenz-frontend/src/api/project-finance-ratecard/project-finance.api.service.ts index bfbf71b4..99139070 100644 --- a/worklenz-frontend/src/api/project-finance-ratecard/project-finance.api.service.ts +++ b/worklenz-frontend/src/api/project-finance-ratecard/project-finance.api.service.ts @@ -1,7 +1,7 @@ import { API_BASE_URL } from "@/shared/constants"; import { IServerResponse } from "@/types/common.types"; import apiClient from "../api-client"; -import { IProjectFinanceGroup } from "@/types/project/project-finance.types"; +import { IProjectFinanceResponse, ITaskBreakdownResponse } from "@/types/project/project-finance.types"; const rootUrl = `${API_BASE_URL}/project-finance`; @@ -9,13 +9,34 @@ export const projectFinanceApiService = { getProjectTasks: async ( projectId: string, groupBy: 'status' | 'priority' | 'phases' = 'status' - ): Promise> => { - const response = await apiClient.get>( + ): Promise> => { + const response = await apiClient.get>( `${rootUrl}/project/${projectId}/tasks`, { params: { group_by: groupBy } } ); + console.log(response.data); + 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; }, } \ No newline at end of file diff --git a/worklenz-frontend/src/app/store.ts b/worklenz-frontend/src/app/store.ts index f9f13429..7ce0ccdc 100644 --- a/worklenz-frontend/src/app/store.ts +++ b/worklenz-frontend/src/app/store.ts @@ -73,6 +73,7 @@ import financeReducer from '../features/finance/finance-slice'; import roadmapReducer from '../features/roadmap/roadmap-slice'; import teamMembersReducer from '@features/team-members/team-members.slice'; import projectFinanceRateCardReducer from '../features/finance/project-finance-slice'; +import projectFinancesReducer from '../features/projects/finance/project-finance.slice'; import groupByFilterDropdownReducer from '../features/group-by-filter-dropdown/group-by-filter-dropdown-slice'; import homePageApiService from '@/api/home-page/home-page.api.service'; import { projectsApi } from '@/api/projects/projects.v1.api.service'; @@ -158,6 +159,7 @@ export const store = configureStore({ timeReportsOverviewReducer: timeReportsOverviewReducer, financeReducer: financeReducer, projectFinanceRateCard: projectFinanceRateCardReducer, + projectFinances: projectFinancesReducer, }, }); diff --git a/worklenz-frontend/src/components/avatars/avatars.tsx b/worklenz-frontend/src/components/avatars/avatars.tsx index 753c6378..4490769d 100644 --- a/worklenz-frontend/src/components/avatars/avatars.tsx +++ b/worklenz-frontend/src/components/avatars/avatars.tsx @@ -4,19 +4,20 @@ import { InlineMember } from '@/types/teamMembers/inlineMember.types'; interface AvatarsProps { members: InlineMember[]; maxCount?: number; + allowClickThrough?: boolean; } -const renderAvatar = (member: InlineMember, index: number) => ( +const renderAvatar = (member: InlineMember, index: number, allowClickThrough: boolean = false) => ( {member.avatar_url ? ( - e.stopPropagation()}> + e.stopPropagation()}> ) : ( - e.stopPropagation()}> + e.stopPropagation()}> ( ); -const Avatars: React.FC = ({ members, maxCount }) => { +const Avatars: React.FC = ({ members, maxCount, allowClickThrough = false }) => { const visibleMembers = maxCount ? members.slice(0, maxCount) : members; return ( -
e.stopPropagation()}> +
e.stopPropagation()}> - {visibleMembers.map((member, index) => renderAvatar(member, index))} + {visibleMembers.map((member, index) => renderAvatar(member, index, allowClickThrough))}
); diff --git a/worklenz-frontend/src/components/conditional-alert.tsx b/worklenz-frontend/src/components/conditional-alert.tsx new file mode 100644 index 00000000..acddfe38 --- /dev/null +++ b/worklenz-frontend/src/components/conditional-alert.tsx @@ -0,0 +1,61 @@ +import { Alert } from 'antd'; +import { useState, useEffect } from 'react'; + +interface ConditionalAlertProps { + message?: string; + type?: 'success' | 'info' | 'warning' | 'error'; + showInitially?: boolean; + onClose?: () => void; + condition?: boolean; + className?: string; +} + +const ConditionalAlert = ({ + message = '', + type = 'info', + showInitially = false, + onClose, + condition, + className = '' +}: ConditionalAlertProps) => { + const [visible, setVisible] = useState(showInitially); + + useEffect(() => { + if (condition !== undefined) { + setVisible(condition); + } + }, [condition]); + + const handleClose = () => { + setVisible(false); + onClose?.(); + }; + + const alertStyles = { + position: 'fixed', + top: 0, + left: 0, + right: 0, + zIndex: 1000, + margin: 0, + borderRadius: 0, + } as const; + + if (!visible || !message) { + return null; + } + + return ( + + ); +}; + +export default ConditionalAlert; \ No newline at end of file diff --git a/worklenz-frontend/src/components/license-alert.tsx b/worklenz-frontend/src/components/license-alert.tsx new file mode 100644 index 00000000..7a807049 --- /dev/null +++ b/worklenz-frontend/src/components/license-alert.tsx @@ -0,0 +1,184 @@ +import { Alert, Button, Space } from 'antd'; +import { useState, useEffect } from 'react'; +import { CrownOutlined, ClockCircleOutlined } from '@ant-design/icons'; +import { ILocalSession } from '@/types/auth/local-session.types'; +import { LICENSE_ALERT_KEY } from '@/shared/constants'; +import { format, isSameDay, differenceInDays, addDays, isAfter } from 'date-fns'; +import { useNavigate } from 'react-router-dom'; + +interface LicenseAlertProps { + currentSession: ILocalSession; + onVisibilityChange?: (visible: boolean) => void; +} + +interface AlertConfig { + type: 'success' | 'info' | 'warning' | 'error'; + message: React.ReactNode; + description: string; + icon: React.ReactNode; + licenseType: 'trial' | 'expired' | 'expiring'; + daysRemaining: number; +} + +const LicenseAlert = ({ currentSession, onVisibilityChange }: LicenseAlertProps) => { + const navigate = useNavigate(); + const [visible, setVisible] = useState(false); + const [alertConfig, setAlertConfig] = useState(null); + + const handleClose = () => { + setVisible(false); + setLastAlertDate(new Date()); + }; + + const getLastAlertDate = () => { + const lastAlertDate = localStorage.getItem(LICENSE_ALERT_KEY); + return lastAlertDate ? new Date(lastAlertDate) : null; + }; + + const setLastAlertDate = (date: Date) => { + localStorage.setItem(LICENSE_ALERT_KEY, format(date, 'yyyy-MM-dd')); + }; + + const handleUpgrade = () => { + navigate('/worklenz/admin-center/billing'); + }; + + const handleExtend = () => { + navigate('/worklenz/admin-center/billing'); + }; + + const getVisibleAndConfig = (): { visible: boolean; config: AlertConfig | null } => { + const lastAlertDate = getLastAlertDate(); + + // Check if alert was already shown today + if (lastAlertDate && isSameDay(lastAlertDate, new Date())) { + return { visible: false, config: null }; + } + + if (!currentSession.valid_till_date) { + return { visible: false, config: null }; + } + + let validTillDate = new Date(currentSession.valid_till_date); + const today = new Date(); + + // If validTillDate is after today, add 1 day (matching Angular logic) + if (isAfter(validTillDate, today)) { + validTillDate = addDays(validTillDate, 1); + } + + // Calculate the difference in days between the two dates + const daysDifference = differenceInDays(validTillDate, today); + + // Don't show if no valid_till_date or difference is >= 7 days + if (daysDifference >= 7) { + return { visible: false, config: null }; + } + + const absDaysDifference = Math.abs(daysDifference); + const dayText = `${absDaysDifference} day${absDaysDifference === 1 ? '' : 's'}`; + + let string1 = ''; + let string2 = dayText; + let licenseType: 'trial' | 'expired' | 'expiring' = 'expiring'; + let alertType: 'success' | 'info' | 'warning' | 'error' = 'warning'; + + if (currentSession.subscription_status === 'trialing') { + licenseType = 'trial'; + if (daysDifference < 0) { + string1 = 'Your Worklenz trial expired'; + string2 = string2 + ' ago'; + alertType = 'error'; + licenseType = 'expired'; + } else if (daysDifference !== 0 && daysDifference < 7) { + string1 = 'Your Worklenz trial expires in'; + } else if (daysDifference === 0 && daysDifference < 7) { + string1 = 'Your Worklenz trial expires'; + string2 = 'today'; + } + } else if (currentSession.subscription_status === 'active') { + if (daysDifference < 0) { + string1 = 'Your Worklenz subscription expired'; + string2 = string2 + ' ago'; + alertType = 'error'; + licenseType = 'expired'; + } else if (daysDifference !== 0 && daysDifference < 7) { + string1 = 'Your Worklenz subscription expires in'; + } else if (daysDifference === 0 && daysDifference < 7) { + string1 = 'Your Worklenz subscription expires'; + string2 = 'today'; + } + } else { + return { visible: false, config: null }; + } + + const config: AlertConfig = { + type: alertType, + message: ( + <> + Action required! {string1} {string2} + + ), + description: '', + icon: licenseType === 'expired' || licenseType === 'trial' ? : , + licenseType, + daysRemaining: absDaysDifference + }; + + return { visible: true, config }; + }; + + useEffect(() => { + const { visible: shouldShow, config } = getVisibleAndConfig(); + setVisible(shouldShow); + setAlertConfig(config); + + // Notify parent about visibility change + if (onVisibilityChange) { + onVisibilityChange(shouldShow); + } + }, [currentSession, onVisibilityChange]); + + const alertStyles = { + margin: 0, + borderRadius: 0, + } as const; + + const actionButtons = alertConfig && ( + + {/* Show button only if user is owner or admin */} + {(currentSession.owner || currentSession.is_admin) && ( + + )} + + ); + + if (!visible || !alertConfig) { + return null; + } + + return ( +
+ +
+ ); +}; + +export default LicenseAlert; \ No newline at end of file diff --git a/worklenz-frontend/src/features/finance/finance-drawer/finance-drawer.tsx b/worklenz-frontend/src/features/finance/finance-drawer/finance-drawer.tsx index 851f6d76..395cc99c 100644 --- a/worklenz-frontend/src/features/finance/finance-drawer/finance-drawer.tsx +++ b/worklenz-frontend/src/features/finance/finance-drawer/finance-drawer.tsx @@ -1,17 +1,40 @@ import React, { useEffect, useState } from 'react'; -import { Drawer, Typography } from 'antd'; +import { Drawer, Typography, Spin } from 'antd'; import { useTranslation } from 'react-i18next'; import { useAppSelector } from '../../../hooks/useAppSelector'; import { useAppDispatch } from '../../../hooks/useAppDispatch'; import { themeWiseColor } from '../../../utils/themeWiseColor'; -import { toggleFinanceDrawer } from '../finance-slice'; +import { closeFinanceDrawer } from '../finance-slice'; +import { projectFinanceApiService } from '../../../api/project-finance-ratecard/project-finance.api.service'; +import { ITaskBreakdownResponse } from '../../../types/project/project-finance.types'; -const FinanceDrawer = ({ task }: { task: any }) => { - const [selectedTask, setSelectedTask] = useState(task); +const FinanceDrawer = () => { + const [taskBreakdown, setTaskBreakdown] = useState(null); + const [loading, setLoading] = useState(false); + + // Get task and drawer state from Redux store + const selectedTask = useAppSelector((state) => state.financeReducer.selectedTask); + const isDrawerOpen = useAppSelector((state) => state.financeReducer.isFinanceDrawerOpen); useEffect(() => { - setSelectedTask(task); - }, [task]); + if (selectedTask?.id && isDrawerOpen) { + fetchTaskBreakdown(selectedTask.id); + } else { + setTaskBreakdown(null); + } + }, [selectedTask, isDrawerOpen]); + + const fetchTaskBreakdown = async (taskId: string) => { + try { + setLoading(true); + const response = await projectFinanceApiService.getTaskBreakdown(taskId); + setTaskBreakdown(response.body); + } catch (error) { + console.error('Error fetching task breakdown:', error); + } finally { + setLoading(false); + } + }; // localization const { t } = useTranslation('project-view-finance'); @@ -19,9 +42,6 @@ const FinanceDrawer = ({ task }: { task: any }) => { // get theme data from theme reducer const themeMode = useAppSelector((state) => state.themeReducer.mode); - const isDrawerOpen = useAppSelector( - (state) => state.financeReducer.isFinanceDrawerOpen - ); const dispatch = useAppDispatch(); const currency = useAppSelector( (state) => state.financeReducer.currency @@ -29,41 +49,15 @@ const FinanceDrawer = ({ task }: { task: any }) => { // function handle drawer close const handleClose = () => { - setSelectedTask(null); - dispatch(toggleFinanceDrawer()); + setTaskBreakdown(null); + dispatch(closeFinanceDrawer()); }; - // group members by job roles and calculate labor hours and costs - const groupedMembers = - selectedTask?.members?.reduce((acc: any, member: any) => { - const memberHours = selectedTask.hours / selectedTask.members.length; - const memberCost = memberHours * member.hourlyRate; - - if (!acc[member.jobRole]) { - acc[member.jobRole] = { - jobRole: member.jobRole, - laborHours: 0, - cost: 0, - members: [], - }; - } - - acc[member.jobRole].laborHours += memberHours; - acc[member.jobRole].cost += memberCost; - acc[member.jobRole].members.push({ - name: member.name, - laborHours: memberHours, - cost: memberCost, - }); - - return acc; - }, {}) || {}; - return ( - {selectedTask?.task || t('noTaskSelected')} + {taskBreakdown?.task?.name || selectedTask?.name || t('noTaskSelected')} } open={isDrawerOpen} @@ -72,98 +66,77 @@ const FinanceDrawer = ({ task }: { task: any }) => { width={480} >
- - - - + + + + + ))} + + ))} + +
+ + + ) : ( + + + - - - - - -
- - - {Object.values(groupedMembers).map((group: any) => ( - - {/* Group Header */} - + - - - - {/* Member Rows */} - {group.members.map((member: any, index: number) => ( + {t('labourHoursColumn')} + + + + + + + {taskBreakdown?.grouped_members?.map((group: any) => ( + + {/* Group Header */} + - - ))} - - ))} - -
- {t('labourHoursColumn')} - - {t('costColumn')} ({currency}) -
- {group.jobRole} - {group.laborHours} - - {group.cost} -
+ {t('costColumn')} ({currency}) +
{group.jobRole} - {member.name} + {group.estimated_hours?.toFixed(2) || '0.00'} { padding: 8, }} > - {member.laborHours} - - {member.cost} + {group.estimated_cost?.toFixed(2) || '0.00'}
+ {/* Member Rows */} + {group.members?.map((member: any, index: number) => ( +
+ {member.name} + + {member.estimated_hours?.toFixed(2) || '0.00'} + + {member.estimated_cost?.toFixed(2) || '0.00'} +
+ )}
); diff --git a/worklenz-frontend/src/features/finance/finance-slice.ts b/worklenz-frontend/src/features/finance/finance-slice.ts index b7bbaddb..775f16bb 100644 --- a/worklenz-frontend/src/features/finance/finance-slice.ts +++ b/worklenz-frontend/src/features/finance/finance-slice.ts @@ -12,6 +12,7 @@ type financeState = { isFinanceDrawerloading?: boolean; drawerRatecard?: RatecardType | null; ratecardsList?: RatecardType[] | null; + selectedTask?: any | null; }; const initialState: financeState = { @@ -23,6 +24,7 @@ const initialState: financeState = { isFinanceDrawerloading: false, drawerRatecard: null, ratecardsList: null, + selectedTask: null, }; interface FetchRateCardsParams { index: number; @@ -128,6 +130,17 @@ const financeSlice = createSlice({ toggleFinanceDrawer: (state) => { state.isFinanceDrawerOpen = !state.isFinanceDrawerOpen; }, + openFinanceDrawer: (state, action: PayloadAction) => { + state.isFinanceDrawerOpen = true; + state.selectedTask = action.payload; + }, + closeFinanceDrawer: (state) => { + state.isFinanceDrawerOpen = false; + state.selectedTask = null; + }, + setSelectedTask: (state, action: PayloadAction) => { + state.selectedTask = action.payload; + }, toggleImportRatecardsDrawer: (state) => { state.isImportRatecardsDrawerOpen = !state.isImportRatecardsDrawerOpen; }, @@ -176,6 +189,9 @@ const financeSlice = createSlice({ export const { toggleRatecardDrawer, toggleFinanceDrawer, + openFinanceDrawer, + closeFinanceDrawer, + setSelectedTask, toggleImportRatecardsDrawer, changeCurrency, ratecardDrawerLoading, diff --git a/worklenz-frontend/src/features/navbar/navbar.tsx b/worklenz-frontend/src/features/navbar/navbar.tsx index 295a8a17..03f7f150 100644 --- a/worklenz-frontend/src/features/navbar/navbar.tsx +++ b/worklenz-frontend/src/features/navbar/navbar.tsx @@ -90,6 +90,7 @@ const Navbar = () => { }, [location]); return ( + { justifyContent: 'space-between', }} > - {daysUntilExpiry !== null && ((daysUntilExpiry <= 3 && daysUntilExpiry > 0) || (daysUntilExpiry >= -7 && daysUntilExpiry < 0)) && ( + {/* {daysUntilExpiry !== null && ((daysUntilExpiry <= 3 && daysUntilExpiry > 0) || (daysUntilExpiry >= -7 && daysUntilExpiry < 0)) && ( 0 ? `Your license will expire in ${daysUntilExpiry} days` : `Your license has expired ${Math.abs(daysUntilExpiry)} days ago`} type="warning" showIcon style={{ width: '100%', marginTop: 12 }} /> - )} + )} */} minutes / 60; + +const calculateTaskCosts = (task: IProjectFinanceTask) => { + const hours = minutesToHours(task.estimated_hours || 0); + const timeLoggedHours = minutesToHours(task.total_time_logged || 0); + const fixedCost = task.fixed_cost || 0; + + // Calculate total budget (estimated hours * rate + fixed cost) + const totalBudget = task.estimated_cost + fixedCost; + + // Calculate total actual (time logged * rate + fixed cost) + const totalActual = task.total_actual || 0; + + // Calculate variance (total actual - total budget) + const variance = totalActual - totalBudget; + + return { + hours, + timeLoggedHours, + totalBudget, + totalActual, + variance + }; +}; + +const calculateGroupTotals = (tasks: IProjectFinanceTask[]) => { + return tasks.reduce( + (acc, task) => { + const { hours, timeLoggedHours, totalBudget, totalActual, variance } = calculateTaskCosts(task); + return { + hours: acc.hours + hours, + total_time_logged: acc.total_time_logged + timeLoggedHours, + estimated_cost: acc.estimated_cost + (task.estimated_cost || 0), + total_budget: acc.total_budget + totalBudget, + total_actual: acc.total_actual + totalActual, + variance: acc.variance + variance + }; + }, + { + hours: 0, + total_time_logged: 0, + estimated_cost: 0, + total_budget: 0, + total_actual: 0, + variance: 0 + } + ); +}; + +const initialState: ProjectFinanceState = { + activeTab: 'finance', + activeGroup: 'status', + loading: false, + taskGroups: [], + projectRateCards: [], +}; + +export const fetchProjectFinances = createAsyncThunk( + 'projectFinances/fetchProjectFinances', + async ({ projectId, groupBy }: { projectId: string; groupBy: GroupTypes }) => { + const response = await projectFinanceApiService.getProjectTasks(projectId, groupBy); + return response.body; + } +); + +export const updateTaskFixedCostAsync = createAsyncThunk( + 'projectFinances/updateTaskFixedCostAsync', + async ({ taskId, groupId, fixedCost }: { taskId: string; groupId: string; fixedCost: number }) => { + await projectFinanceApiService.updateTaskFixedCost(taskId, fixedCost); + return { taskId, groupId, fixedCost }; + } +); + +export const projectFinancesSlice = createSlice({ + name: 'projectFinances', + initialState, + reducers: { + setActiveTab: (state, action: PayloadAction) => { + state.activeTab = action.payload; + }, + setActiveGroup: (state, action: PayloadAction) => { + state.activeGroup = action.payload; + }, + updateTaskFixedCost: (state, action: PayloadAction<{ taskId: string; groupId: string; fixedCost: number }>) => { + const { taskId, groupId, fixedCost } = action.payload; + const group = state.taskGroups.find(g => g.group_id === groupId); + if (group) { + const task = group.tasks.find(t => t.id === taskId); + if (task) { + task.fixed_cost = fixedCost; + // Recalculate task costs after updating fixed cost + const { totalBudget, totalActual, variance } = calculateTaskCosts(task); + task.total_budget = totalBudget; + task.total_actual = totalActual; + task.variance = variance; + } + } + }, + updateTaskEstimatedCost: (state, action: PayloadAction<{ taskId: string; groupId: string; estimatedCost: number }>) => { + const { taskId, groupId, estimatedCost } = action.payload; + const group = state.taskGroups.find(g => g.group_id === groupId); + if (group) { + const task = group.tasks.find(t => t.id === taskId); + if (task) { + task.estimated_cost = estimatedCost; + // Recalculate task costs after updating estimated cost + const { totalBudget, totalActual, variance } = calculateTaskCosts(task); + task.total_budget = totalBudget; + task.total_actual = totalActual; + task.variance = variance; + } + } + }, + updateTaskTimeLogged: (state, action: PayloadAction<{ taskId: string; groupId: string; timeLogged: number }>) => { + const { taskId, groupId, timeLogged } = action.payload; + const group = state.taskGroups.find(g => g.group_id === groupId); + if (group) { + const task = group.tasks.find(t => t.id === taskId); + if (task) { + task.total_time_logged = timeLogged; + // Recalculate task costs after updating time logged + const { totalBudget, totalActual, variance } = calculateTaskCosts(task); + task.total_budget = totalBudget; + task.total_actual = totalActual; + task.variance = variance; + } + } + } + }, + extraReducers: (builder) => { + builder + .addCase(fetchProjectFinances.pending, (state) => { + state.loading = true; + }) + .addCase(fetchProjectFinances.fulfilled, (state, action) => { + state.loading = false; + state.taskGroups = action.payload.groups; + state.projectRateCards = action.payload.project_rate_cards; + }) + .addCase(fetchProjectFinances.rejected, (state) => { + state.loading = false; + }) + .addCase(updateTaskFixedCostAsync.fulfilled, (state, action) => { + const { taskId, groupId, fixedCost } = action.payload; + const group = state.taskGroups.find(g => g.group_id === groupId); + if (group) { + const task = group.tasks.find(t => t.id === taskId); + if (task) { + task.fixed_cost = fixedCost; + // Recalculate task costs after updating fixed cost + const { totalBudget, totalActual, variance } = calculateTaskCosts(task); + task.total_budget = totalBudget; + task.total_actual = totalActual; + task.variance = variance; + } + } + }); + }, +}); + +export const { + setActiveTab, + setActiveGroup, + updateTaskFixedCost, + updateTaskEstimatedCost, + updateTaskTimeLogged +} = projectFinancesSlice.actions; + +export default projectFinancesSlice.reducer; diff --git a/worklenz-frontend/src/layouts/MainLayout.tsx b/worklenz-frontend/src/layouts/MainLayout.tsx index 83a4f4c4..5dbc5c3e 100644 --- a/worklenz-frontend/src/layouts/MainLayout.tsx +++ b/worklenz-frontend/src/layouts/MainLayout.tsx @@ -5,15 +5,22 @@ import { useAppSelector } from '../hooks/useAppSelector'; import { useMediaQuery } from 'react-responsive'; import { colors } from '../styles/colors'; import { verifyAuthentication } from '@/features/auth/authSlice'; -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { useAppDispatch } from '@/hooks/useAppDispatch'; import HubSpot from '@/components/HubSpot'; +import LicenseAlert from '@/components/license-alert'; +import { useAuthService } from '@/hooks/useAuth'; +import { ILocalSession } from '@/types/auth/local-session.types'; const MainLayout = () => { const themeMode = useAppSelector(state => state.themeReducer.mode); const isDesktop = useMediaQuery({ query: '(min-width: 1024px)' }); const dispatch = useAppDispatch(); const navigate = useNavigate(); + const currentSession = useAuthService().getCurrentSession(); + + // State for alert visibility + const [showAlert, setShowAlert] = useState(false); const verifyAuthStatus = async () => { const session = await dispatch(verifyAuthentication()).unwrap(); @@ -26,6 +33,20 @@ const MainLayout = () => { void verifyAuthStatus(); }, [dispatch, navigate]); + const handleUpgrade = () => { + // Handle upgrade logic here + console.log('Upgrade clicked'); + // You can navigate to upgrade page or open a modal + }; + + const handleExtend = () => { + // Handle license extension logic here + console.log('Extend license clicked'); + // You can navigate to renewal page or open a modal + }; + + const alertHeight = showAlert ? 64 : 0; // Fixed height for license alert + const headerStyles = { zIndex: 999, position: 'fixed', @@ -34,11 +55,13 @@ const MainLayout = () => { alignItems: 'center', padding: 0, borderBottom: themeMode === 'dark' ? '1px solid #303030' : 'none', + top: alertHeight, // Push navbar down when alert is shown } as const; const contentStyles = { paddingInline: isDesktop ? 64 : 24, overflowX: 'hidden', + marginTop: alertHeight + 64, // Adjust top margin based on alert height + navbar height } as const; return ( diff --git a/worklenz-frontend/src/lib/project/finance-table-wrapper.tsx b/worklenz-frontend/src/lib/project/finance-table-wrapper.tsx new file mode 100644 index 00000000..36dc4053 --- /dev/null +++ b/worklenz-frontend/src/lib/project/finance-table-wrapper.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import { Table } from 'antd'; +import { useTranslation } from 'react-i18next'; +import { financeTableColumns } from './project-view-finance-table-columns'; + +interface IFinanceTableData { + id: string; + name: string; + estimated_hours: number; + estimated_cost: number; + fixed_cost: number; + total_budgeted_cost: number; + total_actual_cost: number; + variance: number; + total_time_logged: number; + assignees: Array<{ + team_member_id: string; + project_member_id: string; + name: string; + avatar_url: string; + }>; +} + +interface FinanceTableWrapperProps { + data: IFinanceTableData[]; + loading?: boolean; +} + +const FinanceTableWrapper: React.FC = ({ data, loading }) => { + const { t } = useTranslation(); + + const columns = financeTableColumns.map(col => ({ + ...col, + title: t(`projectViewFinance.${col.name}`), + dataIndex: col.key, + key: col.key, + width: col.width, + render: col.render || ((value: any) => { + if (col.type === 'hours') { + return value ? value.toFixed(2) : '0.00'; + } + if (col.type === 'currency') { + return value ? `$${value.toFixed(2)}` : '$0.00'; + } + return value; + }) + })); + + return ( + + ); +}; + +export default FinanceTableWrapper; \ No newline at end of file diff --git a/worklenz-frontend/src/lib/project/project-view-finance-table-columns.ts b/worklenz-frontend/src/lib/project/project-view-finance-table-columns.ts index e08bd430..09fee17e 100644 --- a/worklenz-frontend/src/lib/project/project-view-finance-table-columns.ts +++ b/worklenz-frontend/src/lib/project/project-view-finance-table-columns.ts @@ -1,57 +1,83 @@ +export enum FinanceTableColumnKeys { + TASK = 'task', + MEMBERS = 'members', + HOURS = 'hours', + TOTAL_TIME_LOGGED = 'total_time_logged', + ESTIMATED_COST = 'estimated_cost', + COST = 'cost', + FIXED_COST = 'fixedCost', + TOTAL_BUDGET = 'totalBudget', + TOTAL_ACTUAL = 'totalActual', + VARIANCE = 'variance', +} + type FinanceTableColumnsType = { - key: string; + key: FinanceTableColumnKeys; name: string; width: number; type: 'string' | 'hours' | 'currency'; + render?: (value: any) => React.ReactNode; }; // finance table columns export const financeTableColumns: FinanceTableColumnsType[] = [ { - key: 'task', - name: 'task', + key: FinanceTableColumnKeys.TASK, + name: 'taskColumn', width: 240, type: 'string', }, { - key: 'members', - name: 'members', + key: FinanceTableColumnKeys.MEMBERS, + name: 'membersColumn', width: 160, type: 'string', }, { - key: 'hours', - name: 'hours', - width: 80, + key: FinanceTableColumnKeys.HOURS, + name: 'hoursColumn', + width: 100, type: 'hours', }, { - key: 'cost', - name: 'cost', + key: FinanceTableColumnKeys.TOTAL_TIME_LOGGED, + name: 'totalTimeLoggedColumn', + width: 120, + type: 'hours', + }, + { + key: FinanceTableColumnKeys.ESTIMATED_COST, + name: 'estimatedCostColumn', width: 120, type: 'currency', }, { - key: 'fixedCost', - name: 'fixedCost', + key: FinanceTableColumnKeys.COST, + name: 'costColumn', width: 120, type: 'currency', }, { - key: 'totalBudget', - name: 'totalBudgetedCost', + key: FinanceTableColumnKeys.FIXED_COST, + name: 'fixedCostColumn', width: 120, type: 'currency', }, { - key: 'totalActual', - name: 'totalActualCost', + key: FinanceTableColumnKeys.TOTAL_BUDGET, + name: 'totalBudgetedCostColumn', width: 120, type: 'currency', }, { - key: 'variance', - name: 'variance', + key: FinanceTableColumnKeys.TOTAL_ACTUAL, + name: 'totalActualCostColumn', + width: 120, + type: 'currency', + }, + { + key: FinanceTableColumnKeys.VARIANCE, + name: 'varianceColumn', width: 120, type: 'currency', }, diff --git a/worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-tab.tsx b/worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-tab.tsx index 3da81d48..95af2101 100644 --- a/worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-tab.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-tab.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import FinanceTableWrapper from './finance-table/finance-table-wrapper'; import { IProjectFinanceGroup } from '@/types/project/project-finance.types'; @@ -10,26 +9,28 @@ interface FinanceTabProps { const FinanceTab = ({ groupType, - taskGroups, + taskGroups = [], loading }: FinanceTabProps) => { // Transform taskGroups into the format expected by FinanceTableWrapper - const activeTablesList = taskGroups.map(group => ({ - id: group.group_id, - name: group.group_name, + const activeTablesList = (taskGroups || []).map(group => ({ + group_id: group.group_id, + group_name: group.group_name, color_code: group.color_code, color_code_dark: group.color_code_dark, - tasks: group.tasks.map(task => ({ - taskId: task.id, - task: task.name, + tasks: (group.tasks || []).map(task => ({ + id: task.id, + name: task.name, hours: task.estimated_hours || 0, - cost: 0, // TODO: Calculate based on rate and hours - fixedCost: 0, // TODO: Add fixed cost field - totalBudget: 0, // TODO: Calculate total budget - totalActual: task.actual_hours || 0, - variance: 0, // TODO: Calculate variance + cost: task.estimated_cost || 0, + fixedCost: task.fixed_cost || 0, + totalBudget: task.total_budget || 0, + totalActual: task.total_actual || 0, + variance: task.variance || 0, members: task.members || [], - isbBillable: task.billable + isbBillable: task.billable, + total_time_logged: task.total_time_logged || 0, + estimated_cost: task.estimated_cost || 0 })) })); diff --git a/worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-table-wrapper.tsx b/worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-table-wrapper.tsx index 2360efc7..ba663bce 100644 --- a/worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-table-wrapper.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-table-wrapper.tsx @@ -1,9 +1,8 @@ import React from "react"; -import { Card, Col, Row, Spin } from "antd"; -import { useThemeContext } from "../../../../../context/theme-context"; -import { FinanceTable } from "./finance-table"; -import { IFinanceTable } from "./finance-table.interface"; +import { Card, Col, Row } from "antd"; + import { IProjectFinanceGroup } from "../../../../../types/project/project-finance.types"; +import FinanceTable from "./finance-table/finance-table"; interface Props { activeTablesList: IProjectFinanceGroup[]; @@ -32,7 +31,7 @@ export const FinanceTableWrapper: React.FC = ({ activeTablesList, loading

{table.group_name}

diff --git a/worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-table/finance-table-wrapper.tsx b/worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-table/finance-table-wrapper.tsx index aafb6224..dff26f54 100644 --- a/worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-table/finance-table-wrapper.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-table/finance-table-wrapper.tsx @@ -1,49 +1,34 @@ -import React, { useEffect, useState } from 'react'; -import { Checkbox, Flex, Typography } from 'antd'; -import { themeWiseColor } from '../../../../../../utils/themeWiseColor'; -import { useAppSelector } from '../../../../../../hooks/useAppSelector'; +import React, { useEffect, useState, useMemo } from 'react'; +import { Flex, InputNumber, Tooltip, Typography, Empty } from 'antd'; +import { themeWiseColor } from '@/utils/themeWiseColor'; +import { useAppSelector } from '@/hooks/useAppSelector'; import { useTranslation } from 'react-i18next'; -import { useAppDispatch } from '../../../../../../hooks/useAppDispatch'; -import { toggleFinanceDrawer } from '@/features/finance/finance-slice'; -import { financeTableColumns } from '@/lib/project/project-view-finance-table-columns'; +import { useAppDispatch } from '@/hooks/useAppDispatch'; +import { openFinanceDrawer } from '@/features/finance/finance-slice'; +import { financeTableColumns, FinanceTableColumnKeys } from '@/lib/project/project-view-finance-table-columns'; import FinanceTable from './finance-table'; import FinanceDrawer from '@/features/finance/finance-drawer/finance-drawer'; +import { convertToHoursMinutes, formatHoursToReadable } from '@/utils/format-hours-to-readable'; +import { IProjectFinanceGroup } from '@/types/project/project-finance.types'; +import { updateTaskFixedCostAsync } from '@/features/projects/finance/project-finance.slice'; interface FinanceTableWrapperProps { - activeTablesList: { - id: string; - name: string; - color_code: string; - color_code_dark: string; - tasks: { - taskId: string; - task: string; - hours: number; - cost: number; - fixedCost: number; - totalBudget: number; - totalActual: number; - variance: number; - members: any[]; - isbBillable: boolean; - }[]; - }[]; + activeTablesList: IProjectFinanceGroup[]; loading: boolean; } -const FinanceTableWrapper: React.FC = ({ - activeTablesList, - loading -}) => { +const FinanceTableWrapper: React.FC = ({ activeTablesList, loading }) => { const [isScrolling, setIsScrolling] = useState(false); - const [selectedTask, setSelectedTask] = useState(null); + const [editingFixedCost, setEditingFixedCost] = useState<{ taskId: string; groupId: string } | null>(null); const { t } = useTranslation('project-view-finance'); const dispatch = useAppDispatch(); + // Get selected task from Redux store + const selectedTask = useAppSelector(state => state.financeReducer.selectedTask); + const onTaskClick = (task: any) => { - setSelectedTask(task); - dispatch(toggleFinanceDrawer()); + dispatch(openFinanceDrawer(task)); }; useEffect(() => { @@ -60,74 +45,89 @@ const FinanceTableWrapper: React.FC = ({ }; }, []); - const themeMode = useAppSelector((state) => state.themeReducer.mode); - const { currency } = useAppSelector((state) => state.financeReducer); + // Handle click outside to close editing + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (editingFixedCost && !(event.target as Element)?.closest('.fixed-cost-input')) { + setEditingFixedCost(null); + } + }; - const totals = activeTablesList.reduce( - ( - acc: { - hours: number; - cost: number; - fixedCost: number; - totalBudget: number; - totalActual: number; - variance: number; + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [editingFixedCost]); + + const themeMode = useAppSelector(state => state.themeReducer.mode); + const { currency } = useAppSelector(state => state.financeReducer); + const taskGroups = useAppSelector(state => state.projectFinances.taskGroups); + + // Use Redux store data for totals calculation to ensure reactivity + const totals = useMemo(() => { + return taskGroups.reduce( + ( + acc: { + hours: number; + cost: number; + fixedCost: number; + totalBudget: number; + totalActual: number; + variance: number; + total_time_logged: number; + estimated_cost: number; + }, + table: IProjectFinanceGroup + ) => { + table.tasks.forEach((task) => { + acc.hours += (task.estimated_hours / 60) || 0; + acc.cost += task.estimated_cost || 0; + acc.fixedCost += task.fixed_cost || 0; + acc.totalBudget += task.total_budget || 0; + acc.totalActual += task.total_actual || 0; + acc.variance += task.variance || 0; + acc.total_time_logged += (task.total_time_logged / 60) || 0; + acc.estimated_cost += task.estimated_cost || 0; + }); + return acc; }, - table: { tasks: any[] } - ) => { - table.tasks.forEach((task: any) => { - acc.hours += task.hours || 0; - acc.cost += task.cost || 0; - acc.fixedCost += task.fixedCost || 0; - acc.totalBudget += task.totalBudget || 0; - acc.totalActual += task.totalActual || 0; - acc.variance += task.variance || 0; - }); - return acc; - }, - { - hours: 0, - cost: 0, - fixedCost: 0, - totalBudget: 0, - totalActual: 0, - variance: 0, - } - ); + { + hours: 0, + cost: 0, + fixedCost: 0, + totalBudget: 0, + totalActual: 0, + variance: 0, + total_time_logged: 0, + estimated_cost: 0, + } + ); + }, [taskGroups]); - const renderFinancialTableHeaderContent = (columnKey: any) => { + const handleFixedCostChange = (value: number | null, taskId: string, groupId: string) => { + dispatch(updateTaskFixedCostAsync({ taskId, groupId, fixedCost: value || 0 })); + setEditingFixedCost(null); + }; + + const renderFinancialTableHeaderContent = (columnKey: FinanceTableColumnKeys) => { switch (columnKey) { - case 'hours': + case FinanceTableColumnKeys.HOURS: return ( - {totals.hours} + + {formatHoursToReadable(totals.hours).toFixed(2)} + ); - case 'cost': - return ( - - {totals.cost} - - ); - case 'fixedCost': - return ( - - {totals.fixedCost} - - ); - case 'totalBudget': - return ( - - {totals.totalBudget} - - ); - case 'totalActual': - return ( - - {totals.totalActual} - - ); - case 'variance': + 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 ( = ({ fontSize: 18, }} > - {totals.variance} + {`${totals.variance?.toFixed(2)}`} + + ); + case FinanceTableColumnKeys.TOTAL_TIME_LOGGED: + return ( + + {totals.total_time_logged?.toFixed(2)} + + ); + case FinanceTableColumnKeys.ESTIMATED_COST: + return ( + + {`${totals.estimated_cost?.toFixed(2)}`} ); default: @@ -143,116 +155,103 @@ const FinanceTableWrapper: React.FC = ({ } }; - const customColumnHeaderStyles = (key: string) => - `px-2 text-left ${key === 'selector' && 'sticky left-0 z-10'} ${key === 'task' && 'sticky left-[48px] z-10'} ${key === 'members' && `sticky left-[288px] 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 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: string) => - `px-2 text-left ${key === 'totalRow' && `sticky left-0 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' : ''}`}`; + 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 ( <> - +
- - {financeTableColumns.map((col) => ( + {financeTableColumns.map(col => ( ))} - - - {financeTableColumns.map( - (col) => - (col.type === 'hours' || col.type === 'currency') && ( - - ) - )} - + {financeTableColumns.map((col, index) => ( + + ))} + + )} - {activeTablesList.map((table, index) => ( - - ))} + {hasAnyTasks ? ( + activeTablesList.map((table) => ( + + )) + ) : ( + + + + )}
- - - {t(`${col.name}Column`)}{' '} - {col.type === 'currency' && `(${currency.toUpperCase()})`} + {t(`${col.name}`)} {col.type === 'currency' && `(${currency.toUpperCase()})`}
- - {t('totalText')} - - - {renderFinancialTableHeaderContent(col.key)} -
+ {col.key === FinanceTableColumnKeys.TASK ? ( + {t('totalText')} + ) : col.key === FinanceTableColumnKeys.MEMBERS ? null : ( + (col.type === 'hours' || col.type === 'currency') && renderFinancialTableHeaderContent(col.key) + )} +
+ + {t('noTasksFound')} + + } + image={Empty.PRESENTED_IMAGE_SIMPLE} + /> +
- {selectedTask && } + ); }; diff --git a/worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-table/finance-table.css b/worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-table/finance-table.css new file mode 100644 index 00000000..09389058 --- /dev/null +++ b/worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-table/finance-table.css @@ -0,0 +1 @@ +/* Finance Table Styles */ \ No newline at end of file diff --git a/worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-table/finance-table.tsx b/worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-table/finance-table.tsx index 15aa423d..b882c55a 100644 --- a/worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-table/finance-table.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-table/finance-table.tsx @@ -1,283 +1,307 @@ -import { Avatar, Checkbox, Flex, Input, Tooltip, Typography } from 'antd'; -import React, { useEffect, useMemo, useState } from 'react'; -import CustomAvatar from '../../../../../../components/CustomAvatar'; -import { useAppSelector } from '../../../../../../hooks/useAppSelector'; +import { Checkbox, Flex, Input, InputNumber, Skeleton, Tooltip, Typography } from 'antd'; +import { useEffect, useMemo, useState } 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 } from '@/lib/project/project-view-finance-table-columns'; +import { themeWiseColor } from '@/utils/themeWiseColor'; +import { colors } from '@/styles/colors'; +import { financeTableColumns, FinanceTableColumnKeys } from '@/lib/project/project-view-finance-table-columns'; import Avatars from '@/components/avatars/avatars'; +import { IProjectFinanceGroup, IProjectFinanceTask } from '@/types/project/project-finance.types'; +import { updateTaskFixedCostAsync, updateTaskFixedCost } from '@/features/projects/finance/project-finance.slice'; +import { useAppDispatch } from '@/hooks/useAppDispatch'; +import './finance-table.css'; type FinanceTableProps = { - table: any; + table: IProjectFinanceGroup; + loading: boolean; isScrolling: boolean; onTaskClick: (task: any) => void; }; const FinanceTable = ({ table, + loading, isScrolling, onTaskClick, }: FinanceTableProps) => { const [isCollapse, setIsCollapse] = useState(false); - const [selectedTask, setSelectedTask] = useState(null); + const [selectedTask, setSelectedTask] = useState(null); + const [tasks, setTasks] = useState(table.tasks); + const dispatch = useAppDispatch(); + + // Get the latest task groups from Redux store + const taskGroups = useAppSelector((state) => state.projectFinances.taskGroups); + + // 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')) { + setSelectedTask(null); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [selectedTask]); // get theme data from theme reducer const themeMode = useAppSelector((state) => state.themeReducer.mode); - // totals of the current table - const totals = useMemo( - () => ({ - hours: (table?.tasks || []).reduce( - (sum: any, task: { hours: any }) => sum + task.hours, - 0 - ), - cost: (table?.tasks || []).reduce( - (sum: any, task: { cost: any }) => sum + task.cost, - 0 - ), - fixedCost: (table?.tasks || []).reduce( - (sum: any, task: { fixedCost: any }) => sum + task.fixedCost, - 0 - ), - totalBudget: (table?.tasks || []).reduce( - (sum: any, task: { totalBudget: any }) => sum + task.totalBudget, - 0 - ), - totalActual: (table?.tasks || []).reduce( - (sum: any, task: { totalActual: any }) => sum + task.totalActual, - 0 - ), - variance: (table?.tasks || []).reduce( - (sum: any, task: { variance: any }) => sum + task.variance, - 0 - ), - }), - [table] - ); + const formatNumber = (value: number | undefined | null) => { + if (value === undefined || value === null) return '0.00'; + return value.toFixed(2); + }; - useEffect(() => { - console.log('Selected Task:', selectedTask); - }, [selectedTask]); + // 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 renderFinancialTableHeaderContent = (columnKey: any) => { + 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 'hours': - return ( - - {totals.hours} - - ); - case 'cost': - return ( - - {totals.cost} - - ); - case 'fixedCost': - return ( - - {totals.fixedCost} - - ); - case 'totalBudget': - return ( - - {totals.totalBudget} - - ); - case 'totalActual': - return ( - - {totals.totalActual} - - ); - case 'variance': - return ( - - {totals.variance} - - ); + case FinanceTableColumnKeys.HOURS: + return {formatNumber(totals.hours)}; + case FinanceTableColumnKeys.TOTAL_TIME_LOGGED: + return {formatNumber(totals.total_time_logged)}; + case FinanceTableColumnKeys.ESTIMATED_COST: + return {formatNumber(totals.estimated_cost)}; + case FinanceTableColumnKeys.FIXED_COST: + return {formatNumber(totals.fixed_cost)}; + case FinanceTableColumnKeys.TOTAL_BUDGET: + return {formatNumber(totals.total_budget)}; + case FinanceTableColumnKeys.TOTAL_ACTUAL: + return {formatNumber(totals.total_actual)}; + case FinanceTableColumnKeys.VARIANCE: + return {formatNumber(totals.variance)}; default: return null; } }; - const renderFinancialTableColumnContent = (columnKey: any, task: any) => { + const handleFixedCostChange = (value: number | null, taskId: string) => { + const fixedCost = value || 0; + + // Optimistic update for immediate UI feedback + dispatch(updateTaskFixedCost({ taskId, groupId: table.group_id, fixedCost })); + + // Then make the API call to persist the change + dispatch(updateTaskFixedCostAsync({ taskId, groupId: table.group_id, fixedCost })); + }; + + const renderFinancialTableColumnContent = (columnKey: FinanceTableColumnKeys, task: IProjectFinanceTask) => { switch (columnKey) { - case 'task': + case FinanceTableColumnKeys.TASK: return ( - + - {task.task} + {task.name} - - {task.isbBillable && } + {task.billable && } ); - case 'members': - return ( - task?.assignees && - ); - case 'hours': - return {task.hours}; - case 'cost': - return {task.cost}; - case 'fixedCost': - return ( - { + e.stopPropagation(); + onTaskClick(task); }} - /> - ); - case 'totalBudget': - return ( - - ); - case 'totalActual': - return {task.totalActual}; - case 'variance': - return ( - - {task.variance} + ({ + ...member, + avatar_url: member.avatar_url || undefined + }))} + allowClickThrough={true} + /> +
+ ); + case FinanceTableColumnKeys.HOURS: + return {formatNumber(task.estimated_hours / 60)}; + case FinanceTableColumnKeys.TOTAL_TIME_LOGGED: + return {formatNumber(task.total_time_logged / 60)}; + case FinanceTableColumnKeys.ESTIMATED_COST: + return {formatNumber(task.estimated_cost)}; + case FinanceTableColumnKeys.FIXED_COST: + return selectedTask?.id === task.id ? ( + { + handleFixedCostChange(Number(e.target.value), task.id); + setSelectedTask(null); + }} + onPressEnter={(e) => { + handleFixedCostChange(Number((e.target as HTMLInputElement).value), task.id); + setSelectedTask(null); + }} + autoFocus + style={{ width: '100%', textAlign: 'right' }} + 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); + }} + > + {formatNumber(task.fixed_cost)} ); + case FinanceTableColumnKeys.VARIANCE: + return {formatNumber(task.variance)}; + case FinanceTableColumnKeys.TOTAL_BUDGET: + return {formatNumber(task.total_budget)}; + case FinanceTableColumnKeys.TOTAL_ACTUAL: + return {formatNumber(task.total_actual)}; + case FinanceTableColumnKeys.COST: + return {formatNumber(task.estimated_cost || 0)}; default: return null; } }; - // layout styles for table and the columns - const customColumnHeaderStyles = (key: string) => - `px-2 text-left ${key === 'tableTitle' && `sticky left-0 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 customColumnStyles = (key: string) => - `px-2 text-left ${key === 'selector' && 'sticky left-0 z-10'} ${key === 'task' && 'sticky left-[48px] z-10'} ${key === 'members' && `sticky left-[288px] z-10 ${isScrolling ? 'after:content after:absolute after:top-0 after:-right-1 after:-z-10 after:h-[52px] 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]'}`; + // Calculate totals for the current table + const totals = useMemo(() => { + return tasks.reduce( + (acc, task) => ({ + hours: acc.hours + (task.estimated_hours / 60), + total_time_logged: acc.total_time_logged + (task.total_time_logged / 60), + estimated_cost: acc.estimated_cost + (task.estimated_cost || 0), + fixed_cost: acc.fixed_cost + (task.fixed_cost || 0), + total_budget: acc.total_budget + (task.total_budget || 0), + total_actual: acc.total_actual + (task.total_actual || 0), + variance: acc.variance + (task.variance || 0) + }), + { + hours: 0, + total_time_logged: 0, + estimated_cost: 0, + fixed_cost: 0, + total_budget: 0, + total_actual: 0, + variance: 0 + } + ); + }, [tasks]); return ( - <> - {/* header row */} - - + <> + {/* header row */} + setIsCollapse((prev) => !prev)} + className={`group ${themeMode === 'dark' ? 'dark' : ''}`} > - - {isCollapse ? : } - {table.name} ({table.tasks.length}) - - - - {financeTableColumns.map( - (col) => - col.key !== 'task' && - col.key !== 'members' && ( + {financeTableColumns.map( + (col, index) => ( setIsCollapse((prev) => !prev) : undefined} > - {renderFinancialTableHeaderContent(col.key)} + {col.key === FinanceTableColumnKeys.TASK ? ( + + {isCollapse ? : } + {table.group_name} ({tasks.length}) + + ) : col.key === FinanceTableColumnKeys.MEMBERS ? null : renderFinancialTableHeaderContent(col.key)} ) - )} - - - {/* task rows */} - {table.tasks.map((task: any) => ( - onTaskClick(task)} - > - - - - {financeTableColumns.map((col) => ( - - {renderFinancialTableColumnContent(col.key, task)} - - ))} + )} - ))} - + + {/* task rows */} + {!isCollapse && tasks.map((task, idx) => ( + e.currentTarget.style.background = themeWiseColor('#f0f0f0', '#333', themeMode)} + onMouseLeave={e => e.currentTarget.style.background = idx % 2 === 0 ? themeWiseColor('#fafafa', '#232323', themeMode) : themeWiseColor('#ffffff', '#181818', themeMode)} + > + {financeTableColumns.map((col) => ( + e.stopPropagation() + : undefined + } + > + {renderFinancialTableColumnContent(col.key, task)} + + ))} + + ))} + + ); }; diff --git a/worklenz-frontend/src/pages/projects/projectView/finance/project-view-finance-header/project-view-finance-header.tsx b/worklenz-frontend/src/pages/projects/projectView/finance/project-view-finance-header/project-view-finance-header.tsx index 6c697f50..7c213481 100644 --- a/worklenz-frontend/src/pages/projects/projectView/finance/project-view-finance-header/project-view-finance-header.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/finance/project-view-finance-header/project-view-finance-header.tsx @@ -1,5 +1,4 @@ import { Button, ConfigProvider, Flex, Select, Typography } from 'antd'; -import React from 'react'; import GroupByFilterDropdown from './group-by-filter-dropdown'; import { DownOutlined } from '@ant-design/icons'; import { useAppDispatch } from '../../../../../hooks/useAppDispatch'; diff --git a/worklenz-frontend/src/pages/projects/projectView/finance/project-view-finance.tsx b/worklenz-frontend/src/pages/projects/projectView/finance/project-view-finance.tsx index 91166edf..5d2fb08f 100644 --- a/worklenz-frontend/src/pages/projects/projectView/finance/project-view-finance.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/finance/project-view-finance.tsx @@ -1,61 +1,34 @@ import { Flex } from 'antd'; -import React, { useState, useEffect } from 'react'; +import { useEffect } from 'react'; import { useParams } from 'react-router-dom'; +import { useAppDispatch } from '@/hooks/useAppDispatch'; +import { useAppSelector } from '@/hooks/useAppSelector'; import ProjectViewFinanceHeader from './project-view-finance-header/project-view-finance-header'; import FinanceTab from './finance-tab/finance-tab'; import RatecardTab from './ratecard-tab/ratecard-tab'; -import { projectFinanceApiService } from '@/api/project-finance-ratecard/project-finance.api.service'; -import { IProjectFinanceGroup } from '@/types/project/project-finance.types'; - -type FinanceTabType = 'finance' | 'ratecard'; -type GroupTypes = 'status' | 'priority' | 'phases'; - -interface TaskGroup { - group_id: string; - group_name: string; - tasks: any[]; -} - -interface FinanceTabProps { - groupType: GroupTypes; - taskGroups: TaskGroup[]; - loading: boolean; -} +import { fetchProjectFinances, setActiveTab, setActiveGroup } from '@/features/projects/finance/project-finance.slice'; +import { RootState } from '@/app/store'; const ProjectViewFinance = () => { const { projectId } = useParams<{ projectId: string }>(); - const [activeTab, setActiveTab] = useState('finance'); - const [activeGroup, setActiveGroup] = useState('status'); - const [loading, setLoading] = useState(false); - const [taskGroups, setTaskGroups] = useState([]); - - const fetchTasks = async () => { - if (!projectId) return; - - try { - setLoading(true); - const response = await projectFinanceApiService.getProjectTasks(projectId, activeGroup); - if (response.done) { - setTaskGroups(response.body); - } - } catch (error) { - console.error('Error fetching tasks:', error); - } finally { - setLoading(false); - } - }; + const dispatch = useAppDispatch(); + + const { activeTab, activeGroup, loading, taskGroups } = useAppSelector((state: RootState) => state.projectFinances); + const { refreshTimestamp } = useAppSelector((state: RootState) => state.projectReducer); useEffect(() => { - fetchTasks(); - }, [projectId, activeGroup]); + if (projectId) { + dispatch(fetchProjectFinances({ projectId, groupBy: activeGroup })); + } + }, [projectId, activeGroup, dispatch, refreshTimestamp]); return ( dispatch(setActiveTab(tab))} activeGroup={activeGroup} - setActiveGroup={setActiveGroup} + setActiveGroup={(group) => dispatch(setActiveGroup(group))} /> {activeTab === 'finance' ? ( diff --git a/worklenz-frontend/src/pages/projects/projectView/project-view-header.tsx b/worklenz-frontend/src/pages/projects/projectView/project-view-header.tsx index 5b5d32ff..385b50f1 100644 --- a/worklenz-frontend/src/pages/projects/projectView/project-view-header.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/project-view-header.tsx @@ -22,8 +22,18 @@ import { useAppSelector } from '@/hooks/useAppSelector'; import { SocketEvents } from '@/shared/socket-events'; import { useAuthService } from '@/hooks/useAuth'; import { useSocket } from '@/socket/socketContext'; -import { setProject, setImportTaskTemplateDrawerOpen, setRefreshTimestamp, getProject } from '@features/project/project.slice'; -import { addTask, fetchTaskGroups, fetchTaskListColumns, IGroupBy } from '@features/tasks/tasks.slice'; +import { + setProject, + setImportTaskTemplateDrawerOpen, + setRefreshTimestamp, + getProject, +} from '@features/project/project.slice'; +import { + addTask, + fetchTaskGroups, + fetchTaskListColumns, + IGroupBy, +} from '@features/tasks/tasks.slice'; import ProjectStatusIcon from '@/components/common/project-status-icon/project-status-icon'; import { formatDate } from '@/utils/timeUtils'; import { toggleSaveAsTemplateDrawer } from '@/features/projects/projectsSlice'; @@ -60,10 +70,7 @@ const ProjectViewHeader = () => { const { socket } = useSocket(); - const { - project: selectedProject, - projectId, - } = useAppSelector(state => state.projectReducer); + const { project: selectedProject, projectId } = useAppSelector(state => state.projectReducer); const { loadingGroups, groupBy } = useAppSelector(state => state.taskReducer); const [creatingTask, setCreatingTask] = useState(false); @@ -74,7 +81,7 @@ const ProjectViewHeader = () => { switch (tab) { case 'tasks-list': dispatch(fetchTaskListColumns(projectId)); - dispatch(fetchPhasesByProjectId(projectId)) + dispatch(fetchPhasesByProjectId(projectId)); dispatch(fetchTaskGroups(projectId)); break; case 'board': @@ -92,6 +99,9 @@ const ProjectViewHeader = () => { case 'updates': dispatch(setRefreshTimestamp()); break; + case 'finance': + dispatch(setRefreshTimestamp()); + break; default: break; } @@ -222,7 +232,7 @@ const ProjectViewHeader = () => { /> - {(isOwnerOrAdmin) && ( + {isOwnerOrAdmin && (