Merge branch 'Worklenz:feature/project-finance' into feature/project-finance

This commit is contained in:
Tharindu Kosgahakumbura
2025-05-27 08:51:31 +05:30
committed by GitHub
33 changed files with 1915 additions and 735 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

@@ -6372,3 +6372,44 @@ BEGIN
); );
END; END;
$$; $$;
CREATE OR REPLACE VIEW project_finance_view AS
SELECT
t.id,
t.name,
t.total_minutes / 3600.0 as estimated_hours,
COALESCE((SELECT SUM(time_spent) FROM task_work_log WHERE task_id = t.id), 0) / 3600.0 as total_time_logged,
COALESCE((SELECT SUM(rate * (time_spent / 3600.0))
FROM task_work_log twl
LEFT JOIN users u ON twl.user_id = u.id
LEFT JOIN team_members tm ON u.id = tm.user_id
LEFT JOIN project_members pm ON tm.id = pm.team_member_id
LEFT JOIN finance_project_rate_card_roles pmr ON pm.project_rate_card_role_id = pmr.id
WHERE twl.task_id = t.id), 0) as estimated_cost,
0 as fixed_cost, -- Default to 0 since the column doesn't exist
COALESCE(t.total_minutes / 3600.0 *
(SELECT rate FROM finance_project_rate_card_roles
WHERE project_id = t.project_id
AND id = (SELECT project_rate_card_role_id FROM project_members WHERE team_member_id = t.reporter_id LIMIT 1)
LIMIT 1), 0) as total_budgeted_cost,
COALESCE((SELECT SUM(rate * (time_spent / 3600.0))
FROM task_work_log twl
LEFT JOIN users u ON twl.user_id = u.id
LEFT JOIN team_members tm ON u.id = tm.user_id
LEFT JOIN project_members pm ON tm.id = pm.team_member_id
LEFT JOIN finance_project_rate_card_roles pmr ON pm.project_rate_card_role_id = pmr.id
WHERE twl.task_id = t.id), 0) as total_actual_cost,
COALESCE((SELECT SUM(rate * (time_spent / 3600.0))
FROM task_work_log twl
LEFT JOIN users u ON twl.user_id = u.id
LEFT JOIN team_members tm ON u.id = tm.user_id
LEFT JOIN project_members pm ON tm.id = pm.team_member_id
LEFT JOIN finance_project_rate_card_roles pmr ON pm.project_rate_card_role_id = pmr.id
WHERE twl.task_id = t.id), 0) -
COALESCE(t.total_minutes / 3600.0 *
(SELECT rate FROM finance_project_rate_card_roles
WHERE project_id = t.project_id
AND id = (SELECT project_rate_card_role_id FROM project_members WHERE team_member_id = t.reporter_id LIMIT 1)
LIMIT 1), 0) as variance,
t.project_id
FROM tasks t;

View File

