From b0ed3f67e8ce84b3fdfc7141126ede7390bdc03f Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Mon, 26 May 2025 16:36:25 +0530 Subject: [PATCH] feat(task-breakdown-api): implement task financial breakdown API and related enhancements - Added a new API endpoint `GET /api/project-finance/task/:id/breakdown` to retrieve detailed financial breakdown for individual tasks, including labor hours and costs grouped by job roles. - Introduced a new SQL migration to add a `fixed_cost` column to the tasks table for improved financial calculations. - Updated the project finance controller to handle task breakdown logic, including calculations for estimated and actual costs. - Enhanced frontend components to integrate the new task breakdown API, providing real-time financial data in the finance drawer. - Updated localization files to reflect changes in financial terminology across English, Spanish, and Portuguese. - Implemented Redux state management for selected tasks in the finance drawer. --- docs/api/task-breakdown-api.md | 195 ++++++++++++ ...20250520000000-add-fixed-cost-to-tasks.sql | 6 + .../controllers/project-finance-controller.ts | 297 ++++++++++++++++-- .../routes/apis/project-finance-api-router.ts | 8 + .../locales/en/project-view-finance.json | 2 +- .../locales/es/project-view-finance.json | 2 +- .../locales/pt/project-view-finance.json | 2 +- .../project-finance.api.service.ts | 15 +- .../src/components/avatars/avatars.tsx | 13 +- .../src/components/conditional-alert.tsx | 61 ++++ .../src/components/license-alert.tsx | 184 +++++++++++ .../finance/finance-drawer/finance-drawer.tsx | 260 ++++++++------- .../src/features/finance/finance-slice.ts | 16 + .../src/features/navbar/navbar.tsx | 5 +- .../projects/finance/project-finance.slice.ts | 30 +- worklenz-frontend/src/layouts/MainLayout.tsx | 25 +- .../project-view-finance-table-columns.ts | 37 ++- .../finance/finance-tab/finance-tab.tsx | 9 +- .../finance-table/finance-table-wrapper.tsx | 294 ++++++++--------- .../finance-table/finance-table.tsx | 210 ++++++++----- .../finance/project-view-finance.tsx | 3 +- .../projectView/project-view-header.tsx | 29 +- .../members-time-sheet/members-time-sheet.tsx | 11 +- worklenz-frontend/src/shared/constants.ts | 1 + .../src/types/auth/local-session.types.ts | 1 + .../types/project/project-finance.types.ts | 69 +++- 26 files changed, 1330 insertions(+), 455 deletions(-) create mode 100644 docs/api/task-breakdown-api.md create mode 100644 worklenz-backend/database/migrations/20250520000000-add-fixed-cost-to-tasks.sql create mode 100644 worklenz-frontend/src/components/conditional-alert.tsx create mode 100644 worklenz-frontend/src/components/license-alert.tsx 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/src/controllers/project-finance-controller.ts b/worklenz-backend/src/controllers/project-finance-controller.ts index df602b15..5a9903be 100644 --- a/worklenz-backend/src/controllers/project-finance-controller.ts +++ b/worklenz-backend/src/controllers/project-finance-controller.ts @@ -17,7 +17,24 @@ export default class ProjectfinanceController extends WorklenzControllerBase { const projectId = req.params.project_id; const groupBy = req.query.group || "status"; - // Get all tasks with their financial data + // 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_costs AS ( SELECT @@ -25,51 +42,94 @@ export default class ProjectfinanceController extends WorklenzControllerBase { 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, - 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)::float as estimated_cost, - COALESCE(t.fixed_cost, 0)::float as fixed_cost, 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 + t.billable, + COALESCE(t.fixed_cost, 0) as fixed_cost FROM tasks t 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 - tc.*, - (tc.estimated_cost + tc.fixed_cost)::float as total_budget, - 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 = tc.id), 0)::float + tc.fixed_cost as total_actual, - (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 = tc.id), 0)::float + tc.fixed_cost) - (tc.estimated_cost + tc.fixed_cost)::float as variance - FROM task_costs tc; + tec.*, + (tec.estimated_cost + tec.fixed_cost) as total_budget, + (tec.actual_cost_from_logs + tec.fixed_cost) as total_actual, + ((tec.actual_cost_from_logs + tec.fixed_cost) - (tec.estimated_cost + tec.fixed_cost)) as variance + FROM task_estimated_costs tec; `; const result = await db.query(q, [projectId]); const tasks = result.rows; - // Add color_code to each assignee + // 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; + } } } } @@ -151,6 +211,185 @@ export default class ProjectfinanceController extends WorklenzControllerBase { }; }); - return res.status(200).send(new ServerResponse(true, groupedTasks)); + // 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 ee34c7e4..a9990954 100644 --- a/worklenz-frontend/public/locales/en/project-view-finance.json +++ b/worklenz-frontend/public/locales/en/project-view-finance.json @@ -11,7 +11,7 @@ "taskColumn": "Task", "membersColumn": "Members", - "hoursColumn": "Hours", + "hoursColumn": "Estimated Hours", "totalTimeLoggedColumn": "Total Time Logged", "costColumn": "Cost", "estimatedCostColumn": "Estimated Cost", diff --git a/worklenz-frontend/public/locales/es/project-view-finance.json b/worklenz-frontend/public/locales/es/project-view-finance.json index 440378fa..93491817 100644 --- a/worklenz-frontend/public/locales/es/project-view-finance.json +++ b/worklenz-frontend/public/locales/es/project-view-finance.json @@ -11,7 +11,7 @@ "taskColumn": "Tarea", "membersColumn": "Miembros", - "hoursColumn": "Horas", + "hoursColumn": "Horas Estimadas", "totalTimeLoggedColumn": "Tiempo Total Registrado", "costColumn": "Costo", "estimatedCostColumn": "Costo Estimado", diff --git a/worklenz-frontend/public/locales/pt/project-view-finance.json b/worklenz-frontend/public/locales/pt/project-view-finance.json index e436d8c0..1520b498 100644 --- a/worklenz-frontend/public/locales/pt/project-view-finance.json +++ b/worklenz-frontend/public/locales/pt/project-view-finance.json @@ -11,7 +11,7 @@ "taskColumn": "Tarefa", "membersColumn": "Membros", - "hoursColumn": "Horas", + "hoursColumn": "Horas Estimadas", "totalTimeLoggedColumn": "Tempo Total Registrado", "costColumn": "Custo", "estimatedCostColumn": "Custo Estimado", 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 9d99ccda..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,8 +9,8 @@ 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 } @@ -20,6 +20,15 @@ export const projectFinanceApiService = { 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 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 }} /> - )} + )} */} { + await projectFinanceApiService.updateTaskFixedCost(taskId, fixedCost); + return { taskId, groupId, fixedCost }; + } +); + export const projectFinancesSlice = createSlice({ name: 'projectFinances', initialState, @@ -140,10 +150,26 @@ export const projectFinancesSlice = createSlice({ }) .addCase(fetchProjectFinances.fulfilled, (state, action) => { state.loading = false; - state.taskGroups = action.payload; + 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; + } + } }); }, }); 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/project-view-finance-table-columns.ts b/worklenz-frontend/src/lib/project/project-view-finance-table-columns.ts index 0e357f37..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,5 +1,18 @@ +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'; @@ -9,61 +22,61 @@ type FinanceTableColumnsType = { // finance table columns export const financeTableColumns: FinanceTableColumnsType[] = [ { - key: 'task', + key: FinanceTableColumnKeys.TASK, name: 'taskColumn', width: 240, type: 'string', }, { - key: 'members', + key: FinanceTableColumnKeys.MEMBERS, name: 'membersColumn', width: 160, type: 'string', }, { - key: 'hours', + key: FinanceTableColumnKeys.HOURS, name: 'hoursColumn', - width: 80, + width: 100, type: 'hours', }, { - key: 'total_time_logged', + key: FinanceTableColumnKeys.TOTAL_TIME_LOGGED, name: 'totalTimeLoggedColumn', width: 120, type: 'hours', }, { - key: 'estimated_cost', + key: FinanceTableColumnKeys.ESTIMATED_COST, name: 'estimatedCostColumn', width: 120, type: 'currency', }, { - key: 'cost', + key: FinanceTableColumnKeys.COST, name: 'costColumn', width: 120, type: 'currency', }, { - key: 'fixedCost', + key: FinanceTableColumnKeys.FIXED_COST, name: 'fixedCostColumn', width: 120, type: 'currency', }, { - key: 'totalBudget', + key: FinanceTableColumnKeys.TOTAL_BUDGET, name: 'totalBudgetedCostColumn', width: 120, type: 'currency', }, { - key: 'totalActual', + key: FinanceTableColumnKeys.TOTAL_ACTUAL, name: 'totalActualCostColumn', width: 120, type: 'currency', }, { - key: 'variance', + 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 02cc1f65..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'; @@ -23,11 +22,11 @@ const FinanceTab = ({ 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 + cost: task.estimated_cost || 0, + fixedCost: task.fixed_cost || 0, + totalBudget: task.total_budget || 0, totalActual: task.total_actual || 0, - variance: 0, // TODO: Calculate variance + variance: task.variance || 0, members: task.members || [], isbBillable: task.billable, total_time_logged: task.total_time_logged || 0, 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 5f35c008..3c7d5c7f 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,52 +1,34 @@ -import React, { useEffect, useState } from 'react'; -import { Checkbox, Flex, Tooltip, 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 } 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; - total_time_logged: number; - estimated_cost: number; - }[]; - }[]; + 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(() => { @@ -63,84 +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; - total_time_logged: number; - estimated_cost: 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; - acc.total_time_logged += task.total_time_logged || 0; - acc.estimated_cost += task.estimated_cost || 0; - }); - return acc; - }, - { - hours: 0, - cost: 0, - fixedCost: 0, - totalBudget: 0, - totalActual: 0, - variance: 0, - total_time_logged: 0, - estimated_cost: 0, - } - ); + { + hours: 0, + cost: 0, + fixedCost: 0, + totalBudget: 0, + totalActual: 0, + variance: 0, + total_time_logged: 0, + estimated_cost: 0, + } + ); + }, [taskGroups]); - console.log("totals", totals); + const handleFixedCostChange = (value: number | null, taskId: string, groupId: string) => { + dispatch(updateTaskFixedCostAsync({ taskId, groupId, fixedCost: value || 0 })); + setEditingFixedCost(null); + }; - const renderFinancialTableHeaderContent = (columnKey: any) => { + const renderFinancialTableHeaderContent = (columnKey: FinanceTableColumnKeys) => { switch (columnKey) { - case 'hours': + case FinanceTableColumnKeys.HOURS: return ( - {formatHoursToReadable(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 'total_time_logged': + case FinanceTableColumnKeys.TOTAL_TIME_LOGGED: return ( {totals.total_time_logged?.toFixed(2)} ); - case 'estimated_cost': + case FinanceTableColumnKeys.ESTIMATED_COST: return ( - {`${currency.toUpperCase()} ${totals.estimated_cost?.toFixed(2)}`} + {`${totals.estimated_cost?.toFixed(2)}`} ); default: @@ -168,54 +155,37 @@ 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-[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-[#141414]' : 'bg-[#fbfbfb]'}`; return ( <> - + - - {financeTableColumns.map((col) => ( + {financeTableColumns.map(col => ( ))} @@ -225,59 +195,43 @@ const FinanceTableWrapper: React.FC = ({ style={{ height: 56, fontWeight: 500, - backgroundColor: themeWiseColor( - '#fbfbfb', - '#141414', - themeMode - ), + backgroundColor: themeWiseColor('#fbfbfb', '#141414', themeMode), }} > - - {financeTableColumns.map( - (col) => - (col.type === 'hours' || col.type === 'currency') && ( - - ) - )} + {financeTableColumns.map((col, index) => ( + + ))} - {activeTablesList.map((table, index) => ( + {activeTablesList.map((table) => ( ))}
- - - {t(`${col.name}`)}{' '} - {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) + )} +
- {selectedTask && } + ); }; 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 d1923e98..97fefb13 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 @@ -8,20 +8,24 @@ import { } 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 { 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 { updateTaskFixedCost } from '@/features/projects/finance/project-finance.slice'; +import { updateTaskFixedCostAsync, updateTaskFixedCost } from '@/features/projects/finance/project-finance.slice'; import { useAppDispatch } from '@/hooks/useAppDispatch'; type FinanceTableProps = { 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); @@ -41,6 +45,20 @@ const FinanceTable = ({ } }, [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); @@ -49,19 +67,28 @@ const FinanceTable = ({ return value.toFixed(2); }; - const renderFinancialTableHeaderContent = (columnKey: string) => { + // 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 'hours': + case FinanceTableColumnKeys.HOURS: return {formatNumber(totals.hours)}; - case 'total_time_logged': + case FinanceTableColumnKeys.TOTAL_TIME_LOGGED: return {formatNumber(totals.total_time_logged)}; - case 'estimated_cost': + case FinanceTableColumnKeys.ESTIMATED_COST: return {formatNumber(totals.estimated_cost)}; - case 'totalBudget': + case FinanceTableColumnKeys.FIXED_COST: + return {formatNumber(totals.fixed_cost)}; + case FinanceTableColumnKeys.TOTAL_BUDGET: return {formatNumber(totals.total_budget)}; - case 'totalActual': + case FinanceTableColumnKeys.TOTAL_ACTUAL: return {formatNumber(totals.total_actual)}; - case 'variance': + case FinanceTableColumnKeys.VARIANCE: return {formatNumber(totals.variance)}; default: return null; @@ -69,12 +96,18 @@ const FinanceTable = ({ }; const handleFixedCostChange = (value: number | null, taskId: string) => { - dispatch(updateTaskFixedCost({ taskId, groupId: table.group_id, fixedCost: value || 0 })); + 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: string, task: IProjectFinanceTask) => { + const renderFinancialTableColumnContent = (columnKey: FinanceTableColumnKeys, task: IProjectFinanceTask) => { switch (columnKey) { - case 'task': + case FinanceTableColumnKeys.TASK: return ( @@ -88,22 +121,43 @@ const FinanceTable = ({ ); - case 'members': + case FinanceTableColumnKeys.MEMBERS: return task.members && ( - ({ - ...member, - avatar_url: member.avatar_url || undefined - }))} - /> +
{ + e.stopPropagation(); + onTaskClick(task); + }} + style={{ + cursor: 'pointer', + width: '100%', + padding: '4px', + borderRadius: '4px', + transition: 'background-color 0.2s' + }} + onMouseEnter={(e) => { + e.currentTarget.style.backgroundColor = themeWiseColor('#f0f0f0', '#333', themeMode); + }} + onMouseLeave={(e) => { + e.currentTarget.style.backgroundColor = 'transparent'; + }} + > + ({ + ...member, + avatar_url: member.avatar_url || undefined + }))} + allowClickThrough={true} + /> +
); - case 'hours': + case FinanceTableColumnKeys.HOURS: return {formatNumber(task.estimated_hours / 60)}; - case 'total_time_logged': + case FinanceTableColumnKeys.TOTAL_TIME_LOGGED: return {formatNumber(task.total_time_logged / 60)}; - case 'estimated_cost': + case FinanceTableColumnKeys.ESTIMATED_COST: return {formatNumber(task.estimated_cost)}; - case 'fixedCost': + case FinanceTableColumnKeys.FIXED_COST: return selectedTask?.id === task.id ? ( { + 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" /> ) : ( - {formatNumber(task.fixed_cost)} + { + e.stopPropagation(); + setSelectedTask(task); + }} + > + {formatNumber(task.fixed_cost)} + ); - case 'variance': + case FinanceTableColumnKeys.VARIANCE: return {formatNumber(task.variance)}; - case 'totalBudget': + case FinanceTableColumnKeys.TOTAL_BUDGET: return {formatNumber(task.total_budget)}; - case 'totalActual': + case FinanceTableColumnKeys.TOTAL_ACTUAL: return {formatNumber(task.total_actual)}; - case 'cost': - return {formatNumber(task.cost || 0)}; + case FinanceTableColumnKeys.COST: + return {formatNumber(task.estimated_cost || 0)}; default: return null; } @@ -141,6 +208,7 @@ const FinanceTable = ({ 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) @@ -149,6 +217,7 @@ const FinanceTable = ({ hours: 0, total_time_logged: 0, estimated_cost: 0, + fixed_cost: 0, total_budget: 0, total_actual: 0, variance: 0 @@ -172,43 +241,33 @@ const FinanceTable = ({ }} className="group" > - setIsCollapse((prev) => !prev)} - > - - {isCollapse ? : } - {table.group_name} ({tasks.length}) - - - {financeTableColumns.map( - (col) => - col.key !== 'task' && - col.key !== 'members' && ( - - {renderFinancialTableHeaderContent(col.key)} - - ) + (col, index) => ( + setIsCollapse((prev) => !prev) : undefined} + > + {col.key === FinanceTableColumnKeys.TASK ? ( + + {isCollapse ? : } + {table.group_name} ({tasks.length}) + + ) : col.key === FinanceTableColumnKeys.MEMBERS ? null : renderFinancialTableHeaderContent(col.key)} + + ) )} @@ -218,17 +277,12 @@ const FinanceTable = ({ key={task.id} style={{ height: 40, - background: idx % 2 === 0 ? '#232323' : '#181818', + background: idx % 2 === 0 ? themeWiseColor('#fafafa', '#232323', themeMode) : themeWiseColor('#ffffff', '#181818', themeMode), transition: 'background 0.2s', - cursor: 'pointer' }} - onMouseEnter={e => e.currentTarget.style.background = '#333'} - onMouseLeave={e => e.currentTarget.style.background = idx % 2 === 0 ? '#232323' : '#181818'} - onClick={() => setSelectedTask(task)} + onMouseEnter={e => 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.tsx b/worklenz-frontend/src/pages/projects/projectView/finance/project-view-finance.tsx index 4531fb21..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 @@ -14,12 +14,13 @@ const ProjectViewFinance = () => { const dispatch = useAppDispatch(); const { activeTab, activeGroup, loading, taskGroups } = useAppSelector((state: RootState) => state.projectFinances); + const { refreshTimestamp } = useAppSelector((state: RootState) => state.projectReducer); useEffect(() => { if (projectId) { dispatch(fetchProjectFinances({ projectId, groupBy: activeGroup })); } - }, [projectId, activeGroup, dispatch]); + }, [projectId, activeGroup, dispatch, refreshTimestamp]); return ( 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 && (