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.
This commit is contained in:
chamikaJ
2025-05-26 16:36:25 +05:30
parent 399d8b420a
commit b0ed3f67e8
26 changed files with 1330 additions and 455 deletions

View File

@@ -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 <Spin />;
if (!breakdown) return null;
return (
<div>
<h3>{breakdown.task.name}</h3>
<p>Total Estimated Cost: ${breakdown.task.total_estimated_cost}</p>
<p>Total Actual Cost: ${breakdown.task.total_actual_cost}</p>
{breakdown.grouped_members.map(group => (
<div key={group.jobRole}>
<h4>{group.jobRole}</h4>
<p>Hours: {group.estimated_hours} | Cost: ${group.estimated_cost}</p>
{group.members.map(member => (
<div key={member.team_member_id}>
{member.name}: {member.estimated_hours}h @ ${member.hourly_rate}/h
</div>
))}
</div>
))}
</div>
);
};
```
### 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.

View File

@@ -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';

View File

@@ -17,7 +17,24 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
const projectId = req.params.project_id; const projectId = req.params.project_id;
const groupBy = req.query.group || "status"; 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 = ` const q = `
WITH task_costs AS ( WITH task_costs AS (
SELECT SELECT
@@ -25,51 +42,94 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
t.name, t.name,
COALESCE(t.total_minutes, 0)::float as estimated_hours, 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(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.project_id,
t.status_id, t.status_id,
t.priority_id, t.priority_id,
(SELECT phase_id FROM task_phase WHERE task_id = t.id) as phase_id, (SELECT phase_id FROM task_phase WHERE task_id = t.id) as phase_id,
(SELECT get_task_assignees(t.id)) as assignees, (SELECT get_task_assignees(t.id)) as assignees,
t.billable t.billable,
COALESCE(t.fixed_cost, 0) as fixed_cost
FROM tasks t FROM tasks t
WHERE t.project_id = $1 AND t.archived = false WHERE t.project_id = $1 AND t.archived = false
),
task_estimated_costs AS (
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 SELECT
tc.*, tec.*,
(tc.estimated_cost + tc.fixed_cost)::float as total_budget, (tec.estimated_cost + tec.fixed_cost) as total_budget,
COALESCE((SELECT SUM(rate * (time_spent / 3600.0)) (tec.actual_cost_from_logs + tec.fixed_cost) as total_actual,
FROM task_work_log twl ((tec.actual_cost_from_logs + tec.fixed_cost) - (tec.estimated_cost + tec.fixed_cost)) as variance
LEFT JOIN users u ON twl.user_id = u.id FROM task_estimated_costs tec;
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;
`; `;
const result = await db.query(q, [projectId]); const result = await db.query(q, [projectId]);
const tasks = result.rows; 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) { for (const task of tasks) {
if (Array.isArray(task.assignees)) { if (Array.isArray(task.assignees)) {
for (const assignee of task.assignees) { for (const assignee of task.assignees) {
assignee.color_code = getColor(assignee.name); 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<IWorkLenzResponse> {
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<IWorkLenzResponse> {
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));
} }
} }

View File

@@ -1,9 +1,17 @@
import express from "express"; import express from "express";
import ProjectfinanceController from "../../controllers/project-finance-controller"; 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(); const projectFinanceApiRouter = express.Router();
projectFinanceApiRouter.get("/project/:project_id/tasks", ProjectfinanceController.getTasks); 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; export default projectFinanceApiRouter;

View File

@@ -11,7 +11,7 @@
"taskColumn": "Task", "taskColumn": "Task",
"membersColumn": "Members", "membersColumn": "Members",
"hoursColumn": "Hours", "hoursColumn": "Estimated Hours",
"totalTimeLoggedColumn": "Total Time Logged", "totalTimeLoggedColumn": "Total Time Logged",
"costColumn": "Cost", "costColumn": "Cost",
"estimatedCostColumn": "Estimated Cost", "estimatedCostColumn": "Estimated Cost",

View File

@@ -11,7 +11,7 @@
"taskColumn": "Tarea", "taskColumn": "Tarea",
"membersColumn": "Miembros", "membersColumn": "Miembros",
"hoursColumn": "Horas", "hoursColumn": "Horas Estimadas",
"totalTimeLoggedColumn": "Tiempo Total Registrado", "totalTimeLoggedColumn": "Tiempo Total Registrado",
"costColumn": "Costo", "costColumn": "Costo",
"estimatedCostColumn": "Costo Estimado", "estimatedCostColumn": "Costo Estimado",

View File

@@ -11,7 +11,7 @@
"taskColumn": "Tarefa", "taskColumn": "Tarefa",
"membersColumn": "Membros", "membersColumn": "Membros",
"hoursColumn": "Horas", "hoursColumn": "Horas Estimadas",
"totalTimeLoggedColumn": "Tempo Total Registrado", "totalTimeLoggedColumn": "Tempo Total Registrado",
"costColumn": "Custo", "costColumn": "Custo",
"estimatedCostColumn": "Custo Estimado", "estimatedCostColumn": "Custo Estimado",

View File

@@ -1,7 +1,7 @@
import { API_BASE_URL } from "@/shared/constants"; import { API_BASE_URL } from "@/shared/constants";
import { IServerResponse } from "@/types/common.types"; import { IServerResponse } from "@/types/common.types";
import apiClient from "../api-client"; import apiClient from "../api-client";
import { IProjectFinanceGroup } from "@/types/project/project-finance.types"; import { IProjectFinanceResponse, ITaskBreakdownResponse } from "@/types/project/project-finance.types";
const rootUrl = `${API_BASE_URL}/project-finance`; const rootUrl = `${API_BASE_URL}/project-finance`;
@@ -9,8 +9,8 @@ export const projectFinanceApiService = {
getProjectTasks: async ( getProjectTasks: async (
projectId: string, projectId: string,
groupBy: 'status' | 'priority' | 'phases' = 'status' groupBy: 'status' | 'priority' | 'phases' = 'status'
): Promise<IServerResponse<IProjectFinanceGroup[]>> => { ): Promise<IServerResponse<IProjectFinanceResponse>> => {
const response = await apiClient.get<IServerResponse<IProjectFinanceGroup[]>>( const response = await apiClient.get<IServerResponse<IProjectFinanceResponse>>(
`${rootUrl}/project/${projectId}/tasks`, `${rootUrl}/project/${projectId}/tasks`,
{ {
params: { group_by: groupBy } params: { group_by: groupBy }
@@ -20,6 +20,15 @@ export const projectFinanceApiService = {
return response.data; return response.data;
}, },
getTaskBreakdown: async (
taskId: string
): Promise<IServerResponse<ITaskBreakdownResponse>> => {
const response = await apiClient.get<IServerResponse<ITaskBreakdownResponse>>(
`${rootUrl}/task/${taskId}/breakdown`
);
return response.data;
},
updateTaskFixedCost: async ( updateTaskFixedCost: async (
taskId: string, taskId: string,
fixedCost: number fixedCost: number

View File

@@ -4,19 +4,20 @@ import { InlineMember } from '@/types/teamMembers/inlineMember.types';
interface AvatarsProps { interface AvatarsProps {
members: InlineMember[]; members: InlineMember[];
maxCount?: number; maxCount?: number;
allowClickThrough?: boolean;
} }
const renderAvatar = (member: InlineMember, index: number) => ( const renderAvatar = (member: InlineMember, index: number, allowClickThrough: boolean = false) => (
<Tooltip <Tooltip
key={member.team_member_id || index} key={member.team_member_id || index}
title={member.end && member.names ? member.names.join(', ') : member.name} title={member.end && member.names ? member.names.join(', ') : member.name}
> >
{member.avatar_url ? ( {member.avatar_url ? (
<span onClick={(e: React.MouseEvent) => e.stopPropagation()}> <span onClick={allowClickThrough ? undefined : (e: React.MouseEvent) => e.stopPropagation()}>
<Avatar src={member.avatar_url} size={28} key={member.team_member_id || index} /> <Avatar src={member.avatar_url} size={28} key={member.team_member_id || index} />
</span> </span>
) : ( ) : (
<span onClick={(e: React.MouseEvent) => e.stopPropagation()}> <span onClick={allowClickThrough ? undefined : (e: React.MouseEvent) => e.stopPropagation()}>
<Avatar <Avatar
size={28} size={28}
key={member.team_member_id || index} key={member.team_member_id || index}
@@ -32,12 +33,12 @@ const renderAvatar = (member: InlineMember, index: number) => (
</Tooltip> </Tooltip>
); );
const Avatars: React.FC<AvatarsProps> = ({ members, maxCount }) => { const Avatars: React.FC<AvatarsProps> = ({ members, maxCount, allowClickThrough = false }) => {
const visibleMembers = maxCount ? members.slice(0, maxCount) : members; const visibleMembers = maxCount ? members.slice(0, maxCount) : members;
return ( return (
<div onClick={(e: React.MouseEvent) => e.stopPropagation()}> <div onClick={allowClickThrough ? undefined : (e: React.MouseEvent) => e.stopPropagation()}>
<Avatar.Group> <Avatar.Group>
{visibleMembers.map((member, index) => renderAvatar(member, index))} {visibleMembers.map((member, index) => renderAvatar(member, index, allowClickThrough))}
</Avatar.Group> </Avatar.Group>
</div> </div>
); );

View File

@@ -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 (
<Alert
message={message}
type={type}
closable
onClose={handleClose}
style={alertStyles}
showIcon
className={className}
/>
);
};
export default ConditionalAlert;

View File

@@ -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<AlertConfig | null>(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} <strong>{string2}</strong>
</>
),
description: '',
icon: licenseType === 'expired' || licenseType === 'trial' ? <CrownOutlined /> : <ClockCircleOutlined />,
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 && (
<Space>
{/* Show button only if user is owner or admin */}
{(currentSession.owner || currentSession.is_admin) && (
<Button
type="primary"
size="small"
onClick={currentSession.subscription_status === 'trialing' ? handleUpgrade : handleExtend}
>
{currentSession.subscription_status === 'trialing' ? 'Upgrade now' : 'Go to Billing'}
</Button>
)}
</Space>
);
if (!visible || !alertConfig) {
return null;
}
return (
<div data-license-alert>
<Alert
message={alertConfig.message}
type={alertConfig.type}
closable
onClose={handleClose}
style={{
...alertStyles,
fontWeight: 500,
}}
showIcon
action={actionButtons}
/>
</div>
);
};
export default LicenseAlert;

View File

@@ -1,17 +1,40 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Drawer, Typography } from 'antd'; import { Drawer, Typography, Spin } from 'antd';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useAppSelector } from '../../../hooks/useAppSelector'; import { useAppSelector } from '../../../hooks/useAppSelector';
import { useAppDispatch } from '../../../hooks/useAppDispatch'; import { useAppDispatch } from '../../../hooks/useAppDispatch';
import { themeWiseColor } from '../../../utils/themeWiseColor'; 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 FinanceDrawer = () => {
const [selectedTask, setSelectedTask] = useState(task); const [taskBreakdown, setTaskBreakdown] = useState<ITaskBreakdownResponse | null>(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(() => { useEffect(() => {
setSelectedTask(task); if (selectedTask?.id && isDrawerOpen) {
}, [task]); 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 // localization
const { t } = useTranslation('project-view-finance'); const { t } = useTranslation('project-view-finance');
@@ -19,9 +42,6 @@ const FinanceDrawer = ({ task }: { task: any }) => {
// get theme data from theme reducer // get theme data from theme reducer
const themeMode = useAppSelector((state) => state.themeReducer.mode); const themeMode = useAppSelector((state) => state.themeReducer.mode);
const isDrawerOpen = useAppSelector(
(state) => state.financeReducer.isFinanceDrawerOpen
);
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const currency = useAppSelector( const currency = useAppSelector(
(state) => state.financeReducer.currency (state) => state.financeReducer.currency
@@ -29,41 +49,15 @@ const FinanceDrawer = ({ task }: { task: any }) => {
// function handle drawer close // function handle drawer close
const handleClose = () => { const handleClose = () => {
setSelectedTask(null); setTaskBreakdown(null);
dispatch(toggleFinanceDrawer()); 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 ( return (
<Drawer <Drawer
title={ title={
<Typography.Text style={{ fontWeight: 500, fontSize: 16 }}> <Typography.Text style={{ fontWeight: 500, fontSize: 16 }}>
{selectedTask?.task || t('noTaskSelected')} {taskBreakdown?.task?.name || selectedTask?.name || t('noTaskSelected')}
</Typography.Text> </Typography.Text>
} }
open={isDrawerOpen} open={isDrawerOpen}
@@ -72,98 +66,77 @@ const FinanceDrawer = ({ task }: { task: any }) => {
width={480} width={480}
> >
<div> <div>
<table {loading ? (
style={{ <div style={{ textAlign: 'center', padding: '20px' }}>
width: '100%', <Spin size="large" />
borderCollapse: 'collapse', </div>
marginBottom: '16px', ) : (
}} <table
> style={{
<thead> width: '100%',
<tr borderCollapse: 'collapse',
style={{ marginBottom: '16px',
height: 48, }}
backgroundColor: themeWiseColor( >
'#F5F5F5', <thead>
'#1d1d1d', <tr
themeMode
),
}}
>
<th
style={{ style={{
textAlign: 'left', height: 48,
padding: 8, backgroundColor: themeWiseColor(
}} '#F5F5F5',
></th> '#1d1d1d',
<th themeMode
style={{ ),
textAlign: 'right',
padding: 8,
}} }}
> >
{t('labourHoursColumn')} <th
</th>
<th
style={{
textAlign: 'right',
padding: 8,
}}
>
{t('costColumn')} ({currency})
</th>
</tr>
</thead>
<div className="mb-4"></div>
<tbody>
{Object.values(groupedMembers).map((group: any) => (
<React.Fragment key={group.jobRole}>
{/* Group Header */}
<tr
style={{ style={{
backgroundColor: themeWiseColor( textAlign: 'left',
'#D9D9D9', padding: 8,
'#000', }}
themeMode ></th>
), <th
height: 56, style={{
textAlign: 'right',
padding: 8,
}} }}
className="border-b-[1px] font-semibold"
> >
<td style={{ padding: 8 }}>{group.jobRole}</td> {t('labourHoursColumn')}
<td </th>
style={{ <th
textAlign: 'right', style={{
padding: 8, textAlign: 'right',
}} padding: 8,
> }}
{group.laborHours} >
</td> {t('costColumn')} ({currency})
<td </th>
style={{ </tr>
textAlign: 'right', </thead>
padding: 8,
}} <tbody>
> {taskBreakdown?.grouped_members?.map((group: any) => (
{group.cost} <React.Fragment key={group.jobRole}>
</td> {/* Group Header */}
</tr>
{/* Member Rows */}
{group.members.map((member: any, index: number) => (
<tr <tr
key={`${group.jobRole}-${index}`} style={{
className="border-b-[1px]" backgroundColor: themeWiseColor(
style={{ height: 56 }} '#D9D9D9',
'#000',
themeMode
),
height: 56,
}}
className="border-b-[1px] font-semibold"
> >
<td style={{ padding: 8 }}>{group.jobRole}</td>
<td <td
style={{ style={{
textAlign: 'right',
padding: 8, padding: 8,
paddingLeft: 32,
}} }}
> >
{member.name} {group.estimated_hours?.toFixed(2) || '0.00'}
</td> </td>
<td <td
style={{ style={{
@@ -171,22 +144,47 @@ const FinanceDrawer = ({ task }: { task: any }) => {
padding: 8, padding: 8,
}} }}
> >
{member.laborHours} {group.estimated_cost?.toFixed(2) || '0.00'}
</td>
<td
style={{
textAlign: 'right',
padding: 8,
}}
>
{member.cost}
</td> </td>
</tr> </tr>
))} {/* Member Rows */}
</React.Fragment> {group.members?.map((member: any, index: number) => (
))} <tr
</tbody> key={`${group.jobRole}-${index}`}
</table> className="border-b-[1px]"
style={{ height: 56 }}
>
<td
style={{
padding: 8,
paddingLeft: 32,
}}
>
{member.name}
</td>
<td
style={{
textAlign: 'right',
padding: 8,
}}
>
{member.estimated_hours?.toFixed(2) || '0.00'}
</td>
<td
style={{
textAlign: 'right',
padding: 8,
}}
>
{member.estimated_cost?.toFixed(2) || '0.00'}
</td>
</tr>
))}
</React.Fragment>
))}
</tbody>
</table>
)}
</div> </div>
</Drawer> </Drawer>
); );

View File

@@ -12,6 +12,7 @@ type financeState = {
isFinanceDrawerloading?: boolean; isFinanceDrawerloading?: boolean;
drawerRatecard?: RatecardType | null; drawerRatecard?: RatecardType | null;
ratecardsList?: RatecardType[] | null; ratecardsList?: RatecardType[] | null;
selectedTask?: any | null;
}; };
const initialState: financeState = { const initialState: financeState = {
@@ -23,6 +24,7 @@ const initialState: financeState = {
isFinanceDrawerloading: false, isFinanceDrawerloading: false,
drawerRatecard: null, drawerRatecard: null,
ratecardsList: null, ratecardsList: null,
selectedTask: null,
}; };
interface FetchRateCardsParams { interface FetchRateCardsParams {
index: number; index: number;
@@ -128,6 +130,17 @@ const financeSlice = createSlice({
toggleFinanceDrawer: (state) => { toggleFinanceDrawer: (state) => {
state.isFinanceDrawerOpen = !state.isFinanceDrawerOpen; state.isFinanceDrawerOpen = !state.isFinanceDrawerOpen;
}, },
openFinanceDrawer: (state, action: PayloadAction<any>) => {
state.isFinanceDrawerOpen = true;
state.selectedTask = action.payload;
},
closeFinanceDrawer: (state) => {
state.isFinanceDrawerOpen = false;
state.selectedTask = null;
},
setSelectedTask: (state, action: PayloadAction<any>) => {
state.selectedTask = action.payload;
},
toggleImportRatecardsDrawer: (state) => { toggleImportRatecardsDrawer: (state) => {
state.isImportRatecardsDrawerOpen = !state.isImportRatecardsDrawerOpen; state.isImportRatecardsDrawerOpen = !state.isImportRatecardsDrawerOpen;
}, },
@@ -176,6 +189,9 @@ const financeSlice = createSlice({
export const { export const {
toggleRatecardDrawer, toggleRatecardDrawer,
toggleFinanceDrawer, toggleFinanceDrawer,
openFinanceDrawer,
closeFinanceDrawer,
setSelectedTask,
toggleImportRatecardsDrawer, toggleImportRatecardsDrawer,
changeCurrency, changeCurrency,
ratecardDrawerLoading, ratecardDrawerLoading,

View File

@@ -90,6 +90,7 @@ const Navbar = () => {
}, [location]); }, [location]);
return ( return (
<Col <Col
style={{ style={{
width: '100%', width: '100%',
@@ -101,14 +102,14 @@ const Navbar = () => {
justifyContent: 'space-between', justifyContent: 'space-between',
}} }}
> >
{daysUntilExpiry !== null && ((daysUntilExpiry <= 3 && daysUntilExpiry > 0) || (daysUntilExpiry >= -7 && daysUntilExpiry < 0)) && ( {/* {daysUntilExpiry !== null && ((daysUntilExpiry <= 3 && daysUntilExpiry > 0) || (daysUntilExpiry >= -7 && daysUntilExpiry < 0)) && (
<Alert <Alert
message={daysUntilExpiry > 0 ? `Your license will expire in ${daysUntilExpiry} days` : `Your license has expired ${Math.abs(daysUntilExpiry)} days ago`} message={daysUntilExpiry > 0 ? `Your license will expire in ${daysUntilExpiry} days` : `Your license has expired ${Math.abs(daysUntilExpiry)} days ago`}
type="warning" type="warning"
showIcon showIcon
style={{ width: '100%', marginTop: 12 }} style={{ width: '100%', marginTop: 12 }}
/> />
)} )} */}
<Flex <Flex
style={{ style={{
width: '100%', width: '100%',

View File

@@ -1,6 +1,6 @@
import { projectFinanceApiService } from '@/api/project-finance-ratecard/project-finance.api.service'; import { projectFinanceApiService } from '@/api/project-finance-ratecard/project-finance.api.service';
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit'; import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
import { IProjectFinanceGroup, IProjectFinanceTask } from '@/types/project/project-finance.types'; import { IProjectFinanceGroup, IProjectFinanceTask, IProjectRateCard } from '@/types/project/project-finance.types';
type FinanceTabType = 'finance' | 'ratecard'; type FinanceTabType = 'finance' | 'ratecard';
type GroupTypes = 'status' | 'priority' | 'phases'; type GroupTypes = 'status' | 'priority' | 'phases';
@@ -10,6 +10,7 @@ interface ProjectFinanceState {
activeGroup: GroupTypes; activeGroup: GroupTypes;
loading: boolean; loading: boolean;
taskGroups: IProjectFinanceGroup[]; taskGroups: IProjectFinanceGroup[];
projectRateCards: IProjectRateCard[];
} }
// Utility functions for frontend calculations // Utility functions for frontend calculations
@@ -67,6 +68,7 @@ const initialState: ProjectFinanceState = {
activeGroup: 'status', activeGroup: 'status',
loading: false, loading: false,
taskGroups: [], taskGroups: [],
projectRateCards: [],
}; };
export const fetchProjectFinances = createAsyncThunk( export const fetchProjectFinances = createAsyncThunk(
@@ -77,6 +79,14 @@ export const fetchProjectFinances = createAsyncThunk(
} }
); );
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({ export const projectFinancesSlice = createSlice({
name: 'projectFinances', name: 'projectFinances',
initialState, initialState,
@@ -140,10 +150,26 @@ export const projectFinancesSlice = createSlice({
}) })
.addCase(fetchProjectFinances.fulfilled, (state, action) => { .addCase(fetchProjectFinances.fulfilled, (state, action) => {
state.loading = false; state.loading = false;
state.taskGroups = action.payload; state.taskGroups = action.payload.groups;
state.projectRateCards = action.payload.project_rate_cards;
}) })
.addCase(fetchProjectFinances.rejected, (state) => { .addCase(fetchProjectFinances.rejected, (state) => {
state.loading = false; 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;
}
}
}); });
}, },
}); });

View File

@@ -5,15 +5,22 @@ import { useAppSelector } from '../hooks/useAppSelector';
import { useMediaQuery } from 'react-responsive'; import { useMediaQuery } from 'react-responsive';
import { colors } from '../styles/colors'; import { colors } from '../styles/colors';
import { verifyAuthentication } from '@/features/auth/authSlice'; import { verifyAuthentication } from '@/features/auth/authSlice';
import { useEffect } from 'react'; import { useEffect, useState } from 'react';
import { useAppDispatch } from '@/hooks/useAppDispatch'; import { useAppDispatch } from '@/hooks/useAppDispatch';
import HubSpot from '@/components/HubSpot'; 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 MainLayout = () => {
const themeMode = useAppSelector(state => state.themeReducer.mode); const themeMode = useAppSelector(state => state.themeReducer.mode);
const isDesktop = useMediaQuery({ query: '(min-width: 1024px)' }); const isDesktop = useMediaQuery({ query: '(min-width: 1024px)' });
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const navigate = useNavigate(); const navigate = useNavigate();
const currentSession = useAuthService().getCurrentSession();
// State for alert visibility
const [showAlert, setShowAlert] = useState(false);
const verifyAuthStatus = async () => { const verifyAuthStatus = async () => {
const session = await dispatch(verifyAuthentication()).unwrap(); const session = await dispatch(verifyAuthentication()).unwrap();
@@ -26,6 +33,20 @@ const MainLayout = () => {
void verifyAuthStatus(); void verifyAuthStatus();
}, [dispatch, navigate]); }, [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 = { const headerStyles = {
zIndex: 999, zIndex: 999,
position: 'fixed', position: 'fixed',
@@ -34,11 +55,13 @@ const MainLayout = () => {
alignItems: 'center', alignItems: 'center',
padding: 0, padding: 0,
borderBottom: themeMode === 'dark' ? '1px solid #303030' : 'none', borderBottom: themeMode === 'dark' ? '1px solid #303030' : 'none',
top: alertHeight, // Push navbar down when alert is shown
} as const; } as const;
const contentStyles = { const contentStyles = {
paddingInline: isDesktop ? 64 : 24, paddingInline: isDesktop ? 64 : 24,
overflowX: 'hidden', overflowX: 'hidden',
marginTop: alertHeight + 64, // Adjust top margin based on alert height + navbar height
} as const; } as const;
return ( return (

View File

@@ -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 = { type FinanceTableColumnsType = {
key: string; key: FinanceTableColumnKeys;
name: string; name: string;
width: number; width: number;
type: 'string' | 'hours' | 'currency'; type: 'string' | 'hours' | 'currency';
@@ -9,61 +22,61 @@ type FinanceTableColumnsType = {
// finance table columns // finance table columns
export const financeTableColumns: FinanceTableColumnsType[] = [ export const financeTableColumns: FinanceTableColumnsType[] = [
{ {
key: 'task', key: FinanceTableColumnKeys.TASK,
name: 'taskColumn', name: 'taskColumn',
width: 240, width: 240,
type: 'string', type: 'string',
}, },
{ {
key: 'members', key: FinanceTableColumnKeys.MEMBERS,
name: 'membersColumn', name: 'membersColumn',
width: 160, width: 160,
type: 'string', type: 'string',
}, },
{ {
key: 'hours', key: FinanceTableColumnKeys.HOURS,
name: 'hoursColumn', name: 'hoursColumn',
width: 80, width: 100,
type: 'hours', type: 'hours',
}, },
{ {
key: 'total_time_logged', key: FinanceTableColumnKeys.TOTAL_TIME_LOGGED,
name: 'totalTimeLoggedColumn', name: 'totalTimeLoggedColumn',
width: 120, width: 120,
type: 'hours', type: 'hours',
}, },
{ {
key: 'estimated_cost', key: FinanceTableColumnKeys.ESTIMATED_COST,
name: 'estimatedCostColumn', name: 'estimatedCostColumn',
width: 120, width: 120,
type: 'currency', type: 'currency',
}, },
{ {
key: 'cost', key: FinanceTableColumnKeys.COST,
name: 'costColumn', name: 'costColumn',
width: 120, width: 120,
type: 'currency', type: 'currency',
}, },
{ {
key: 'fixedCost', key: FinanceTableColumnKeys.FIXED_COST,
name: 'fixedCostColumn', name: 'fixedCostColumn',
width: 120, width: 120,
type: 'currency', type: 'currency',
}, },
{ {
key: 'totalBudget', key: FinanceTableColumnKeys.TOTAL_BUDGET,
name: 'totalBudgetedCostColumn', name: 'totalBudgetedCostColumn',
width: 120, width: 120,
type: 'currency', type: 'currency',
}, },
{ {
key: 'totalActual', key: FinanceTableColumnKeys.TOTAL_ACTUAL,
name: 'totalActualCostColumn', name: 'totalActualCostColumn',
width: 120, width: 120,
type: 'currency', type: 'currency',
}, },
{ {
key: 'variance', key: FinanceTableColumnKeys.VARIANCE,
name: 'varianceColumn', name: 'varianceColumn',
width: 120, width: 120,
type: 'currency', type: 'currency',

View File

@@ -1,4 +1,3 @@
import React from 'react';
import FinanceTableWrapper from './finance-table/finance-table-wrapper'; import FinanceTableWrapper from './finance-table/finance-table-wrapper';
import { IProjectFinanceGroup } from '@/types/project/project-finance.types'; import { IProjectFinanceGroup } from '@/types/project/project-finance.types';
@@ -23,11 +22,11 @@ const FinanceTab = ({
id: task.id, id: task.id,
name: task.name, name: task.name,
hours: task.estimated_hours || 0, hours: task.estimated_hours || 0,
cost: 0, // TODO: Calculate based on rate and hours cost: task.estimated_cost || 0,
fixedCost: 0, // TODO: Add fixed cost field fixedCost: task.fixed_cost || 0,
totalBudget: 0, // TODO: Calculate total budget totalBudget: task.total_budget || 0,
totalActual: task.total_actual || 0, totalActual: task.total_actual || 0,
variance: 0, // TODO: Calculate variance variance: task.variance || 0,
members: task.members || [], members: task.members || [],
isbBillable: task.billable, isbBillable: task.billable,
total_time_logged: task.total_time_logged || 0, total_time_logged: task.total_time_logged || 0,

View File

@@ -1,52 +1,34 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState, useMemo } from 'react';
import { Checkbox, Flex, Tooltip, Typography } from 'antd'; import { Flex, InputNumber, Tooltip, Typography } from 'antd';
import { themeWiseColor } from '../../../../../../utils/themeWiseColor'; import { themeWiseColor } from '@/utils/themeWiseColor';
import { useAppSelector } from '../../../../../../hooks/useAppSelector'; import { useAppSelector } from '@/hooks/useAppSelector';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useAppDispatch } from '../../../../../../hooks/useAppDispatch'; import { useAppDispatch } from '@/hooks/useAppDispatch';
import { toggleFinanceDrawer } from '@/features/finance/finance-slice'; import { openFinanceDrawer } from '@/features/finance/finance-slice';
import { financeTableColumns } from '@/lib/project/project-view-finance-table-columns'; import { financeTableColumns, FinanceTableColumnKeys } from '@/lib/project/project-view-finance-table-columns';
import FinanceTable from './finance-table'; import FinanceTable from './finance-table';
import FinanceDrawer from '@/features/finance/finance-drawer/finance-drawer'; import FinanceDrawer from '@/features/finance/finance-drawer/finance-drawer';
import { convertToHoursMinutes, formatHoursToReadable } from '@/utils/format-hours-to-readable'; 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 { interface FinanceTableWrapperProps {
activeTablesList: { activeTablesList: IProjectFinanceGroup[];
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;
}[];
}[];
loading: boolean; loading: boolean;
} }
const FinanceTableWrapper: React.FC<FinanceTableWrapperProps> = ({ const FinanceTableWrapper: React.FC<FinanceTableWrapperProps> = ({ activeTablesList, loading }) => {
activeTablesList,
loading
}) => {
const [isScrolling, setIsScrolling] = useState(false); 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 { t } = useTranslation('project-view-finance');
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
// Get selected task from Redux store
const selectedTask = useAppSelector(state => state.financeReducer.selectedTask);
const onTaskClick = (task: any) => { const onTaskClick = (task: any) => {
setSelectedTask(task); dispatch(openFinanceDrawer(task));
dispatch(toggleFinanceDrawer());
}; };
useEffect(() => { useEffect(() => {
@@ -63,84 +45,89 @@ const FinanceTableWrapper: React.FC<FinanceTableWrapperProps> = ({
}; };
}, []); }, []);
const themeMode = useAppSelector((state) => state.themeReducer.mode); // Handle click outside to close editing
const { currency } = useAppSelector((state) => state.financeReducer); useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (editingFixedCost && !(event.target as Element)?.closest('.fixed-cost-input')) {
setEditingFixedCost(null);
}
};
const totals = activeTablesList.reduce( document.addEventListener('mousedown', handleClickOutside);
( return () => {
acc: { document.removeEventListener('mousedown', handleClickOutside);
hours: number; };
cost: number; }, [editingFixedCost]);
fixedCost: number;
totalBudget: number; const themeMode = useAppSelector(state => state.themeReducer.mode);
totalActual: number; const { currency } = useAppSelector(state => state.financeReducer);
variance: number; const taskGroups = useAppSelector(state => state.projectFinances.taskGroups);
total_time_logged: number;
estimated_cost: number; // 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[] } {
) => { hours: 0,
table.tasks.forEach((task: any) => { cost: 0,
acc.hours += task.hours || 0; fixedCost: 0,
acc.cost += task.cost || 0; totalBudget: 0,
acc.fixedCost += task.fixedCost || 0; totalActual: 0,
acc.totalBudget += task.totalBudget || 0; variance: 0,
acc.totalActual += task.totalActual || 0; total_time_logged: 0,
acc.variance += task.variance || 0; estimated_cost: 0,
acc.total_time_logged += task.total_time_logged || 0; }
acc.estimated_cost += task.estimated_cost || 0; );
}); }, [taskGroups]);
return acc;
},
{
hours: 0,
cost: 0,
fixedCost: 0,
totalBudget: 0,
totalActual: 0,
variance: 0,
total_time_logged: 0,
estimated_cost: 0,
}
);
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) { switch (columnKey) {
case 'hours': case FinanceTableColumnKeys.HOURS:
return ( return (
<Typography.Text style={{ fontSize: 18 }}> <Typography.Text style={{ fontSize: 18 }}>
<Tooltip title={convertToHoursMinutes(totals.hours)}> <Tooltip title={convertToHoursMinutes(totals.hours)}>
{formatHoursToReadable(totals.hours)} {formatHoursToReadable(totals.hours).toFixed(2)}
</Tooltip> </Tooltip>
</Typography.Text> </Typography.Text>
); );
case 'cost': case FinanceTableColumnKeys.COST:
return ( return <Typography.Text style={{ fontSize: 18 }}>{`${totals.cost?.toFixed(2)}`}</Typography.Text>;
<Typography.Text style={{ fontSize: 18 }}> case FinanceTableColumnKeys.FIXED_COST:
{totals.cost} return <Typography.Text style={{ fontSize: 18 }}>{totals.fixedCost?.toFixed(2)}</Typography.Text>;
</Typography.Text> case FinanceTableColumnKeys.TOTAL_BUDGET:
); return <Typography.Text style={{ fontSize: 18 }}>{totals.totalBudget?.toFixed(2)}</Typography.Text>;
case 'fixedCost': case FinanceTableColumnKeys.TOTAL_ACTUAL:
return ( return <Typography.Text style={{ fontSize: 18 }}>{totals.totalActual?.toFixed(2)}</Typography.Text>;
<Typography.Text style={{ fontSize: 18 }}> case FinanceTableColumnKeys.VARIANCE:
{totals.fixedCost}
</Typography.Text>
);
case 'totalBudget':
return (
<Typography.Text style={{ fontSize: 18 }}>
{totals.totalBudget}
</Typography.Text>
);
case 'totalActual':
return (
<Typography.Text style={{ fontSize: 18 }}>
{totals.totalActual}
</Typography.Text>
);
case 'variance':
return ( return (
<Typography.Text <Typography.Text
style={{ style={{
@@ -148,19 +135,19 @@ const FinanceTableWrapper: React.FC<FinanceTableWrapperProps> = ({
fontSize: 18, fontSize: 18,
}} }}
> >
{totals.variance} {`${totals.variance?.toFixed(2)}`}
</Typography.Text> </Typography.Text>
); );
case 'total_time_logged': case FinanceTableColumnKeys.TOTAL_TIME_LOGGED:
return ( return (
<Typography.Text style={{ fontSize: 18 }}> <Typography.Text style={{ fontSize: 18 }}>
{totals.total_time_logged?.toFixed(2)} {totals.total_time_logged?.toFixed(2)}
</Typography.Text> </Typography.Text>
); );
case 'estimated_cost': case FinanceTableColumnKeys.ESTIMATED_COST:
return ( return (
<Typography.Text style={{ fontSize: 18 }}> <Typography.Text style={{ fontSize: 18 }}>
{`${currency.toUpperCase()} ${totals.estimated_cost?.toFixed(2)}`} {`${totals.estimated_cost?.toFixed(2)}`}
</Typography.Text> </Typography.Text>
); );
default: default:
@@ -168,54 +155,37 @@ const FinanceTableWrapper: React.FC<FinanceTableWrapperProps> = ({
} }
}; };
const customColumnHeaderStyles = (key: string) => const customColumnHeaderStyles = (key: FinanceTableColumnKeys) =>
`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]'}`; `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) => const customColumnStyles = (key: FinanceTableColumnKeys) =>
`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' : ''}`}`; `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 ( return (
<> <>
<Flex <Flex vertical className="tasklist-container min-h-0 max-w-full overflow-x-auto">
vertical
className="tasklist-container min-h-0 max-w-full overflow-x-auto"
>
<table> <table>
<tbody> <tbody>
<tr <tr
style={{ style={{
height: 56, height: 56,
fontWeight: 600, fontWeight: 600,
backgroundColor: themeWiseColor( backgroundColor: themeWiseColor('#fafafa', '#1d1d1d', themeMode),
'#fafafa',
'#1d1d1d',
themeMode
),
borderBlockEnd: `2px solid rgb(0 0 0 / 0.05)`, borderBlockEnd: `2px solid rgb(0 0 0 / 0.05)`,
}} }}
> >
<td {financeTableColumns.map(col => (
style={{ width: 32, paddingInline: 16 }}
className={customColumnHeaderStyles('selector')}
>
<Checkbox />
</td>
{financeTableColumns.map((col) => (
<td <td
key={col.key} key={col.key}
style={{ style={{
minWidth: col.width, minWidth: col.width,
paddingInline: 16, paddingInline: 16,
textAlign: textAlign: col.type === 'hours' || col.type === 'currency' ? 'center' : 'start',
col.type === 'hours' || col.type === 'currency'
? 'center'
: 'start',
}} }}
className={`${customColumnHeaderStyles(col.key)} before:constent relative before:absolute before:left-0 before:top-1/2 before:h-[36px] before:w-0.5 before:-translate-y-1/2 ${themeMode === 'dark' ? 'before:bg-white/10' : 'before:bg-black/5'}`} className={`${customColumnHeaderStyles(col.key)} before:constent relative before:absolute before:left-0 before:top-1/2 before:h-[36px] before:w-0.5 before:-translate-y-1/2 ${themeMode === 'dark' ? 'before:bg-white/10' : 'before:bg-black/5'}`}
> >
<Typography.Text> <Typography.Text>
{t(`${col.name}`)}{' '} {t(`${col.name}`)} {col.type === 'currency' && `(${currency.toUpperCase()})`}
{col.type === 'currency' && `(${currency.toUpperCase()})`}
</Typography.Text> </Typography.Text>
</td> </td>
))} ))}
@@ -225,59 +195,43 @@ const FinanceTableWrapper: React.FC<FinanceTableWrapperProps> = ({
style={{ style={{
height: 56, height: 56,
fontWeight: 500, fontWeight: 500,
backgroundColor: themeWiseColor( backgroundColor: themeWiseColor('#fbfbfb', '#141414', themeMode),
'#fbfbfb',
'#141414',
themeMode
),
}} }}
> >
<td {financeTableColumns.map((col, index) => (
colSpan={3} <td
style={{ key={col.key}
paddingInline: 16, style={{
backgroundColor: themeWiseColor( minWidth: col.width,
'#fbfbfb', paddingInline: 16,
'#141414', textAlign: col.key === FinanceTableColumnKeys.TASK ? 'left' : 'right',
themeMode backgroundColor: themeWiseColor('#fbfbfb', '#141414', themeMode),
), }}
}} className={customColumnStyles(col.key)}
className={customColumnStyles('totalRow')} >
> {col.key === FinanceTableColumnKeys.TASK ? (
<Typography.Text style={{ fontSize: 18 }}> <Typography.Text style={{ fontSize: 18 }}>{t('totalText')}</Typography.Text>
{t('totalText')} ) : col.key === FinanceTableColumnKeys.MEMBERS ? null : (
</Typography.Text> (col.type === 'hours' || col.type === 'currency') && renderFinancialTableHeaderContent(col.key)
</td> )}
{financeTableColumns.map( </td>
(col) => ))}
(col.type === 'hours' || col.type === 'currency') && (
<td
key={col.key}
style={{
minWidth: col.width,
paddingInline: 16,
textAlign: 'end',
}}
>
{renderFinancialTableHeaderContent(col.key)}
</td>
)
)}
</tr> </tr>
{activeTablesList.map((table, index) => ( {activeTablesList.map((table) => (
<FinanceTable <FinanceTable
key={index} key={table.group_id}
table={table} table={table}
isScrolling={isScrolling} isScrolling={isScrolling}
onTaskClick={onTaskClick} onTaskClick={onTaskClick}
loading={loading}
/> />
))} ))}
</tbody> </tbody>
</table> </table>
</Flex> </Flex>
{selectedTask && <FinanceDrawer task={selectedTask} />} <FinanceDrawer />
</> </>
); );
}; };