@@ -5,6 +5,8 @@ import db from "../config/db";
import { ServerResponse } from "../models/server-response"; import { ServerResponse } from "../models/server-response";
import WorklenzControllerBase from "./worklenz-controller-base"; import WorklenzControllerBase from "./worklenz-controller-base";
import HandleExceptions from "../decorators/handle-exceptions"; import HandleExceptions from "../decorators/handle-exceptions";
import { TASK_STATUS_COLOR_ALPHA } from "../shared/constants";
import { getColor } from "../shared/utils";
export default class ProjectfinanceController extends WorklenzControllerBase { export default class ProjectfinanceController extends WorklenzControllerBase {
@HandleExceptions() @HandleExceptions()
@@ -12,124 +14,382 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
req: IWorkLenzRequest, req: IWorkLenzRequest,
res: IWorkLenzResponse res: IWorkLenzResponse
): Promise<IWorkLenzResponse> { ): Promise<IWorkLenzResponse> {
const { project_id } = req.params; const projectId = req.params.project_id;
const { group_by = "status" } = req.query; const groupBy = req.query.group || "status";
// First, get the project rate cards for this project
const rateCardQuery = `
SELECT
fprr.id,
fprr.project_id,
fprr.job_title_id,
fprr.rate,
jt.name as job_title_name
FROM finance_project_rate_card_roles fprr
LEFT JOIN job_titles jt ON fprr.job_title_id = jt.id
WHERE fprr.project_id = $1
ORDER BY jt.name;
`;
const rateCardResult = await db.query(rateCardQuery, [projectId]);
const projectRateCards = rateCardResult.rows;
// Get all tasks with their financial data - using project_members.project_rate_card_role_id
const q = ` const q = `
WITH task_data AS ( WITH task_costs AS (
SELECT SELECT
t.id, t.id,
t.name, t.name,
COALESCE(t.total_minutes, 0)::float as estimated_hours,
COALESCE((SELECT SUM(time_spent) FROM task_work_log WHERE task_id = t.id), 0) / 3600.0::float as total_time_logged,
t.project_id,
t.status_id, t.status_id,
t.priority_id, t.priority_id,
tp.phase_id, (SELECT phase_id FROM task_phase WHERE task_id = t.id) as phase_id,
(t.total_minutes / 3600.0) as estimated_hours,
(COALESCE((SELECT SUM(time_spent) FROM task_work_log WHERE task_id = t.id), 0) / 3600.0) as actual_hours,
t.completed_at,
t.created_at,
t.updated_at,
t.billable,
s.name as status_name,
p.name as priority_name,
ph.name as phase_name,
(SELECT color_code FROM sys_task_status_categories WHERE id = s.category_id) as status_color,
(SELECT color_code_dark FROM sys_task_status_categories WHERE id = s.category_id) as status_color_dark,
(SELECT color_code FROM task_priorities WHERE id = t.priority_id) as priority_color,
(SELECT color_code FROM project_phases WHERE id = tp.phase_id) as phase_color,
(SELECT get_task_assignees(t.id)) as assignees, (SELECT get_task_assignees(t.id)) as assignees,
json_agg( t.billable,
json_build_object( COALESCE(t.fixed_cost, 0) as fixed_cost
'name', u.name,
'avatar_url', u.avatar_url,
'team_member_id', tm.id,
'color_code', '#1890ff'
)
) FILTER (WHERE u.id IS NOT NULL) as members
FROM tasks t FROM tasks t
LEFT JOIN task_statuses s ON t.status_id = s.id WHERE t.project_id = $1 AND t.archived = false
LEFT JOIN task_priorities p ON t.priority_id = p.id ),
LEFT JOIN task_phase tp ON t.id = tp.task_id task_estimated_costs AS (
LEFT JOIN project_phases ph ON tp.phase_id = ph.id SELECT
LEFT JOIN tasks_assignees ta ON t.id = ta.task_id tc.*,
LEFT JOIN project_members pm ON ta.project_member_id = pm.id -- Calculate estimated cost based on estimated hours and assignee rates from project_members
LEFT JOIN team_members tm ON pm.team_member_id = tm.id COALESCE((
LEFT JOIN finance_project_rate_card_roles pmr ON pm.project_rate_card_role_id = pmr.id SELECT SUM(tc.estimated_hours * COALESCE(fprr.rate, 0))
LEFT JOIN users u ON tm.user_id = u.id FROM json_array_elements(tc.assignees) AS assignee_json
LEFT JOIN job_titles jt ON tm.job_title_id = jt.id LEFT JOIN project_members pm ON pm.team_member_id = (assignee_json->>'team_member_id')::uuid
WHERE t.project_id = $1 AND pm.project_id = tc.project_id
GROUP BY LEFT JOIN finance_project_rate_card_roles fprr ON fprr.id = pm.project_rate_card_role_id
t.id, WHERE assignee_json->>'team_member_id' IS NOT NULL
s.name, ), 0) as estimated_cost,
p.name, -- Calculate actual cost based on time logged and assignee rates from project_members
ph.name, COALESCE((
tp.phase_id, SELECT SUM(
s.category_id, COALESCE(fprr.rate, 0) * (twl.time_spent / 3600.0)
t.priority_id )
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
CASE tec.*,
WHEN $2 = 'status' THEN status_id (tec.estimated_cost + tec.fixed_cost) as total_budget,
WHEN $2 = 'priority' THEN priority_id (tec.actual_cost_from_logs + tec.fixed_cost) as total_actual,
WHEN $2 = 'phases' THEN phase_id ((tec.actual_cost_from_logs + tec.fixed_cost) - (tec.estimated_cost + tec.fixed_cost)) as variance
END as group_id, FROM task_estimated_costs tec;
CASE
WHEN $2 = 'status' THEN status_name
WHEN $2 = 'priority' THEN priority_name
WHEN $2 = 'phases' THEN phase_name
END as group_name,
CASE
WHEN $2 = 'status' THEN status_color
WHEN $2 = 'priority' THEN priority_color
WHEN $2 = 'phases' THEN phase_color
END as color_code,
CASE
WHEN $2 = 'status' THEN status_color_dark
WHEN $2 = 'priority' THEN priority_color
WHEN $2 = 'phases' THEN phase_color
END as color_code_dark,
json_agg(
json_build_object(
'id', id,
'name', name,
'status_id', status_id,
'priority_id', priority_id,
'phase_id', phase_id,
'estimated_hours', estimated_hours,
'actual_hours', actual_hours,
'completed_at', completed_at,
'created_at', created_at,
'updated_at', updated_at,
'billable', billable,
'assignees', assignees,
'members', members
)
) as tasks
FROM task_data
GROUP BY
CASE
WHEN $2 = 'status' THEN status_id
WHEN $2 = 'priority' THEN priority_id
WHEN $2 = 'phases' THEN phase_id
END,
CASE
WHEN $2 = 'status' THEN status_name
WHEN $2 = 'priority' THEN priority_name
WHEN $2 = 'phases' THEN phase_name
END,
CASE
WHEN $2 = 'status' THEN status_color
WHEN $2 = 'priority' THEN priority_color
WHEN $2 = 'phases' THEN phase_color
END,
CASE
WHEN $2 = 'status' THEN status_color_dark
WHEN $2 = 'priority' THEN priority_color
WHEN $2 = 'phases' THEN phase_color
END
ORDER BY group_name;
`; `;
const result = await db.query(q, [project_id, group_by]); const result = await db.query(q, [projectId]);
return res.status(200).send(new ServerResponse(true, result.rows)); const tasks = result.rows;
// Add color_code to each assignee and include their rate information using project_members
for (const task of tasks) {
if (Array.isArray(task.assignees)) {
for (const assignee of task.assignees) {
assignee.color_code = getColor(assignee.name);
// Get the rate for this assignee using project_members.project_rate_card_role_id
const memberRateQuery = `
SELECT
pm.project_rate_card_role_id,
fprr.rate,
fprr.job_title_id,
jt.name as job_title_name
FROM project_members pm
LEFT JOIN finance_project_rate_card_roles fprr ON fprr.id = pm.project_rate_card_role_id
LEFT JOIN job_titles jt ON fprr.job_title_id = jt.id
WHERE pm.team_member_id = $1 AND pm.project_id = $2
`;
try {
const memberRateResult = await db.query(memberRateQuery, [assignee.team_member_id, projectId]);
if (memberRateResult.rows.length > 0) {
const memberRate = memberRateResult.rows[0];
assignee.project_rate_card_role_id = memberRate.project_rate_card_role_id;
assignee.rate = memberRate.rate ? Number(memberRate.rate) : 0;
assignee.job_title_id = memberRate.job_title_id;
assignee.job_title_name = memberRate.job_title_name;
} else {
// Member doesn't have a rate card role assigned
assignee.project_rate_card_role_id = null;
assignee.rate = 0;
assignee.job_title_id = null;
assignee.job_title_name = null;
}
} catch (error) {
console.error("Error fetching member rate from project_members:", error);
assignee.project_rate_card_role_id = null;
assignee.rate = 0;
assignee.job_title_id = null;
assignee.job_title_name = null;
}
}
}
}
// Get groups based on groupBy parameter
let groups: Array<{ id: string; group_name: string; color_code: string; color_code_dark: string }> = [];
if (groupBy === "status") {
const q = `
SELECT
ts.id,
ts.name as group_name,
stsc.color_code::text,
stsc.color_code_dark::text
FROM task_statuses ts
INNER JOIN sys_task_status_categories stsc ON ts.category_id = stsc.id
WHERE ts.project_id = $1
ORDER BY ts.sort_order;
`;
groups = (await db.query(q, [projectId])).rows;
} else if (groupBy === "priority") {
const q = `
SELECT
id,
name as group_name,
color_code::text,
color_code_dark::text
FROM task_priorities
ORDER BY value;
`;
groups = (await db.query(q)).rows;
} else if (groupBy === "phases") {
const q = `
SELECT
id,
name as group_name,
color_code::text,
color_code::text as color_code_dark
FROM project_phases
WHERE project_id = $1
ORDER BY sort_index;
`;
groups = (await db.query(q, [projectId])).rows;
// Add TASK_STATUS_COLOR_ALPHA to color codes
for (const group of groups) {
group.color_code = group.color_code + TASK_STATUS_COLOR_ALPHA;
group.color_code_dark = group.color_code_dark + TASK_STATUS_COLOR_ALPHA;
}
}
// Group tasks by the selected criteria
const groupedTasks = groups.map(group => {
const groupTasks = tasks.filter(task => {
if (groupBy === "status") return task.status_id === group.id;
if (groupBy === "priority") return task.priority_id === group.id;
if (groupBy === "phases") return task.phase_id === group.id;
return false;
});
return {
group_id: group.id,
group_name: group.group_name,
color_code: group.color_code,
color_code_dark: group.color_code_dark,
tasks: groupTasks.map(task => ({
id: task.id,
name: task.name,
estimated_hours: Number(task.estimated_hours) || 0,
total_time_logged: Number(task.total_time_logged) || 0,
estimated_cost: Number(task.estimated_cost) || 0,
fixed_cost: Number(task.fixed_cost) || 0,
total_budget: Number(task.total_budget) || 0,
total_actual: Number(task.total_actual) || 0,
variance: Number(task.variance) || 0,
members: task.assignees,
billable: task.billable
}))
};
});
// Include project rate cards in the response for reference
const responseData = {
groups: groupedTasks,
project_rate_cards: projectRateCards
};
return res.status(200).send(new ServerResponse(true, responseData));
}
@HandleExceptions()
public static async updateTaskFixedCost(
req: IWorkLenzRequest,
res: IWorkLenzResponse
): Promise<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,13 +11,16 @@
"taskColumn": "Task", "taskColumn": "Task",
"membersColumn": "Members", "membersColumn": "Members",
"hoursColumn": "Hours", "hoursColumn": "Estimated Hours",
"totalTimeLoggedColumn": "Total Time Logged",
"costColumn": "Cost", "costColumn": "Cost",
"estimatedCostColumn": "Estimated Cost",
"fixedCostColumn": "Fixed Cost", "fixedCostColumn": "Fixed Cost",
"totalBudgetedCostColumn": "Total Budgeted Cost", "totalBudgetedCostColumn": "Total Budgeted Cost",
"totalActualCostColumn": "Total Actual Cost", "totalActualCostColumn": "Total Actual Cost",
"varianceColumn": "Variance", "varianceColumn": "Variance",
"totalText": "Total", "totalText": "Total",
"noTasksFound": "No tasks found",
"addRoleButton": "+ Add Role", "addRoleButton": "+ Add Role",
"ratecardImportantNotice": "* This rate card is generated based on the company's standard job titles and rates. However, you have the flexibility to modify it according to the project. These changes will not impact the organization's standard job titles and rates.", "ratecardImportantNotice": "* This rate card is generated based on the company's standard job titles and rates. However, you have the flexibility to modify it according to the project. These changes will not impact the organization's standard job titles and rates.",

View File

@@ -11,13 +11,16 @@
"taskColumn": "Tarea", "taskColumn": "Tarea",
"membersColumn": "Miembros", "membersColumn": "Miembros",
"hoursColumn": "Horas", "hoursColumn": "Horas Estimadas",
"totalTimeLoggedColumn": "Tiempo Total Registrado",
"costColumn": "Costo", "costColumn": "Costo",
"estimatedCostColumn": "Costo Estimado",
"fixedCostColumn": "Costo Fijo", "fixedCostColumn": "Costo Fijo",
"totalBudgetedCostColumn": "Costo Total Presupuestado", "totalBudgetedCostColumn": "Costo Total Presupuestado",
"totalActualCostColumn": "Costo Total Real", "totalActualCostColumn": "Costo Total Real",
"varianceColumn": "Diferencia", "varianceColumn": "Diferencia",
"totalText": "Total", "totalText": "Total",
"noTasksFound": "No se encontraron tareas",
"addRoleButton": "+ Agregar Rol", "addRoleButton": "+ Agregar Rol",
"ratecardImportantNotice": "* Esta tarifa se genera en función de los títulos de trabajo y tarifas estándar de la empresa. Sin embargo, tienes la flexibilidad de modificarla según el proyecto. Estos cambios no afectarán los títulos de trabajo y tarifas estándar de la organización.", "ratecardImportantNotice": "* Esta tarifa se genera en función de los títulos de trabajo y tarifas estándar de la empresa. Sin embargo, tienes la flexibilidad de modificarla según el proyecto. Estos cambios no afectarán los títulos de trabajo y tarifas estándar de la organización.",

View File

@@ -11,13 +11,16 @@
"taskColumn": "Tarefa", "taskColumn": "Tarefa",
"membersColumn": "Membros", "membersColumn": "Membros",
"hoursColumn": "Horas", "hoursColumn": "Horas Estimadas",
"totalTimeLoggedColumn": "Tempo Total Registrado",
"costColumn": "Custo", "costColumn": "Custo",
"estimatedCostColumn": "Custo Estimado",
"fixedCostColumn": "Custo Fixo", "fixedCostColumn": "Custo Fixo",
"totalBudgetedCostColumn": "Custo Total Orçado", "totalBudgetedCostColumn": "Custo Total Orçado",
"totalActualCostColumn": "Custo Total Real", "totalActualCostColumn": "Custo Total Real",
"varianceColumn": "Variação", "varianceColumn": "Variação",
"totalText": "Total", "totalText": "Total",
"noTasksFound": "Nenhuma tarefa encontrada",
"addRoleButton": "+ Adicionar Função", "addRoleButton": "+ Adicionar Função",
"ratecardImportantNotice": "* Esta tabela de taxas é gerada com base nos cargos e taxas padrão da empresa. No entanto, você tem a flexibilidade de modificá-la de acordo com o projeto. Essas alterações não impactarão os cargos e taxas padrão da organização.", "ratecardImportantNotice": "* Esta tabela de taxas é gerada com base nos cargos e taxas padrão da empresa. No entanto, você tem a flexibilidade de modificá-la de acordo com o projeto. Essas alterações não impactarão os cargos e taxas padrão da organização.",

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,13 +9,34 @@ 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 }
} }
); );
console.log(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 (
taskId: string,
fixedCost: number
): Promise<IServerResponse<any>> => {
const response = await apiClient.put<IServerResponse<any>>(
`${rootUrl}/task/${taskId}/fixed-cost`,
{ fixed_cost: fixedCost }
);
return response.data; return response.data;
}, },
} }

View File

@@ -73,6 +73,7 @@ import financeReducer from '../features/finance/finance-slice';
import roadmapReducer from '../features/roadmap/roadmap-slice'; import roadmapReducer from '../features/roadmap/roadmap-slice';
import teamMembersReducer from '@features/team-members/team-members.slice'; import teamMembersReducer from '@features/team-members/team-members.slice';
import projectFinanceRateCardReducer from '../features/finance/project-finance-slice'; import projectFinanceRateCardReducer from '../features/finance/project-finance-slice';
import projectFinancesReducer from '../features/projects/finance/project-finance.slice';
import groupByFilterDropdownReducer from '../features/group-by-filter-dropdown/group-by-filter-dropdown-slice'; import groupByFilterDropdownReducer from '../features/group-by-filter-dropdown/group-by-filter-dropdown-slice';
import homePageApiService from '@/api/home-page/home-page.api.service'; import homePageApiService from '@/api/home-page/home-page.api.service';
import { projectsApi } from '@/api/projects/projects.v1.api.service'; import { projectsApi } from '@/api/projects/projects.v1.api.service';
@@ -158,6 +159,7 @@ export const store = configureStore({
timeReportsOverviewReducer: timeReportsOverviewReducer, timeReportsOverviewReducer: timeReportsOverviewReducer,
financeReducer: financeReducer, financeReducer: financeReducer,
projectFinanceRateCard: projectFinanceRateCardReducer, projectFinanceRateCard: projectFinanceRateCardReducer,
projectFinances: projectFinancesReducer,
}, },
}); });

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,6 +66,11 @@ const FinanceDrawer = ({ task }: { task: any }) => {
width={480} width={480}
> >
<div> <div>
{loading ? (
<div style={{ textAlign: 'center', padding: '20px' }}>
<Spin size="large" />
</div>
) : (
<table <table
style={{ style={{
width: '100%', width: '100%',
@@ -115,10 +114,8 @@ const FinanceDrawer = ({ task }: { task: any }) => {
</tr> </tr>
</thead> </thead>
<div className="mb-4"></div>
<tbody> <tbody>
{Object.values(groupedMembers).map((group: any) => ( {taskBreakdown?.grouped_members?.map((group: any) => (
<React.Fragment key={group.jobRole}> <React.Fragment key={group.jobRole}>
{/* Group Header */} {/* Group Header */}
<tr <tr
@@ -139,7 +136,7 @@ const FinanceDrawer = ({ task }: { task: any }) => {
padding: 8, padding: 8,
}} }}
> >
{group.laborHours} {group.estimated_hours?.toFixed(2) || '0.00'}
</td> </td>
<td <td
style={{ style={{
@@ -147,11 +144,11 @@ const FinanceDrawer = ({ task }: { task: any }) => {
padding: 8, padding: 8,
}} }}
> >
{group.cost} {group.estimated_cost?.toFixed(2) || '0.00'}
</td> </td>
</tr> </tr>
{/* Member Rows */} {/* Member Rows */}
{group.members.map((member: any, index: number) => ( {group.members?.map((member: any, index: number) => (
<tr <tr
key={`${group.jobRole}-${index}`} key={`${group.jobRole}-${index}`}
className="border-b-[1px]" className="border-b-[1px]"
@@ -171,7 +168,7 @@ const FinanceDrawer = ({ task }: { task: any }) => {
padding: 8, padding: 8,
}} }}
> >
{member.laborHours} {member.estimated_hours?.toFixed(2) || '0.00'}
</td> </td>
<td <td
style={{ style={{
@@ -179,7 +176,7 @@ const FinanceDrawer = ({ task }: { task: any }) => {
padding: 8, padding: 8,
}} }}
> >
{member.cost} {member.estimated_cost?.toFixed(2) || '0.00'}
</td> </td>
</tr> </tr>
))} ))}
@@ -187,6 +184,7 @@ const FinanceDrawer = ({ task }: { task: any }) => {
))} ))}
</tbody> </tbody>
</table> </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

@@ -0,0 +1,185 @@
import { projectFinanceApiService } from '@/api/project-finance-ratecard/project-finance.api.service';
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
import { IProjectFinanceGroup, IProjectFinanceTask, IProjectRateCard } from '@/types/project/project-finance.types';
type FinanceTabType = 'finance' | 'ratecard';
type GroupTypes = 'status' | 'priority' | 'phases';
interface ProjectFinanceState {
activeTab: FinanceTabType;
activeGroup: GroupTypes;
loading: boolean;
taskGroups: IProjectFinanceGroup[];
projectRateCards: IProjectRateCard[];
}
// Utility functions for frontend calculations
const minutesToHours = (minutes: number) => minutes / 60;
const calculateTaskCosts = (task: IProjectFinanceTask) => {
const hours = minutesToHours(task.estimated_hours || 0);
const timeLoggedHours = minutesToHours(task.total_time_logged || 0);
const fixedCost = task.fixed_cost || 0;
// Calculate total budget (estimated hours * rate + fixed cost)
const totalBudget = task.estimated_cost + fixedCost;
// Calculate total actual (time logged * rate + fixed cost)
const totalActual = task.total_actual || 0;
// Calculate variance (total actual - total budget)
const variance = totalActual - totalBudget;
return {
hours,
timeLoggedHours,
totalBudget,
totalActual,
variance
};
};
const calculateGroupTotals = (tasks: IProjectFinanceTask[]) => {
return tasks.reduce(
(acc, task) => {
const { hours, timeLoggedHours, totalBudget, totalActual, variance } = calculateTaskCosts(task);
return {
hours: acc.hours + hours,
total_time_logged: acc.total_time_logged + timeLoggedHours,
estimated_cost: acc.estimated_cost + (task.estimated_cost || 0),
total_budget: acc.total_budget + totalBudget,
total_actual: acc.total_actual + totalActual,
variance: acc.variance + variance
};
},
{
hours: 0,
total_time_logged: 0,
estimated_cost: 0,
total_budget: 0,
total_actual: 0,
variance: 0
}
);
};
const initialState: ProjectFinanceState = {
activeTab: 'finance',
activeGroup: 'status',
loading: false,
taskGroups: [],
projectRateCards: [],
};
export const fetchProjectFinances = createAsyncThunk(
'projectFinances/fetchProjectFinances',
async ({ projectId, groupBy }: { projectId: string; groupBy: GroupTypes }) => {
const response = await projectFinanceApiService.getProjectTasks(projectId, groupBy);
return response.body;
}
);
export const updateTaskFixedCostAsync = createAsyncThunk(
'projectFinances/updateTaskFixedCostAsync',
async ({ taskId, groupId, fixedCost }: { taskId: string; groupId: string; fixedCost: number }) => {
await projectFinanceApiService.updateTaskFixedCost(taskId, fixedCost);
return { taskId, groupId, fixedCost };
}
);
export const projectFinancesSlice = createSlice({
name: 'projectFinances',
initialState,
reducers: {
setActiveTab: (state, action: PayloadAction<FinanceTabType>) => {
state.activeTab = action.payload;
},
setActiveGroup: (state, action: PayloadAction<GroupTypes>) => {
state.activeGroup = action.payload;
},
updateTaskFixedCost: (state, action: PayloadAction<{ taskId: string; groupId: string; fixedCost: number }>) => {
const { taskId, groupId, fixedCost } = action.payload;
const group = state.taskGroups.find(g => g.group_id === groupId);
if (group) {
const task = group.tasks.find(t => t.id === taskId);
if (task) {
task.fixed_cost = fixedCost;
// Recalculate task costs after updating fixed cost
const { totalBudget, totalActual, variance } = calculateTaskCosts(task);
task.total_budget = totalBudget;
task.total_actual = totalActual;
task.variance = variance;
}
}
},
updateTaskEstimatedCost: (state, action: PayloadAction<{ taskId: string; groupId: string; estimatedCost: number }>) => {
const { taskId, groupId, estimatedCost } = action.payload;
const group = state.taskGroups.find(g => g.group_id === groupId);
if (group) {
const task = group.tasks.find(t => t.id === taskId);
if (task) {
task.estimated_cost = estimatedCost;
// Recalculate task costs after updating estimated cost
const { totalBudget, totalActual, variance } = calculateTaskCosts(task);
task.total_budget = totalBudget;
task.total_actual = totalActual;
task.variance = variance;
}
}
},
updateTaskTimeLogged: (state, action: PayloadAction<{ taskId: string; groupId: string; timeLogged: number }>) => {
const { taskId, groupId, timeLogged } = action.payload;
const group = state.taskGroups.find(g => g.group_id === groupId);
if (group) {
const task = group.tasks.find(t => t.id === taskId);
if (task) {
task.total_time_logged = timeLogged;
// Recalculate task costs after updating time logged
const { totalBudget, totalActual, variance } = calculateTaskCosts(task);
task.total_budget = totalBudget;
task.total_actual = totalActual;
task.variance = variance;
}
}
}
},
extraReducers: (builder) => {
builder
.addCase(fetchProjectFinances.pending, (state) => {
state.loading = true;
})
.addCase(fetchProjectFinances.fulfilled, (state, action) => {
state.loading = false;
state.taskGroups = action.payload.groups;
state.projectRateCards = action.payload.project_rate_cards;
})
.addCase(fetchProjectFinances.rejected, (state) => {
state.loading = false;
})
.addCase(updateTaskFixedCostAsync.fulfilled, (state, action) => {
const { taskId, groupId, fixedCost } = action.payload;
const group = state.taskGroups.find(g => g.group_id === groupId);
if (group) {
const task = group.tasks.find(t => t.id === taskId);
if (task) {
task.fixed_cost = fixedCost;
// Recalculate task costs after updating fixed cost
const { totalBudget, totalActual, variance } = calculateTaskCosts(task);
task.total_budget = totalBudget;
task.total_actual = totalActual;
task.variance = variance;
}
}
});
},
});
export const {
setActiveTab,
setActiveGroup,
updateTaskFixedCost,
updateTaskEstimatedCost,
updateTaskTimeLogged
} = projectFinancesSlice.actions;
export default projectFinancesSlice.reducer;

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

@@ -0,0 +1,61 @@
import React from 'react';
import { Table } from 'antd';
import { useTranslation } from 'react-i18next';
import { financeTableColumns } from './project-view-finance-table-columns';
interface IFinanceTableData {
id: string;
name: string;
estimated_hours: number;
estimated_cost: number;
fixed_cost: number;
total_budgeted_cost: number;
total_actual_cost: number;
variance: number;
total_time_logged: number;
assignees: Array<{
team_member_id: string;
project_member_id: string;
name: string;
avatar_url: string;
}>;
}
interface FinanceTableWrapperProps {
data: IFinanceTableData[];
loading?: boolean;
}
const FinanceTableWrapper: React.FC<FinanceTableWrapperProps> = ({ data, loading }) => {
const { t } = useTranslation();
const columns = financeTableColumns.map(col => ({
...col,
title: t(`projectViewFinance.${col.name}`),
dataIndex: col.key,
key: col.key,
width: col.width,
render: col.render || ((value: any) => {
if (col.type === 'hours') {
return value ? value.toFixed(2) : '0.00';
}
if (col.type === 'currency') {
return value ? `$${value.toFixed(2)}` : '$0.00';
}
return value;
})
}));
return (
<Table
dataSource={data}
columns={columns}
loading={loading}
pagination={false}
rowKey="id"
scroll={{ x: 'max-content' }}
/>
);
};
export default FinanceTableWrapper;

View File

@@ -1,57 +1,83 @@
export enum FinanceTableColumnKeys {
TASK = 'task',
MEMBERS = 'members',
HOURS = 'hours',
TOTAL_TIME_LOGGED = 'total_time_logged',
ESTIMATED_COST = 'estimated_cost',
COST = 'cost',
FIXED_COST = 'fixedCost',
TOTAL_BUDGET = 'totalBudget',
TOTAL_ACTUAL = 'totalActual',
VARIANCE = 'variance',
}
type FinanceTableColumnsType = { type FinanceTableColumnsType = {
key: string; key: FinanceTableColumnKeys;
name: string; name: string;
width: number; width: number;
type: 'string' | 'hours' | 'currency'; type: 'string' | 'hours' | 'currency';
render?: (value: any) => React.ReactNode;
}; };
// finance table columns // finance table columns
export const financeTableColumns: FinanceTableColumnsType[] = [ export const financeTableColumns: FinanceTableColumnsType[] = [
{ {
key: 'task', key: FinanceTableColumnKeys.TASK,
name: 'task', name: 'taskColumn',
width: 240, width: 240,
type: 'string', type: 'string',
}, },
{ {
key: 'members', key: FinanceTableColumnKeys.MEMBERS,
name: 'members', name: 'membersColumn',
width: 160, width: 160,
type: 'string', type: 'string',
}, },
{ {
key: 'hours', key: FinanceTableColumnKeys.HOURS,
name: 'hours', name: 'hoursColumn',
width: 80, width: 100,
type: 'hours', type: 'hours',
}, },
{ {
key: 'cost', key: FinanceTableColumnKeys.TOTAL_TIME_LOGGED,
name: 'cost', name: 'totalTimeLoggedColumn',
width: 120,
type: 'hours',
},
{
key: FinanceTableColumnKeys.ESTIMATED_COST,
name: 'estimatedCostColumn',
width: 120, width: 120,
type: 'currency', type: 'currency',
}, },
{ {
key: 'fixedCost', key: FinanceTableColumnKeys.COST,
name: 'fixedCost', name: 'costColumn',
width: 120, width: 120,
type: 'currency', type: 'currency',
}, },
{ {
key: 'totalBudget', key: FinanceTableColumnKeys.FIXED_COST,
name: 'totalBudgetedCost', name: 'fixedCostColumn',
width: 120, width: 120,
type: 'currency', type: 'currency',
}, },
{ {
key: 'totalActual', key: FinanceTableColumnKeys.TOTAL_BUDGET,
name: 'totalActualCost', name: 'totalBudgetedCostColumn',
width: 120, width: 120,
type: 'currency', type: 'currency',
}, },
{ {
key: 'variance', key: FinanceTableColumnKeys.TOTAL_ACTUAL,
name: 'variance', name: 'totalActualCostColumn',
width: 120,
type: 'currency',
},
{
key: FinanceTableColumnKeys.VARIANCE,
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';
@@ -10,26 +9,28 @@ interface FinanceTabProps {
const FinanceTab = ({ const FinanceTab = ({
groupType, groupType,
taskGroups, taskGroups = [],
loading loading
}: FinanceTabProps) => { }: FinanceTabProps) => {
// Transform taskGroups into the format expected by FinanceTableWrapper // Transform taskGroups into the format expected by FinanceTableWrapper
const activeTablesList = taskGroups.map(group => ({ const activeTablesList = (taskGroups || []).map(group => ({
id: group.group_id, group_id: group.group_id,
name: group.group_name, group_name: group.group_name,
color_code: group.color_code, color_code: group.color_code,
color_code_dark: group.color_code_dark, color_code_dark: group.color_code_dark,
tasks: group.tasks.map(task => ({ tasks: (group.tasks || []).map(task => ({
taskId: task.id, id: task.id,
task: 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.actual_hours || 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,
estimated_cost: task.estimated_cost || 0
})) }))
})); }));

View File

@@ -1,9 +1,8 @@
import React from "react"; import React from "react";
import { Card, Col, Row, Spin } from "antd"; import { Card, Col, Row } from "antd";
import { useThemeContext } from "../../../../../context/theme-context";
import { FinanceTable } from "./finance-table";
import { IFinanceTable } from "./finance-table.interface";
import { IProjectFinanceGroup } from "../../../../../types/project/project-finance.types"; import { IProjectFinanceGroup } from "../../../../../types/project/project-finance.types";
import FinanceTable from "./finance-table/finance-table";
interface Props { interface Props {
activeTablesList: IProjectFinanceGroup[]; activeTablesList: IProjectFinanceGroup[];
@@ -32,7 +31,7 @@ export const FinanceTableWrapper: React.FC<Props> = ({ activeTablesList, loading
<h3>{table.group_name}</h3> <h3>{table.group_name}</h3>
</div> </div>
<FinanceTable <FinanceTable
table={table as unknown as IFinanceTable} table={table}
loading={loading} loading={loading}
/> />
</Card> </Card>

View File

@@ -1,49 +1,34 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState, useMemo } from 'react';
import { Checkbox, Flex, Typography } from 'antd'; import { Flex, InputNumber, Tooltip, Typography, Empty } 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 { 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;
}[];
}[];
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(() => {
@@ -60,10 +45,27 @@ 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 () => {
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: { acc: {
hours: number; hours: number;
@@ -72,16 +74,20 @@ const FinanceTableWrapper: React.FC<FinanceTableWrapperProps> = ({
totalBudget: number; totalBudget: number;
totalActual: number; totalActual: number;
variance: number; variance: number;
total_time_logged: number;
estimated_cost: number;
}, },
table: { tasks: any[] } table: IProjectFinanceGroup
) => { ) => {
table.tasks.forEach((task: any) => { table.tasks.forEach((task) => {
acc.hours += task.hours || 0; acc.hours += (task.estimated_hours / 60) || 0;
acc.cost += task.cost || 0; acc.cost += task.estimated_cost || 0;
acc.fixedCost += task.fixedCost || 0; acc.fixedCost += task.fixed_cost || 0;
acc.totalBudget += task.totalBudget || 0; acc.totalBudget += task.total_budget || 0;
acc.totalActual += task.totalActual || 0; acc.totalActual += task.total_actual || 0;
acc.variance += task.variance || 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; return acc;
}, },
@@ -92,42 +98,36 @@ const FinanceTableWrapper: React.FC<FinanceTableWrapperProps> = ({
totalBudget: 0, totalBudget: 0,
totalActual: 0, totalActual: 0,
variance: 0, variance: 0,
total_time_logged: 0,
estimated_cost: 0,
} }
); );
}, [taskGroups]);
const renderFinancialTableHeaderContent = (columnKey: any) => { const handleFixedCostChange = (value: number | null, taskId: string, groupId: string) => {
dispatch(updateTaskFixedCostAsync({ taskId, groupId, fixedCost: value || 0 }));
setEditingFixedCost(null);
};
const renderFinancialTableHeaderContent = (columnKey: FinanceTableColumnKeys) => {
switch (columnKey) { switch (columnKey) {
case 'hours': case FinanceTableColumnKeys.HOURS:
return ( return (
<Typography.Text style={{ fontSize: 18 }}> <Typography.Text style={{ fontSize: 18 }}>
{totals.hours} <Tooltip title={convertToHoursMinutes(totals.hours)}>
{formatHoursToReadable(totals.hours).toFixed(2)}
</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={{
@@ -135,7 +135,19 @@ const FinanceTableWrapper: React.FC<FinanceTableWrapperProps> = ({
fontSize: 18, fontSize: 18,
}} }}
> >
{totals.variance} {`${totals.variance?.toFixed(2)}`}
</Typography.Text>
);
case FinanceTableColumnKeys.TOTAL_TIME_LOGGED:
return (
<Typography.Text style={{ fontSize: 18 }}>
{totals.total_time_logged?.toFixed(2)}
</Typography.Text>
);
case FinanceTableColumnKeys.ESTIMATED_COST:
return (
<Typography.Text style={{ fontSize: 18 }}>
{`${totals.estimated_cost?.toFixed(2)}`}
</Typography.Text> </Typography.Text>
); );
default: default:
@@ -143,116 +155,103 @@ 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-[56px] after:w-1.5 after:bg-transparent after:bg-gradient-to-r after:from-[rgba(0,0,0,0.12)] after:to-transparent' : ''}`} ${key === FinanceTableColumnKeys.MEMBERS && `sticky left-[240px] z-10 ${isScrolling ? 'after:content after:absolute after:top-0 after:-right-1 after:-z-10 after:h-[56px] after:w-1.5 after:bg-transparent after:bg-gradient-to-r after:from-[rgba(0,0,0,0.12)] after:to-transparent' : ''}`} ${themeMode === 'dark' ? 'bg-[#141414]' : 'bg-[#fbfbfb]'}`;
// Check if there are any tasks across all groups
const hasAnyTasks = activeTablesList.some(table => table.tasks && table.tasks.length > 0);
return ( 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}Column`)}{' '} {t(`${col.name}`)} {col.type === 'currency' && `(${currency.toUpperCase()})`}
{col.type === 'currency' && `(${currency.toUpperCase()})`}
</Typography.Text> </Typography.Text>
</td> </td>
))} ))}
</tr> </tr>
{hasAnyTasks && (
<tr <tr
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}
style={{
paddingInline: 16,
backgroundColor: themeWiseColor(
'#fbfbfb',
'#141414',
themeMode
),
}}
className={customColumnStyles('totalRow')}
>
<Typography.Text style={{ fontSize: 18 }}>
{t('totalText')}
</Typography.Text>
</td>
{financeTableColumns.map(
(col) =>
(col.type === 'hours' || col.type === 'currency') && (
<td <td
key={col.key} key={col.key}
style={{ style={{
minWidth: col.width, minWidth: col.width,
paddingInline: 16, paddingInline: 16,
textAlign: 'end', textAlign: col.key === FinanceTableColumnKeys.TASK ? 'left' : 'right',
backgroundColor: themeWiseColor('#fbfbfb', '#141414', themeMode),
}} }}
className={customColumnStyles(col.key)}
> >
{renderFinancialTableHeaderContent(col.key)} {col.key === FinanceTableColumnKeys.TASK ? (
</td> <Typography.Text style={{ fontSize: 18 }}>{t('totalText')}</Typography.Text>
) ) : col.key === FinanceTableColumnKeys.MEMBERS ? null : (
(col.type === 'hours' || col.type === 'currency') && renderFinancialTableHeaderContent(col.key)
)} )}
</td>
))}
</tr> </tr>
)}
{activeTablesList.map((table, index) => ( {hasAnyTasks ? (
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}
/> />
))} ))
) : (
<tr>
<td colSpan={financeTableColumns.length} style={{ padding: '40px 0', textAlign: 'center' }}>
<Empty
description={
<Typography.Text type="secondary">
{t('noTasksFound')}
</Typography.Text>
}
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
</td>
</tr>
)}
</tbody> </tbody>
</table> </table>
</Flex> </Flex>
{selectedTask && <FinanceDrawer task={selectedTask} />} <FinanceDrawer />
</> </>
); );
}; };

View File

@@ -0,0 +1 @@
/* Finance Table Styles */

View File

@@ -1,195 +1,224 @@
import { Avatar, Checkbox, Flex, Input, Tooltip, Typography } from 'antd'; import { Checkbox, Flex, Input, InputNumber, Skeleton, Tooltip, Typography } from 'antd';
import React, { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import CustomAvatar from '../../../../../../components/CustomAvatar'; import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppSelector } from '../../../../../../hooks/useAppSelector';
import { import {
DollarCircleOutlined, DollarCircleOutlined,
DownOutlined, DownOutlined,
RightOutlined, RightOutlined,
} 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 { updateTaskFixedCostAsync, updateTaskFixedCost } from '@/features/projects/finance/project-finance.slice';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import './finance-table.css';
type FinanceTableProps = { type FinanceTableProps = {
table: any; table: IProjectFinanceGroup;
loading: boolean;
isScrolling: boolean; isScrolling: boolean;
onTaskClick: (task: any) => void; onTaskClick: (task: any) => void;
}; };
const FinanceTable = ({ const FinanceTable = ({
table, table,
loading,
isScrolling, isScrolling,
onTaskClick, onTaskClick,
}: FinanceTableProps) => { }: FinanceTableProps) => {
const [isCollapse, setIsCollapse] = useState<boolean>(false); const [isCollapse, setIsCollapse] = useState<boolean>(false);
const [selectedTask, setSelectedTask] = useState(null); const [selectedTask, setSelectedTask] = useState<IProjectFinanceTask | null>(null);
const [tasks, setTasks] = useState<IProjectFinanceTask[]>(table.tasks);
const dispatch = useAppDispatch();
// Get the latest task groups from Redux store
const taskGroups = useAppSelector((state) => state.projectFinances.taskGroups);
// Update local state when table.tasks or Redux store changes
useEffect(() => {
const updatedGroup = taskGroups.find(g => g.group_id === table.group_id);
if (updatedGroup) {
setTasks(updatedGroup.tasks);
} else {
setTasks(table.tasks);
}
}, [table.tasks, taskGroups, table.group_id]);
// Handle click outside to close editing
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (selectedTask && !(event.target as Element)?.closest('.fixed-cost-input')) {
setSelectedTask(null);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [selectedTask]);
// get theme data from theme reducer // get theme data from theme reducer
const themeMode = useAppSelector((state) => state.themeReducer.mode); const themeMode = useAppSelector((state) => state.themeReducer.mode);
// totals of the current table const formatNumber = (value: number | undefined | null) => {
const totals = useMemo( if (value === undefined || value === null) return '0.00';
() => ({ return value.toFixed(2);
hours: (table?.tasks || []).reduce( };
(sum: any, task: { hours: any }) => sum + task.hours,
0
),
cost: (table?.tasks || []).reduce(
(sum: any, task: { cost: any }) => sum + task.cost,
0
),
fixedCost: (table?.tasks || []).reduce(
(sum: any, task: { fixedCost: any }) => sum + task.fixedCost,
0
),
totalBudget: (table?.tasks || []).reduce(
(sum: any, task: { totalBudget: any }) => sum + task.totalBudget,
0
),
totalActual: (table?.tasks || []).reduce(
(sum: any, task: { totalActual: any }) => sum + task.totalActual,
0
),
variance: (table?.tasks || []).reduce(
(sum: any, task: { variance: any }) => sum + task.variance,
0
),
}),
[table]
);
useEffect(() => { // Custom column styles for sticky positioning
console.log('Selected Task:', selectedTask); const customColumnStyles = (key: FinanceTableColumnKeys) =>
}, [selectedTask]); `px-2 text-left ${key === FinanceTableColumnKeys.TASK && 'sticky left-0 z-10'} ${key === FinanceTableColumnKeys.MEMBERS && `sticky left-[240px] z-10 ${isScrolling ? 'after:content after:absolute after:top-0 after:-right-1 after:-z-10 after:h-[40px] after:w-1.5 after:bg-transparent after:bg-gradient-to-r after:from-[rgba(0,0,0,0.12)] after:to-transparent' : ''}`} ${themeMode === 'dark' ? 'bg-[#1d1d1d] border-[#303030]' : 'bg-white'}`;
const renderFinancialTableHeaderContent = (columnKey: any) => { const customHeaderColumnStyles = (key: FinanceTableColumnKeys) =>
`px-2 text-left ${key === FinanceTableColumnKeys.TASK && 'sticky left-0 z-10'} ${key === FinanceTableColumnKeys.MEMBERS && `sticky left-[240px] z-10 ${isScrolling ? 'after:content after:absolute after:top-0 after:-right-1 after:-z-10 after:h-[40px] after:w-1.5 after:bg-transparent after:bg-gradient-to-r after:from-[rgba(0,0,0,0.12)] after:to-transparent' : ''}`}`;
const renderFinancialTableHeaderContent = (columnKey: FinanceTableColumnKeys) => {
switch (columnKey) { switch (columnKey) {
case 'hours': case FinanceTableColumnKeys.HOURS:
return ( return <Typography.Text>{formatNumber(totals.hours)}</Typography.Text>;
<Typography.Text style={{ color: colors.darkGray }}> case FinanceTableColumnKeys.TOTAL_TIME_LOGGED:
{totals.hours} return <Typography.Text>{formatNumber(totals.total_time_logged)}</Typography.Text>;
</Typography.Text> case FinanceTableColumnKeys.ESTIMATED_COST:
); return <Typography.Text>{formatNumber(totals.estimated_cost)}</Typography.Text>;
case 'cost': case FinanceTableColumnKeys.FIXED_COST:
return ( return <Typography.Text>{formatNumber(totals.fixed_cost)}</Typography.Text>;
<Typography.Text style={{ color: colors.darkGray }}> case FinanceTableColumnKeys.TOTAL_BUDGET:
{totals.cost} return <Typography.Text>{formatNumber(totals.total_budget)}</Typography.Text>;
</Typography.Text> case FinanceTableColumnKeys.TOTAL_ACTUAL:
); return <Typography.Text>{formatNumber(totals.total_actual)}</Typography.Text>;
case 'fixedCost': case FinanceTableColumnKeys.VARIANCE:
return ( return <Typography.Text>{formatNumber(totals.variance)}</Typography.Text>;
<Typography.Text style={{ color: colors.darkGray }}>
{totals.fixedCost}
</Typography.Text>
);
case 'totalBudget':
return (
<Typography.Text style={{ color: colors.darkGray }}>
{totals.totalBudget}
</Typography.Text>
);
case 'totalActual':
return (
<Typography.Text style={{ color: colors.darkGray }}>
{totals.totalActual}
</Typography.Text>
);
case 'variance':
return (
<Typography.Text
style={{
color:
totals.variance < 0
? '#FF0000'
: themeWiseColor('#6DC376', colors.darkGray, themeMode),
}}
>
{totals.variance}
</Typography.Text>
);
default: default:
return null; return null;
} }
}; };
const renderFinancialTableColumnContent = (columnKey: any, task: any) => { const handleFixedCostChange = (value: number | null, taskId: string) => {
const fixedCost = value || 0;
// Optimistic update for immediate UI feedback
dispatch(updateTaskFixedCost({ taskId, groupId: table.group_id, fixedCost }));
// Then make the API call to persist the change
dispatch(updateTaskFixedCostAsync({ taskId, groupId: table.group_id, fixedCost }));
};
const renderFinancialTableColumnContent = (columnKey: FinanceTableColumnKeys, task: IProjectFinanceTask) => {
switch (columnKey) { switch (columnKey) {
case 'task': case FinanceTableColumnKeys.TASK:
return ( return (
<Tooltip title={task.task}> <Tooltip title={task.name}>
<Flex gap={8} align="center"> <Flex gap={8} align="center">
<Typography.Text <Typography.Text
ellipsis={{ expanded: false }} ellipsis={{ expanded: false }}
style={{ maxWidth: 160 }} style={{ maxWidth: 160 }}
> >
{task.task} {task.name}
</Typography.Text> </Typography.Text>
{task.billable && <DollarCircleOutlined />}
{task.isbBillable && <DollarCircleOutlined />}
</Flex> </Flex>
</Tooltip> </Tooltip>
); );
case 'members': case FinanceTableColumnKeys.MEMBERS:
return ( return task.members && (
task?.assignees && <Avatars members={task.assignees} /> <div
); onClick={(e) => {
case 'hours': e.stopPropagation();
return <Typography.Text>{task.hours}</Typography.Text>; onTaskClick(task);
case 'cost':
return <Typography.Text>{task.cost}</Typography.Text>;
case 'fixedCost':
return (
<Input
value={task.fixedCost}
style={{
background: 'transparent',
border: 'none',
boxShadow: 'none',
textAlign: 'right',
padding: 0,
}} }}
/>
);
case 'totalBudget':
return (
<Input
value={task.totalBudget}
style={{ style={{
background: 'transparent', cursor: 'pointer',
border: 'none', width: '100%'
boxShadow: 'none',
textAlign: 'right',
padding: 0,
}}
/>
);
case 'totalActual':
return <Typography.Text>{task.totalActual}</Typography.Text>;
case 'variance':
return (
<Typography.Text
style={{
color: task.variance < 0 ? '#FF0000' : '#6DC376',
}} }}
> >
{task.variance} <Avatars
members={task.members.map(member => ({
...member,
avatar_url: member.avatar_url || undefined
}))}
allowClickThrough={true}
/>
</div>
);
case FinanceTableColumnKeys.HOURS:
return <Typography.Text>{formatNumber(task.estimated_hours / 60)}</Typography.Text>;
case FinanceTableColumnKeys.TOTAL_TIME_LOGGED:
return <Typography.Text>{formatNumber(task.total_time_logged / 60)}</Typography.Text>;
case FinanceTableColumnKeys.ESTIMATED_COST:
return <Typography.Text>{formatNumber(task.estimated_cost)}</Typography.Text>;
case FinanceTableColumnKeys.FIXED_COST:
return selectedTask?.id === task.id ? (
<InputNumber
value={task.fixed_cost}
onBlur={(e) => {
handleFixedCostChange(Number(e.target.value), task.id);
setSelectedTask(null);
}}
onPressEnter={(e) => {
handleFixedCostChange(Number((e.target as HTMLInputElement).value), task.id);
setSelectedTask(null);
}}
autoFocus
style={{ width: '100%', textAlign: 'right' }}
formatter={(value) => `${value}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',')}
parser={(value) => Number(value!.replace(/\$\s?|(,*)/g, ''))}
min={0}
precision={2}
className="fixed-cost-input"
/>
) : (
<Typography.Text
style={{ cursor: 'pointer', width: '100%', display: 'block' }}
onClick={(e) => {
e.stopPropagation();
setSelectedTask(task);
}}
>
{formatNumber(task.fixed_cost)}
</Typography.Text> </Typography.Text>
); );
case FinanceTableColumnKeys.VARIANCE:
return <Typography.Text>{formatNumber(task.variance)}</Typography.Text>;
case FinanceTableColumnKeys.TOTAL_BUDGET:
return <Typography.Text>{formatNumber(task.total_budget)}</Typography.Text>;
case FinanceTableColumnKeys.TOTAL_ACTUAL:
return <Typography.Text>{formatNumber(task.total_actual)}</Typography.Text>;
case FinanceTableColumnKeys.COST:
return <Typography.Text>{formatNumber(task.estimated_cost || 0)}</Typography.Text>;
default: default:
return null; return null;
} }
}; };
// layout styles for table and the columns // Calculate totals for the current table
const customColumnHeaderStyles = (key: string) => const totals = useMemo(() => {
`px-2 text-left ${key === 'tableTitle' && `sticky left-0 z-10 ${isScrolling ? 'after:content after:absolute after:top-0 after:-right-1 after:-z-10 after:h-[40px] after:w-1.5 after:bg-transparent after:bg-gradient-to-r after:from-[rgba(0,0,0,0.12)] after:to-transparent' : ''}`}`; return tasks.reduce(
(acc, task) => ({
const customColumnStyles = (key: string) => hours: acc.hours + (task.estimated_hours / 60),
`px-2 text-left ${key === 'selector' && 'sticky left-0 z-10'} ${key === 'task' && 'sticky left-[48px] z-10'} ${key === 'members' && `sticky left-[288px] z-10 ${isScrolling ? 'after:content after:absolute after:top-0 after:-right-1 after:-z-10 after:h-[52px] after:w-1.5 after:bg-transparent after:bg-gradient-to-r after:from-[rgba(0,0,0,0.12)] after:to-transparent' : ''}`} ${themeMode === 'dark' ? 'bg-[#1d1d1d] border-[#303030]' : 'bg-[#fafafa]'}`; total_time_logged: acc.total_time_logged + (task.total_time_logged / 60),
estimated_cost: acc.estimated_cost + (task.estimated_cost || 0),
fixed_cost: acc.fixed_cost + (task.fixed_cost || 0),
total_budget: acc.total_budget + (task.total_budget || 0),
total_actual: acc.total_actual + (task.total_actual || 0),
variance: acc.variance + (task.variance || 0)
}),
{
hours: 0,
total_time_logged: 0,
estimated_cost: 0,
fixed_cost: 0,
total_budget: 0,
total_actual: 0,
variance: 0
}
);
}, [tasks]);
return ( return (
<Skeleton active loading={loading}>
<> <>
{/* header row */} {/* header row */}
<tr <tr
@@ -202,75 +231,69 @@ const FinanceTable = ({
), ),
fontWeight: 600, fontWeight: 600,
}} }}
className="group" className={`group ${themeMode === 'dark' ? 'dark' : ''}`}
> >
{financeTableColumns.map(
(col, index) => (
<td <td
colSpan={3} key={`header-${col.key}`}
style={{ style={{
width: 48, width: col.width,
textTransform: 'capitalize',
textAlign: 'left',
paddingInline: 16, paddingInline: 16,
textAlign: col.key === FinanceTableColumnKeys.TASK || col.key === FinanceTableColumnKeys.MEMBERS ? 'left' : 'right',
backgroundColor: themeWiseColor( backgroundColor: themeWiseColor(
table.color_code, table.color_code,
table.color_code_dark, table.color_code_dark,
themeMode themeMode
), ),
cursor: 'pointer', cursor: col.key === FinanceTableColumnKeys.TASK ? 'pointer' : 'default',
textTransform: col.key === FinanceTableColumnKeys.TASK ? 'capitalize' : 'none',
}} }}
className={customColumnHeaderStyles('tableTitle')} className={customHeaderColumnStyles(col.key)}
onClick={(e) => setIsCollapse((prev) => !prev)} onClick={col.key === FinanceTableColumnKeys.TASK ? () => setIsCollapse((prev) => !prev) : undefined}
> >
{col.key === FinanceTableColumnKeys.TASK ? (
<Flex gap={8} align="center" style={{ color: colors.darkGray }}> <Flex gap={8} align="center" style={{ color: colors.darkGray }}>
{isCollapse ? <RightOutlined /> : <DownOutlined />} {isCollapse ? <RightOutlined /> : <DownOutlined />}
{table.name} ({table.tasks.length}) {table.group_name} ({tasks.length})
</Flex> </Flex>
</td> ) : col.key === FinanceTableColumnKeys.MEMBERS ? null : renderFinancialTableHeaderContent(col.key)}
{financeTableColumns.map(
(col) =>
col.key !== 'task' &&
col.key !== 'members' && (
<td
key={col.key}
style={{
width: col.width,
paddingInline: 16,
textAlign: 'end',
}}
>
{renderFinancialTableHeaderContent(col.key)}
</td> </td>
) )
)} )}
</tr> </tr>
{/* task rows */} {/* task rows */}
{table.tasks.map((task: any) => ( {!isCollapse && tasks.map((task, idx) => (
<tr <tr
key={task.taskId} key={task.id}
style={{ height: 52 }} style={{
className={`${isCollapse ? 'hidden' : 'static'} cursor-pointer border-b-[1px] ${themeMode === 'dark' ? 'hover:bg-[#000000]' : 'hover:bg-[#f8f7f9]'} `} height: 40,
onClick={() => onTaskClick(task)} background: idx % 2 === 0 ? themeWiseColor('#fafafa', '#232323', themeMode) : themeWiseColor('#ffffff', '#181818', themeMode),
transition: 'background 0.2s',
}}
className={themeMode === 'dark' ? 'dark' : ''}
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)}
> >
<td
style={{ paddingInline: 16 }}
className={customColumnStyles('selector')}
>
<Checkbox />
</td>
{financeTableColumns.map((col) => ( {financeTableColumns.map((col) => (
<td <td
key={col.key} key={`${task.id}-${col.key}`}
className={customColumnStyles(col.key)}
style={{ style={{
width: col.width, width: col.width,
paddingInline: 16, paddingInline: 16,
textAlign: textAlign: col.type === 'string' ? 'left' : 'right',
col.type === 'hours' || col.type === 'currency' backgroundColor: (col.key === FinanceTableColumnKeys.TASK || col.key === FinanceTableColumnKeys.MEMBERS) ?
? 'end' (idx % 2 === 0 ? themeWiseColor('#fafafa', '#232323', themeMode) : themeWiseColor('#ffffff', '#181818', themeMode)) :
: 'start', '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>
@@ -278,6 +301,7 @@ const FinanceTable = ({
</tr> </tr>
))} ))}
</> </>
</Skeleton>
); );
}; };

View File

@@ -1,5 +1,4 @@
import { Button, ConfigProvider, Flex, Select, Typography } from 'antd'; import { Button, ConfigProvider, Flex, Select, Typography } from 'antd';
import React from 'react';
import GroupByFilterDropdown from './group-by-filter-dropdown'; import GroupByFilterDropdown from './group-by-filter-dropdown';
import { DownOutlined } from '@ant-design/icons'; import { DownOutlined } from '@ant-design/icons';
import { useAppDispatch } from '../../../../../hooks/useAppDispatch'; import { useAppDispatch } from '../../../../../hooks/useAppDispatch';

View File

@@ -1,61 +1,34 @@
import { Flex } from 'antd'; import { Flex } from 'antd';
import React, { useState, useEffect } from 'react'; import { useEffect } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useAppSelector } from '@/hooks/useAppSelector';
import ProjectViewFinanceHeader from './project-view-finance-header/project-view-finance-header'; import ProjectViewFinanceHeader from './project-view-finance-header/project-view-finance-header';
import FinanceTab from './finance-tab/finance-tab'; import FinanceTab from './finance-tab/finance-tab';
import RatecardTab from './ratecard-tab/ratecard-tab'; import RatecardTab from './ratecard-tab/ratecard-tab';
import { projectFinanceApiService } from '@/api/project-finance-ratecard/project-finance.api.service'; import { fetchProjectFinances, setActiveTab, setActiveGroup } from '@/features/projects/finance/project-finance.slice';
import { IProjectFinanceGroup } from '@/types/project/project-finance.types'; import { RootState } from '@/app/store';
type FinanceTabType = 'finance' | 'ratecard';
type GroupTypes = 'status' | 'priority' | 'phases';
interface TaskGroup {
group_id: string;
group_name: string;
tasks: any[];
}
interface FinanceTabProps {
groupType: GroupTypes;
taskGroups: TaskGroup[];
loading: boolean;
}
const ProjectViewFinance = () => { const ProjectViewFinance = () => {
const { projectId } = useParams<{ projectId: string }>(); const { projectId } = useParams<{ projectId: string }>();
const [activeTab, setActiveTab] = useState<FinanceTabType>('finance'); const dispatch = useAppDispatch();
const [activeGroup, setActiveGroup] = useState<GroupTypes>('status');
const [loading, setLoading] = useState(false);
const [taskGroups, setTaskGroups] = useState<IProjectFinanceGroup[]>([]);
const fetchTasks = async () => { const { activeTab, activeGroup, loading, taskGroups } = useAppSelector((state: RootState) => state.projectFinances);
if (!projectId) return; const { refreshTimestamp } = useAppSelector((state: RootState) => state.projectReducer);
try {
setLoading(true);
const response = await projectFinanceApiService.getProjectTasks(projectId, activeGroup);
if (response.done) {
setTaskGroups(response.body);
}
} catch (error) {
console.error('Error fetching tasks:', error);
} finally {
setLoading(false);
}
};
useEffect(() => { useEffect(() => {
fetchTasks(); if (projectId) {
}, [projectId, activeGroup]); dispatch(fetchProjectFinances({ projectId, groupBy: activeGroup }));
}
}, [projectId, activeGroup, dispatch, refreshTimestamp]);
return ( return (
<Flex vertical gap={16} style={{ overflowX: 'hidden' }}> <Flex vertical gap={16} style={{ overflowX: 'hidden' }}>
<ProjectViewFinanceHeader <ProjectViewFinanceHeader
activeTab={activeTab} activeTab={activeTab}
setActiveTab={setActiveTab} setActiveTab={(tab) => dispatch(setActiveTab(tab))}
activeGroup={activeGroup} activeGroup={activeGroup}
setActiveGroup={setActiveGroup} setActiveGroup={(group) => dispatch(setActiveGroup(group))}
/> />
{activeTab === 'finance' ? ( {activeTab === 'finance' ? (

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

@@ -10,28 +10,34 @@ export interface IProjectFinanceJobTitle {
} }
export interface IProjectFinanceMember { export interface IProjectFinanceMember {
id: string;
team_member_id: string; team_member_id: string;
job_title_id: string; project_member_id: string;
rate: number | null; name: string;
user: IProjectFinanceUser; email_notifications_enabled: boolean;
job_title: IProjectFinanceJobTitle; avatar_url: string | null;
user_id: string;
email: string;
socket_id: string | null;
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 {
id: string; id: string;
name: string; name: string;
status_id: string;
priority_id: string;
phase_id: string;
estimated_hours: number; estimated_hours: number;
actual_hours: number; total_time_logged: number;
completed_at: string | null; estimated_cost: number;
created_at: string;
updated_at: string;
billable: boolean;
assignees: any[]; // Using any[] since we don't have the assignee structure yet
members: IProjectFinanceMember[]; members: IProjectFinanceMember[];
billable: boolean;
fixed_cost: number;
variance: number;
total_budget: number;
total_actual: number;
} }
export interface IProjectFinanceGroup { export interface IProjectFinanceGroup {
@@ -42,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';

View File

@@ -0,0 +1,7 @@
export const formatHoursToReadable = (hours: number) => {
return hours / 60;
};
export const convertToHoursMinutes = (hours: number) => {
return `${Math.floor(hours / 60)} h ${hours % 60} min`;
};