View File

@@ -8,20 +8,24 @@ import {
} from '@ant-design/icons'; } from '@ant-design/icons';
import { themeWiseColor } from '@/utils/themeWiseColor'; import { themeWiseColor } from '@/utils/themeWiseColor';
import { colors } from '@/styles/colors'; 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 Avatars from '@/components/avatars/avatars';
import { IProjectFinanceGroup, IProjectFinanceTask } from '@/types/project/project-finance.types'; 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'; import { useAppDispatch } from '@/hooks/useAppDispatch';
type FinanceTableProps = { type FinanceTableProps = {
table: IProjectFinanceGroup; table: IProjectFinanceGroup;
loading: boolean; loading: boolean;
isScrolling: boolean;
onTaskClick: (task: any) => void;
}; };
const FinanceTable = ({ const FinanceTable = ({
table, table,
loading, loading,
isScrolling,
onTaskClick,
}: FinanceTableProps) => { }: FinanceTableProps) => {
const [isCollapse, setIsCollapse] = useState<boolean>(false); const [isCollapse, setIsCollapse] = useState<boolean>(false);
const [selectedTask, setSelectedTask] = useState<IProjectFinanceTask | null>(null); const [selectedTask, setSelectedTask] = useState<IProjectFinanceTask | null>(null);
@@ -41,6 +45,20 @@ const FinanceTable = ({
} }
}, [table.tasks, taskGroups, table.group_id]); }, [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 // get theme data from theme reducer
const themeMode = useAppSelector((state) => state.themeReducer.mode); const themeMode = useAppSelector((state) => state.themeReducer.mode);
@@ -49,19 +67,28 @@ const FinanceTable = ({
return value.toFixed(2); 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) { switch (columnKey) {
case 'hours': case FinanceTableColumnKeys.HOURS:
return <Typography.Text>{formatNumber(totals.hours)}</Typography.Text>; return <Typography.Text>{formatNumber(totals.hours)}</Typography.Text>;
case 'total_time_logged': case FinanceTableColumnKeys.TOTAL_TIME_LOGGED:
return <Typography.Text>{formatNumber(totals.total_time_logged)}</Typography.Text>; return <Typography.Text>{formatNumber(totals.total_time_logged)}</Typography.Text>;
case 'estimated_cost': case FinanceTableColumnKeys.ESTIMATED_COST:
return <Typography.Text>{formatNumber(totals.estimated_cost)}</Typography.Text>; return <Typography.Text>{formatNumber(totals.estimated_cost)}</Typography.Text>;
case 'totalBudget': case FinanceTableColumnKeys.FIXED_COST:
return <Typography.Text>{formatNumber(totals.fixed_cost)}</Typography.Text>;
case FinanceTableColumnKeys.TOTAL_BUDGET:
return <Typography.Text>{formatNumber(totals.total_budget)}</Typography.Text>; return <Typography.Text>{formatNumber(totals.total_budget)}</Typography.Text>;
case 'totalActual': case FinanceTableColumnKeys.TOTAL_ACTUAL:
return <Typography.Text>{formatNumber(totals.total_actual)}</Typography.Text>; return <Typography.Text>{formatNumber(totals.total_actual)}</Typography.Text>;
case 'variance': case FinanceTableColumnKeys.VARIANCE:
return <Typography.Text>{formatNumber(totals.variance)}</Typography.Text>; return <Typography.Text>{formatNumber(totals.variance)}</Typography.Text>;
default: default:
return null; return null;
@@ -69,12 +96,18 @@ const FinanceTable = ({
}; };
const handleFixedCostChange = (value: number | null, taskId: string) => { 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) { switch (columnKey) {
case 'task': case FinanceTableColumnKeys.TASK:
return ( return (
<Tooltip title={task.name}> <Tooltip title={task.name}>
<Flex gap={8} align="center"> <Flex gap={8} align="center">
@@ -88,22 +121,43 @@ const FinanceTable = ({
</Flex> </Flex>
</Tooltip> </Tooltip>
); );
case 'members': case FinanceTableColumnKeys.MEMBERS:
return task.members && ( return task.members && (
<Avatars <div
members={task.members.map(member => ({ onClick={(e) => {
...member, e.stopPropagation();
avatar_url: member.avatar_url || undefined 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';
}}
>
<Avatars
members={task.members.map(member => ({
...member,
avatar_url: member.avatar_url || undefined
}))}
allowClickThrough={true}
/>
</div>
); );
case 'hours': case FinanceTableColumnKeys.HOURS:
return <Typography.Text>{formatNumber(task.estimated_hours / 60)}</Typography.Text>; return <Typography.Text>{formatNumber(task.estimated_hours / 60)}</Typography.Text>;
case 'total_time_logged': case FinanceTableColumnKeys.TOTAL_TIME_LOGGED:
return <Typography.Text>{formatNumber(task.total_time_logged / 60)}</Typography.Text>; return <Typography.Text>{formatNumber(task.total_time_logged / 60)}</Typography.Text>;
case 'estimated_cost': case FinanceTableColumnKeys.ESTIMATED_COST:
return <Typography.Text>{formatNumber(task.estimated_cost)}</Typography.Text>; return <Typography.Text>{formatNumber(task.estimated_cost)}</Typography.Text>;
case 'fixedCost': case FinanceTableColumnKeys.FIXED_COST:
return selectedTask?.id === task.id ? ( return selectedTask?.id === task.id ? (
<InputNumber <InputNumber
value={task.fixed_cost} value={task.fixed_cost}
@@ -111,24 +165,37 @@ const FinanceTable = ({
handleFixedCostChange(Number(e.target.value), task.id); handleFixedCostChange(Number(e.target.value), task.id);
setSelectedTask(null); setSelectedTask(null);
}} }}
onPressEnter={(e) => {
handleFixedCostChange(Number((e.target as HTMLInputElement).value), task.id);
setSelectedTask(null);
}}
autoFocus autoFocus
style={{ width: '100%', textAlign: 'right' }} style={{ width: '100%', textAlign: 'right' }}
formatter={(value) => `${value}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',')} formatter={(value) => `${value}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',')}
parser={(value) => Number(value!.replace(/\$\s?|(,*)/g, ''))} parser={(value) => Number(value!.replace(/\$\s?|(,*)/g, ''))}
min={0} min={0}
precision={2} precision={2}
className="fixed-cost-input"
/> />
) : ( ) : (
<Typography.Text>{formatNumber(task.fixed_cost)}</Typography.Text> <Typography.Text
style={{ cursor: 'pointer', width: '100%', display: 'block' }}
onClick={(e) => {
e.stopPropagation();
setSelectedTask(task);
}}
>
{formatNumber(task.fixed_cost)}
</Typography.Text>
); );
case 'variance': case FinanceTableColumnKeys.VARIANCE:
return <Typography.Text>{formatNumber(task.variance)}</Typography.Text>; return <Typography.Text>{formatNumber(task.variance)}</Typography.Text>;
case 'totalBudget': case FinanceTableColumnKeys.TOTAL_BUDGET:
return <Typography.Text>{formatNumber(task.total_budget)}</Typography.Text>; return <Typography.Text>{formatNumber(task.total_budget)}</Typography.Text>;
case 'totalActual': case FinanceTableColumnKeys.TOTAL_ACTUAL:
return <Typography.Text>{formatNumber(task.total_actual)}</Typography.Text>; return <Typography.Text>{formatNumber(task.total_actual)}</Typography.Text>;
case 'cost': case FinanceTableColumnKeys.COST:
return <Typography.Text>{formatNumber(task.cost || 0)}</Typography.Text>; return <Typography.Text>{formatNumber(task.estimated_cost || 0)}</Typography.Text>;
default: default:
return null; return null;
} }
@@ -141,6 +208,7 @@ const FinanceTable = ({
hours: acc.hours + (task.estimated_hours / 60), hours: acc.hours + (task.estimated_hours / 60),
total_time_logged: acc.total_time_logged + (task.total_time_logged / 60), total_time_logged: acc.total_time_logged + (task.total_time_logged / 60),
estimated_cost: acc.estimated_cost + (task.estimated_cost || 0), 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_budget: acc.total_budget + (task.total_budget || 0),
total_actual: acc.total_actual + (task.total_actual || 0), total_actual: acc.total_actual + (task.total_actual || 0),
variance: acc.variance + (task.variance || 0) variance: acc.variance + (task.variance || 0)
@@ -149,6 +217,7 @@ const FinanceTable = ({
hours: 0, hours: 0,
total_time_logged: 0, total_time_logged: 0,
estimated_cost: 0, estimated_cost: 0,
fixed_cost: 0,
total_budget: 0, total_budget: 0,
total_actual: 0, total_actual: 0,
variance: 0 variance: 0
@@ -172,43 +241,33 @@ const FinanceTable = ({
}} }}
className="group" className="group"
> >
<td
colSpan={3}
style={{
width: 48,
textTransform: 'capitalize',
textAlign: 'left',
paddingInline: 16,
backgroundColor: themeWiseColor(
table.color_code,
table.color_code_dark,
themeMode
),
cursor: 'pointer',
}}
onClick={() => setIsCollapse((prev) => !prev)}
>
<Flex gap={8} align="center" style={{ color: colors.darkGray }}>
{isCollapse ? <RightOutlined /> : <DownOutlined />}
{table.group_name} ({tasks.length})
</Flex>
</td>
{financeTableColumns.map( {financeTableColumns.map(
(col) => (col, index) => (
col.key !== 'task' && <td
col.key !== 'members' && ( key={`header-${col.key}`}
<td style={{
key={`header-${col.key}`} width: col.width,
style={{ paddingInline: 16,
width: col.width, textAlign: col.key === FinanceTableColumnKeys.TASK || col.key === FinanceTableColumnKeys.MEMBERS ? 'left' : 'right',
paddingInline: 16, backgroundColor: themeWiseColor(
textAlign: 'end', table.color_code,
}} table.color_code_dark,
> themeMode
{renderFinancialTableHeaderContent(col.key)} ),
</td> cursor: col.key === FinanceTableColumnKeys.TASK ? 'pointer' : 'default',
) textTransform: col.key === FinanceTableColumnKeys.TASK ? 'capitalize' : 'none',
}}
className={customHeaderColumnStyles(col.key)}
onClick={col.key === FinanceTableColumnKeys.TASK ? () => setIsCollapse((prev) => !prev) : undefined}
>
{col.key === FinanceTableColumnKeys.TASK ? (
<Flex gap={8} align="center" style={{ color: colors.darkGray }}>
{isCollapse ? <RightOutlined /> : <DownOutlined />}
{table.group_name} ({tasks.length})
</Flex>
) : col.key === FinanceTableColumnKeys.MEMBERS ? null : renderFinancialTableHeaderContent(col.key)}
</td>
)
)} )}
</tr> </tr>
@@ -218,17 +277,12 @@ const FinanceTable = ({
key={task.id} key={task.id}
style={{ style={{
height: 40, height: 40,
background: idx % 2 === 0 ? '#232323' : '#181818', background: idx % 2 === 0 ? themeWiseColor('#fafafa', '#232323', themeMode) : themeWiseColor('#ffffff', '#181818', themeMode),
transition: 'background 0.2s', transition: 'background 0.2s',
cursor: 'pointer'
}} }}
onMouseEnter={e => e.currentTarget.style.background = '#333'} onMouseEnter={e => e.currentTarget.style.background = themeWiseColor('#f0f0f0', '#333', themeMode)}
onMouseLeave={e => e.currentTarget.style.background = idx % 2 === 0 ? '#232323' : '#181818'} onMouseLeave={e => e.currentTarget.style.background = idx % 2 === 0 ? themeWiseColor('#fafafa', '#232323', themeMode) : themeWiseColor('#ffffff', '#181818', themeMode)}
onClick={() => setSelectedTask(task)}
> >
<td style={{ width: 48, paddingInline: 16 }}>
<Checkbox />
</td>
{financeTableColumns.map((col) => ( {financeTableColumns.map((col) => (
<td <td
key={`${task.id}-${col.key}`} key={`${task.id}-${col.key}`}
@@ -236,7 +290,17 @@ const FinanceTable = ({
width: col.width, width: col.width,
paddingInline: 16, paddingInline: 16,
textAlign: col.type === 'string' ? 'left' : 'right', textAlign: col.type === 'string' ? 'left' : 'right',
backgroundColor: (col.key === FinanceTableColumnKeys.TASK || col.key === FinanceTableColumnKeys.MEMBERS) ?
(idx % 2 === 0 ? themeWiseColor('#fafafa', '#232323', themeMode) : themeWiseColor('#ffffff', '#181818', themeMode)) :
'transparent',
cursor: 'default'
}} }}
className={customColumnStyles(col.key)}
onClick={
col.key === FinanceTableColumnKeys.FIXED_COST
? (e) => e.stopPropagation()
: undefined
}
> >
{renderFinancialTableColumnContent(col.key, task)} {renderFinancialTableColumnContent(col.key, task)}
</td> </td>

View File

@@ -14,12 +14,13 @@ const ProjectViewFinance = () => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { activeTab, activeGroup, loading, taskGroups } = useAppSelector((state: RootState) => state.projectFinances); const { activeTab, activeGroup, loading, taskGroups } = useAppSelector((state: RootState) => state.projectFinances);
const { refreshTimestamp } = useAppSelector((state: RootState) => state.projectReducer);
useEffect(() => { useEffect(() => {
if (projectId) { if (projectId) {
dispatch(fetchProjectFinances({ projectId, groupBy: activeGroup })); dispatch(fetchProjectFinances({ projectId, groupBy: activeGroup }));
} }
}, [projectId, activeGroup, dispatch]); }, [projectId, activeGroup, dispatch, refreshTimestamp]);
return ( return (
<Flex vertical gap={16} style={{ overflowX: 'hidden' }}> <Flex vertical gap={16} style={{ overflowX: 'hidden' }}>

View File

@@ -22,8 +22,18 @@ import { useAppSelector } from '@/hooks/useAppSelector';
import { SocketEvents } from '@/shared/socket-events'; import { SocketEvents } from '@/shared/socket-events';
import { useAuthService } from '@/hooks/useAuth'; import { useAuthService } from '@/hooks/useAuth';
import { useSocket } from '@/socket/socketContext'; import { useSocket } from '@/socket/socketContext';
import { setProject, setImportTaskTemplateDrawerOpen, setRefreshTimestamp, getProject } from '@features/project/project.slice'; import {
import { addTask, fetchTaskGroups, fetchTaskListColumns, IGroupBy } from '@features/tasks/tasks.slice'; 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 ProjectStatusIcon from '@/components/common/project-status-icon/project-status-icon';
import { formatDate } from '@/utils/timeUtils'; import { formatDate } from '@/utils/timeUtils';
import { toggleSaveAsTemplateDrawer } from '@/features/projects/projectsSlice'; import { toggleSaveAsTemplateDrawer } from '@/features/projects/projectsSlice';
@@ -60,10 +70,7 @@ const ProjectViewHeader = () => {
const { socket } = useSocket(); const { socket } = useSocket();
const { const { project: selectedProject, projectId } = useAppSelector(state => state.projectReducer);
project: selectedProject,
projectId,
} = useAppSelector(state => state.projectReducer);
const { loadingGroups, groupBy } = useAppSelector(state => state.taskReducer); const { loadingGroups, groupBy } = useAppSelector(state => state.taskReducer);
const [creatingTask, setCreatingTask] = useState(false); const [creatingTask, setCreatingTask] = useState(false);
@@ -74,7 +81,7 @@ const ProjectViewHeader = () => {
switch (tab) { switch (tab) {
case 'tasks-list': case 'tasks-list':
dispatch(fetchTaskListColumns(projectId)); dispatch(fetchTaskListColumns(projectId));
dispatch(fetchPhasesByProjectId(projectId)) dispatch(fetchPhasesByProjectId(projectId));
dispatch(fetchTaskGroups(projectId)); dispatch(fetchTaskGroups(projectId));
break; break;
case 'board': case 'board':
@@ -92,6 +99,9 @@ const ProjectViewHeader = () => {
case 'updates': case 'updates':
dispatch(setRefreshTimestamp()); dispatch(setRefreshTimestamp());
break; break;
case 'finance':
dispatch(setRefreshTimestamp());
break;
default: default:
break; break;
} }
@@ -222,7 +232,7 @@ const ProjectViewHeader = () => {
/> />
</Tooltip> </Tooltip>
{(isOwnerOrAdmin) && ( {isOwnerOrAdmin && (
<Tooltip title="Save as template"> <Tooltip title="Save as template">
<Button <Button
shape="circle" shape="circle"
@@ -299,10 +309,9 @@ const ProjectViewHeader = () => {
style={{ paddingInline: 0, marginBlockEnd: 12 }} style={{ paddingInline: 0, marginBlockEnd: 12 }}
extra={renderHeaderActions()} extra={renderHeaderActions()}
/> />
{createPortal(<ProjectDrawer onClose={() => { }} />, document.body, 'project-drawer')} {createPortal(<ProjectDrawer onClose={() => {}} />, document.body, 'project-drawer')}
{createPortal(<ImportTaskTemplate />, document.body, 'import-task-template')} {createPortal(<ImportTaskTemplate />, document.body, 'import-task-template')}
{createPortal(<SaveProjectAsTemplate />, document.body, 'save-project-as-template')} {createPortal(<SaveProjectAsTemplate />, document.body, 'save-project-as-template')}
</> </>
); );
}; };

View File

@@ -48,7 +48,16 @@ const MembersTimeSheet = forwardRef<MembersTimeSheetRef>((_, ref) => {
const loggedTimeInHours = parseFloat(item.logged_time || '0') / 3600; const loggedTimeInHours = parseFloat(item.logged_time || '0') / 3600;
return loggedTimeInHours.toFixed(2); return loggedTimeInHours.toFixed(2);
}) : []; }) : [];
const colors = Array.isArray(jsonData) ? jsonData.map(item => item.color_code) : []; const colors = Array.isArray(jsonData) ? jsonData.map(item => {
const overUnder = parseFloat(item.over_under_utilized_hours || '0');
if (overUnder > 0) {
return '#ef4444'; // Red for over-utilized
} else if (overUnder < 0) {
return '#22c55e'; // Green for under-utilized
} else {
return '#6b7280'; // Gray for exactly on target
}
}) : [];
const themeMode = useAppSelector(state => state.themeReducer.mode); const themeMode = useAppSelector(state => state.themeReducer.mode);

View File

@@ -185,6 +185,7 @@ export const WORKLENZ_REDIRECT_PROJ_KEY = 'worklenz.redirect_proj';
export const PROJECT_SORT_FIELD = 'worklenz.projects.sort_field'; export const PROJECT_SORT_FIELD = 'worklenz.projects.sort_field';
export const PROJECT_SORT_ORDER = 'worklenz.projects.sort_order'; export const PROJECT_SORT_ORDER = 'worklenz.projects.sort_order';
export const PROJECT_LIST_COLUMNS = 'worklenz.reporting.projects.column_list'; export const PROJECT_LIST_COLUMNS = 'worklenz.reporting.projects.column_list';
export const LICENSE_ALERT_KEY = 'worklenz.licensing_close';
export const PROJECT_STATUS_ICON_MAP = { export const PROJECT_STATUS_ICON_MAP = {
'check-circle': CheckCircleOutlined, 'check-circle': CheckCircleOutlined,

View File

@@ -28,4 +28,5 @@ export interface ILocalSession extends IUserType {
subscription_status?: string; subscription_status?: string;
subscription_type?: string; subscription_type?: string;
trial_expire_date?: string; trial_expire_date?: string;
valid_till_date?: string;
} }

View File

@@ -17,8 +17,13 @@ export interface IProjectFinanceMember {
avatar_url: string | null; avatar_url: string | null;
user_id: string; user_id: string;
email: string; email: string;
socket_id: string; socket_id: string | null;
team_id: string; team_id: string;
color_code: string;
project_rate_card_role_id: string | null;
rate: number;
job_title_id: string | null;
job_title_name: string | null;
} }
export interface IProjectFinanceTask { export interface IProjectFinanceTask {
@@ -29,11 +34,10 @@ export interface IProjectFinanceTask {
estimated_cost: number; estimated_cost: number;
members: IProjectFinanceMember[]; members: IProjectFinanceMember[];
billable: boolean; billable: boolean;
fixed_cost?: number; fixed_cost: number;
variance?: number; variance: number;
total_budget?: number; total_budget: number;
total_actual?: number; total_actual: number;
cost?: number;
} }
export interface IProjectFinanceGroup { export interface IProjectFinanceGroup {
@@ -44,4 +48,57 @@ export interface IProjectFinanceGroup {
tasks: IProjectFinanceTask[]; tasks: IProjectFinanceTask[];
} }
export interface IProjectRateCard {
id: string;
project_id: string;
job_title_id: string;
rate: string;
job_title_name: string;
}
export interface IProjectFinanceResponse {
groups: IProjectFinanceGroup[];
project_rate_cards: IProjectRateCard[];
}
export interface ITaskBreakdownMember {
team_member_id: string;
name: string;
avatar_url: string;
hourly_rate: number;
estimated_hours: number;
logged_hours: number;
estimated_cost: number;
actual_cost: number;
}
export interface ITaskBreakdownJobRole {
jobRole: string;
estimated_hours: number;
logged_hours: number;
estimated_cost: number;
actual_cost: number;
members: ITaskBreakdownMember[];
}
export interface ITaskBreakdownTask {
id: string;
name: string;
project_id: string;
billable: boolean;
estimated_hours: number;
logged_hours: number;
estimated_labor_cost: number;
actual_labor_cost: number;
fixed_cost: number;
total_estimated_cost: number;
total_actual_cost: number;
}
export interface ITaskBreakdownResponse {
task: ITaskBreakdownTask;
grouped_members: ITaskBreakdownJobRole[];
members: Array<ITaskBreakdownMember & { job_title_name: string }>;
}
export type ProjectFinanceGroupType = 'status' | 'priority' | 'phases'; export type ProjectFinanceGroupType = 'status' | 'priority' | 'phases';