Merge branch 'Worklenz:feature/project-finance' into feature/project-finance
This commit is contained in:
195
docs/api/task-breakdown-api.md
Normal file
195
docs/api/task-breakdown-api.md
Normal 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.
|
||||||
@@ -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';
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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;
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
61
worklenz-frontend/src/components/conditional-alert.tsx
Normal file
61
worklenz-frontend/src/components/conditional-alert.tsx
Normal 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;
|
||||||
184
worklenz-frontend/src/components/license-alert.tsx
Normal file
184
worklenz-frontend/src/components/license-alert.tsx
Normal 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;
|
||||||
@@ -1,17 +1,40 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Drawer, Typography } from 'antd';
|
import { Drawer, Typography, Spin } from 'antd';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useAppSelector } from '../../../hooks/useAppSelector';
|
import { useAppSelector } from '../../../hooks/useAppSelector';
|
||||||
import { useAppDispatch } from '../../../hooks/useAppDispatch';
|
import { useAppDispatch } from '../../../hooks/useAppDispatch';
|
||||||
import { themeWiseColor } from '../../../utils/themeWiseColor';
|
import { themeWiseColor } from '../../../utils/themeWiseColor';
|
||||||
import { toggleFinanceDrawer } from '../finance-slice';
|
import { closeFinanceDrawer } from '../finance-slice';
|
||||||
|
import { projectFinanceApiService } from '../../../api/project-finance-ratecard/project-finance.api.service';
|
||||||
|
import { ITaskBreakdownResponse } from '../../../types/project/project-finance.types';
|
||||||
|
|
||||||
const FinanceDrawer = ({ task }: { task: any }) => {
|
const FinanceDrawer = () => {
|
||||||
const [selectedTask, setSelectedTask] = useState(task);
|
const [taskBreakdown, setTaskBreakdown] = useState<ITaskBreakdownResponse | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
// Get task and drawer state from Redux store
|
||||||
|
const selectedTask = useAppSelector((state) => state.financeReducer.selectedTask);
|
||||||
|
const isDrawerOpen = useAppSelector((state) => state.financeReducer.isFinanceDrawerOpen);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedTask(task);
|
if (selectedTask?.id && isDrawerOpen) {
|
||||||
}, [task]);
|
fetchTaskBreakdown(selectedTask.id);
|
||||||
|
} else {
|
||||||
|
setTaskBreakdown(null);
|
||||||
|
}
|
||||||
|
}, [selectedTask, isDrawerOpen]);
|
||||||
|
|
||||||
|
const fetchTaskBreakdown = async (taskId: string) => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await projectFinanceApiService.getTaskBreakdown(taskId);
|
||||||
|
setTaskBreakdown(response.body);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching task breakdown:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// localization
|
// localization
|
||||||
const { t } = useTranslation('project-view-finance');
|
const { t } = useTranslation('project-view-finance');
|
||||||
@@ -19,9 +42,6 @@ const FinanceDrawer = ({ task }: { task: any }) => {
|
|||||||
// get theme data from theme reducer
|
// get theme data from theme reducer
|
||||||
const themeMode = useAppSelector((state) => state.themeReducer.mode);
|
const themeMode = useAppSelector((state) => state.themeReducer.mode);
|
||||||
|
|
||||||
const isDrawerOpen = useAppSelector(
|
|
||||||
(state) => state.financeReducer.isFinanceDrawerOpen
|
|
||||||
);
|
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const currency = useAppSelector(
|
const currency = useAppSelector(
|
||||||
(state) => state.financeReducer.currency
|
(state) => state.financeReducer.currency
|
||||||
@@ -29,41 +49,15 @@ const FinanceDrawer = ({ task }: { task: any }) => {
|
|||||||
|
|
||||||
// function handle drawer close
|
// function handle drawer close
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
setSelectedTask(null);
|
setTaskBreakdown(null);
|
||||||
dispatch(toggleFinanceDrawer());
|
dispatch(closeFinanceDrawer());
|
||||||
};
|
};
|
||||||
|
|
||||||
// group members by job roles and calculate labor hours and costs
|
|
||||||
const groupedMembers =
|
|
||||||
selectedTask?.members?.reduce((acc: any, member: any) => {
|
|
||||||
const memberHours = selectedTask.hours / selectedTask.members.length;
|
|
||||||
const memberCost = memberHours * member.hourlyRate;
|
|
||||||
|
|
||||||
if (!acc[member.jobRole]) {
|
|
||||||
acc[member.jobRole] = {
|
|
||||||
jobRole: member.jobRole,
|
|
||||||
laborHours: 0,
|
|
||||||
cost: 0,
|
|
||||||
members: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
acc[member.jobRole].laborHours += memberHours;
|
|
||||||
acc[member.jobRole].cost += memberCost;
|
|
||||||
acc[member.jobRole].members.push({
|
|
||||||
name: member.name,
|
|
||||||
laborHours: memberHours,
|
|
||||||
cost: memberCost,
|
|
||||||
});
|
|
||||||
|
|
||||||
return acc;
|
|
||||||
}, {}) || {};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Drawer
|
<Drawer
|
||||||
title={
|
title={
|
||||||
<Typography.Text style={{ fontWeight: 500, fontSize: 16 }}>
|
<Typography.Text style={{ fontWeight: 500, fontSize: 16 }}>
|
||||||
{selectedTask?.task || t('noTaskSelected')}
|
{taskBreakdown?.task?.name || selectedTask?.name || t('noTaskSelected')}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
}
|
}
|
||||||
open={isDrawerOpen}
|
open={isDrawerOpen}
|
||||||
@@ -72,98 +66,77 @@ const FinanceDrawer = ({ task }: { task: any }) => {
|
|||||||
width={480}
|
width={480}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<table
|
{loading ? (
|
||||||
style={{
|
<div style={{ textAlign: 'center', padding: '20px' }}>
|
||||||
width: '100%',
|
<Spin size="large" />
|
||||||
borderCollapse: 'collapse',
|
</div>
|
||||||
marginBottom: '16px',
|
) : (
|
||||||
}}
|
<table
|
||||||
>
|
style={{
|
||||||
<thead>
|
width: '100%',
|
||||||
<tr
|
borderCollapse: 'collapse',
|
||||||
style={{
|
marginBottom: '16px',
|
||||||
height: 48,
|
}}
|
||||||
backgroundColor: themeWiseColor(
|
>
|
||||||
'#F5F5F5',
|
<thead>
|
||||||
'#1d1d1d',
|
<tr
|
||||||
themeMode
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<th
|
|
||||||
style={{
|
style={{
|
||||||
textAlign: 'left',
|
height: 48,
|
||||||
padding: 8,
|
backgroundColor: themeWiseColor(
|
||||||
}}
|
'#F5F5F5',
|
||||||
></th>
|
'#1d1d1d',
|
||||||
<th
|
themeMode
|
||||||
style={{
|
),
|
||||||
textAlign: 'right',
|
|
||||||
padding: 8,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t('labourHoursColumn')}
|
<th
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
style={{
|
|
||||||
textAlign: 'right',
|
|
||||||
padding: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t('costColumn')} ({currency})
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
|
|
||||||
<div className="mb-4"></div>
|
|
||||||
|
|
||||||
<tbody>
|
|
||||||
{Object.values(groupedMembers).map((group: any) => (
|
|
||||||
<React.Fragment key={group.jobRole}>
|
|
||||||
{/* Group Header */}
|
|
||||||
<tr
|
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: themeWiseColor(
|
textAlign: 'left',
|
||||||
'#D9D9D9',
|
padding: 8,
|
||||||
'#000',
|
}}
|
||||||
themeMode
|
></th>
|
||||||
),
|
<th
|
||||||
height: 56,
|
style={{
|
||||||
|
textAlign: 'right',
|
||||||
|
padding: 8,
|
||||||
}}
|
}}
|
||||||
className="border-b-[1px] font-semibold"
|
|
||||||
>
|
>
|
||||||
<td style={{ padding: 8 }}>{group.jobRole}</td>
|
{t('labourHoursColumn')}
|
||||||
<td
|
</th>
|
||||||
style={{
|
<th
|
||||||
textAlign: 'right',
|
style={{
|
||||||
padding: 8,
|
textAlign: 'right',
|
||||||
}}
|
padding: 8,
|
||||||
>
|
}}
|
||||||
{group.laborHours}
|
>
|
||||||
</td>
|
{t('costColumn')} ({currency})
|
||||||
<td
|
</th>
|
||||||
style={{
|
</tr>
|
||||||
textAlign: 'right',
|
</thead>
|
||||||
padding: 8,
|
|
||||||
}}
|
<tbody>
|
||||||
>
|
{taskBreakdown?.grouped_members?.map((group: any) => (
|
||||||
{group.cost}
|
<React.Fragment key={group.jobRole}>
|
||||||
</td>
|
{/* Group Header */}
|
||||||
</tr>
|
|
||||||
{/* Member Rows */}
|
|
||||||
{group.members.map((member: any, index: number) => (
|
|
||||||
<tr
|
<tr
|
||||||
key={`${group.jobRole}-${index}`}
|
style={{
|
||||||
className="border-b-[1px]"
|
backgroundColor: themeWiseColor(
|
||||||
style={{ height: 56 }}
|
'#D9D9D9',
|
||||||
|
'#000',
|
||||||
|
themeMode
|
||||||
|
),
|
||||||
|
height: 56,
|
||||||
|
}}
|
||||||
|
className="border-b-[1px] font-semibold"
|
||||||
>
|
>
|
||||||
|
<td style={{ padding: 8 }}>{group.jobRole}</td>
|
||||||
<td
|
<td
|
||||||
style={{
|
style={{
|
||||||
|
textAlign: 'right',
|
||||||
padding: 8,
|
padding: 8,
|
||||||
paddingLeft: 32,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{member.name}
|
{group.estimated_hours?.toFixed(2) || '0.00'}
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
style={{
|
style={{
|
||||||
@@ -171,22 +144,47 @@ const FinanceDrawer = ({ task }: { task: any }) => {
|
|||||||
padding: 8,
|
padding: 8,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{member.laborHours}
|
{group.estimated_cost?.toFixed(2) || '0.00'}
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
style={{
|
|
||||||
textAlign: 'right',
|
|
||||||
padding: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{member.cost}
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
{/* Member Rows */}
|
||||||
</React.Fragment>
|
{group.members?.map((member: any, index: number) => (
|
||||||
))}
|
<tr
|
||||||
</tbody>
|
key={`${group.jobRole}-${index}`}
|
||||||
</table>
|
className="border-b-[1px]"
|
||||||
|
style={{ height: 56 }}
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
padding: 8,
|
||||||
|
paddingLeft: 32,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{member.name}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
textAlign: 'right',
|
||||||
|
padding: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{member.estimated_hours?.toFixed(2) || '0.00'}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
textAlign: 'right',
|
||||||
|
padding: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{member.estimated_cost?.toFixed(2) || '0.00'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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%',
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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 (
|
||||||
|
|||||||
61
worklenz-frontend/src/lib/project/finance-table-wrapper.tsx
Normal file
61
worklenz-frontend/src/lib/project/finance-table-wrapper.tsx
Normal 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;
|
||||||
@@ -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',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
||||||
}))
|
}))
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,74 +45,89 @@ const FinanceTableWrapper: React.FC<FinanceTableWrapperProps> = ({
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const themeMode = useAppSelector((state) => state.themeReducer.mode);
|
// Handle click outside to close editing
|
||||||
const { currency } = useAppSelector((state) => state.financeReducer);
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (editingFixedCost && !(event.target as Element)?.closest('.fixed-cost-input')) {
|
||||||
|
setEditingFixedCost(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const totals = activeTablesList.reduce(
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
(
|
return () => {
|
||||||
acc: {
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
hours: number;
|
};
|
||||||
cost: number;
|
}, [editingFixedCost]);
|
||||||
fixedCost: number;
|
|
||||||
totalBudget: number;
|
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||||
totalActual: number;
|
const { currency } = useAppSelector(state => state.financeReducer);
|
||||||
variance: number;
|
const taskGroups = useAppSelector(state => state.projectFinances.taskGroups);
|
||||||
|
|
||||||
|
// Use Redux store data for totals calculation to ensure reactivity
|
||||||
|
const totals = useMemo(() => {
|
||||||
|
return taskGroups.reduce(
|
||||||
|
(
|
||||||
|
acc: {
|
||||||
|
hours: number;
|
||||||
|
cost: number;
|
||||||
|
fixedCost: number;
|
||||||
|
totalBudget: number;
|
||||||
|
totalActual: number;
|
||||||
|
variance: number;
|
||||||
|
total_time_logged: number;
|
||||||
|
estimated_cost: number;
|
||||||
|
},
|
||||||
|
table: IProjectFinanceGroup
|
||||||
|
) => {
|
||||||
|
table.tasks.forEach((task) => {
|
||||||
|
acc.hours += (task.estimated_hours / 60) || 0;
|
||||||
|
acc.cost += task.estimated_cost || 0;
|
||||||
|
acc.fixedCost += task.fixed_cost || 0;
|
||||||
|
acc.totalBudget += task.total_budget || 0;
|
||||||
|
acc.totalActual += task.total_actual || 0;
|
||||||
|
acc.variance += task.variance || 0;
|
||||||
|
acc.total_time_logged += (task.total_time_logged / 60) || 0;
|
||||||
|
acc.estimated_cost += task.estimated_cost || 0;
|
||||||
|
});
|
||||||
|
return acc;
|
||||||
},
|
},
|
||||||
table: { tasks: any[] }
|
{
|
||||||
) => {
|
hours: 0,
|
||||||
table.tasks.forEach((task: any) => {
|
cost: 0,
|
||||||
acc.hours += task.hours || 0;
|
fixedCost: 0,
|
||||||
acc.cost += task.cost || 0;
|
totalBudget: 0,
|
||||||
acc.fixedCost += task.fixedCost || 0;
|
totalActual: 0,
|
||||||
acc.totalBudget += task.totalBudget || 0;
|
variance: 0,
|
||||||
acc.totalActual += task.totalActual || 0;
|
total_time_logged: 0,
|
||||||
acc.variance += task.variance || 0;
|
estimated_cost: 0,
|
||||||
});
|
}
|
||||||
return acc;
|
);
|
||||||
},
|
}, [taskGroups]);
|
||||||
{
|
|
||||||
hours: 0,
|
|
||||||
cost: 0,
|
|
||||||
fixedCost: 0,
|
|
||||||
totalBudget: 0,
|
|
||||||
totalActual: 0,
|
|
||||||
variance: 0,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
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>
|
||||||
|
|
||||||
<tr
|
{hasAnyTasks && (
|
||||||
style={{
|
<tr
|
||||||
height: 56,
|
|
||||||
fontWeight: 500,
|
|
||||||
backgroundColor: themeWiseColor(
|
|
||||||
'#fbfbfb',
|
|
||||||
'#141414',
|
|
||||||
themeMode
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<td
|
|
||||||
colSpan={3}
|
|
||||||
style={{
|
style={{
|
||||||
paddingInline: 16,
|
height: 56,
|
||||||
backgroundColor: themeWiseColor(
|
fontWeight: 500,
|
||||||
'#fbfbfb',
|
backgroundColor: themeWiseColor('#fbfbfb', '#141414', themeMode),
|
||||||
'#141414',
|
|
||||||
themeMode
|
|
||||||
),
|
|
||||||
}}
|
}}
|
||||||
className={customColumnStyles('totalRow')}
|
|
||||||
>
|
>
|
||||||
<Typography.Text style={{ fontSize: 18 }}>
|
{financeTableColumns.map((col, index) => (
|
||||||
{t('totalText')}
|
<td
|
||||||
</Typography.Text>
|
key={col.key}
|
||||||
</td>
|
style={{
|
||||||
{financeTableColumns.map(
|
minWidth: col.width,
|
||||||
(col) =>
|
paddingInline: 16,
|
||||||
(col.type === 'hours' || col.type === 'currency') && (
|
textAlign: col.key === FinanceTableColumnKeys.TASK ? 'left' : 'right',
|
||||||
<td
|
backgroundColor: themeWiseColor('#fbfbfb', '#141414', themeMode),
|
||||||
key={col.key}
|
}}
|
||||||
style={{
|
className={customColumnStyles(col.key)}
|
||||||
minWidth: col.width,
|
>
|
||||||
paddingInline: 16,
|
{col.key === FinanceTableColumnKeys.TASK ? (
|
||||||
textAlign: 'end',
|
<Typography.Text style={{ fontSize: 18 }}>{t('totalText')}</Typography.Text>
|
||||||
}}
|
) : col.key === FinanceTableColumnKeys.MEMBERS ? null : (
|
||||||
>
|
(col.type === 'hours' || col.type === 'currency') && renderFinancialTableHeaderContent(col.key)
|
||||||
{renderFinancialTableHeaderContent(col.key)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
)
|
))}
|
||||||
)}
|
</tr>
|
||||||
</tr>
|
)}
|
||||||
|
|
||||||
{activeTablesList.map((table, index) => (
|
{hasAnyTasks ? (
|
||||||
<FinanceTable
|
activeTablesList.map((table) => (
|
||||||
key={index}
|
<FinanceTable
|
||||||
table={table}
|
key={table.group_id}
|
||||||
isScrolling={isScrolling}
|
table={table}
|
||||||
onTaskClick={onTaskClick}
|
isScrolling={isScrolling}
|
||||||
/>
|
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 />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
/* Finance Table Styles */
|
||||||
@@ -1,283 +1,307 @@
|
|||||||
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 */}
|
<>
|
||||||
<tr
|
{/* header row */}
|
||||||
style={{
|
<tr
|
||||||
height: 40,
|
|
||||||
backgroundColor: themeWiseColor(
|
|
||||||
table.color_code,
|
|
||||||
table.color_code_dark,
|
|
||||||
themeMode
|
|
||||||
),
|
|
||||||
fontWeight: 600,
|
|
||||||
}}
|
|
||||||
className="group"
|
|
||||||
>
|
|
||||||
<td
|
|
||||||
colSpan={3}
|
|
||||||
style={{
|
style={{
|
||||||
width: 48,
|
height: 40,
|
||||||
textTransform: 'capitalize',
|
|
||||||
textAlign: 'left',
|
|
||||||
paddingInline: 16,
|
|
||||||
backgroundColor: themeWiseColor(
|
backgroundColor: themeWiseColor(
|
||||||
table.color_code,
|
table.color_code,
|
||||||
table.color_code_dark,
|
table.color_code_dark,
|
||||||
themeMode
|
themeMode
|
||||||
),
|
),
|
||||||
cursor: 'pointer',
|
fontWeight: 600,
|
||||||
}}
|
}}
|
||||||
className={customColumnHeaderStyles('tableTitle')}
|
className={`group ${themeMode === 'dark' ? 'dark' : ''}`}
|
||||||
onClick={(e) => setIsCollapse((prev) => !prev)}
|
|
||||||
>
|
>
|
||||||
<Flex gap={8} align="center" style={{ color: colors.darkGray }}>
|
{financeTableColumns.map(
|
||||||
{isCollapse ? <RightOutlined /> : <DownOutlined />}
|
(col, index) => (
|
||||||
{table.name} ({table.tasks.length})
|
|
||||||
</Flex>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
{financeTableColumns.map(
|
|
||||||
(col) =>
|
|
||||||
col.key !== 'task' &&
|
|
||||||
col.key !== 'members' && (
|
|
||||||
<td
|
<td
|
||||||
key={col.key}
|
key={`header-${col.key}`}
|
||||||
style={{
|
style={{
|
||||||
width: col.width,
|
width: col.width,
|
||||||
paddingInline: 16,
|
paddingInline: 16,
|
||||||
textAlign: 'end',
|
textAlign: col.key === FinanceTableColumnKeys.TASK || col.key === FinanceTableColumnKeys.MEMBERS ? 'left' : 'right',
|
||||||
|
backgroundColor: themeWiseColor(
|
||||||
|
table.color_code,
|
||||||
|
table.color_code_dark,
|
||||||
|
themeMode
|
||||||
|
),
|
||||||
|
cursor: col.key === FinanceTableColumnKeys.TASK ? 'pointer' : 'default',
|
||||||
|
textTransform: col.key === FinanceTableColumnKeys.TASK ? 'capitalize' : 'none',
|
||||||
}}
|
}}
|
||||||
|
className={customHeaderColumnStyles(col.key)}
|
||||||
|
onClick={col.key === FinanceTableColumnKeys.TASK ? () => setIsCollapse((prev) => !prev) : undefined}
|
||||||
>
|
>
|
||||||
{renderFinancialTableHeaderContent(col.key)}
|
{col.key === FinanceTableColumnKeys.TASK ? (
|
||||||
|
<Flex gap={8} align="center" style={{ color: colors.darkGray }}>
|
||||||
|
{isCollapse ? <RightOutlined /> : <DownOutlined />}
|
||||||
|
{table.group_name} ({tasks.length})
|
||||||
|
</Flex>
|
||||||
|
) : col.key === FinanceTableColumnKeys.MEMBERS ? null : renderFinancialTableHeaderContent(col.key)}
|
||||||
</td>
|
</td>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</tr>
|
|
||||||
|
|
||||||
{/* task rows */}
|
|
||||||
{table.tasks.map((task: any) => (
|
|
||||||
<tr
|
|
||||||
key={task.taskId}
|
|
||||||
style={{ height: 52 }}
|
|
||||||
className={`${isCollapse ? 'hidden' : 'static'} cursor-pointer border-b-[1px] ${themeMode === 'dark' ? 'hover:bg-[#000000]' : 'hover:bg-[#f8f7f9]'} `}
|
|
||||||
onClick={() => onTaskClick(task)}
|
|
||||||
>
|
|
||||||
<td
|
|
||||||
style={{ paddingInline: 16 }}
|
|
||||||
className={customColumnStyles('selector')}
|
|
||||||
>
|
|
||||||
<Checkbox />
|
|
||||||
</td>
|
|
||||||
{financeTableColumns.map((col) => (
|
|
||||||
<td
|
|
||||||
key={col.key}
|
|
||||||
className={customColumnStyles(col.key)}
|
|
||||||
style={{
|
|
||||||
width: col.width,
|
|
||||||
paddingInline: 16,
|
|
||||||
textAlign:
|
|
||||||
col.type === 'hours' || col.type === 'currency'
|
|
||||||
? 'end'
|
|
||||||
: 'start',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{renderFinancialTableColumnContent(col.key, task)}
|
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
|
||||||
</>
|
{/* task rows */}
|
||||||
|
{!isCollapse && tasks.map((task, idx) => (
|
||||||
|
<tr
|
||||||
|
key={task.id}
|
||||||
|
style={{
|
||||||
|
height: 40,
|
||||||
|
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)}
|
||||||
|
>
|
||||||
|
{financeTableColumns.map((col) => (
|
||||||
|
<td
|
||||||
|
key={`${task.id}-${col.key}`}
|
||||||
|
style={{
|
||||||
|
width: col.width,
|
||||||
|
paddingInline: 16,
|
||||||
|
textAlign: col.type === 'string' ? 'left' : 'right',
|
||||||
|
backgroundColor: (col.key === FinanceTableColumnKeys.TASK || col.key === FinanceTableColumnKeys.MEMBERS) ?
|
||||||
|
(idx % 2 === 0 ? themeWiseColor('#fafafa', '#232323', themeMode) : themeWiseColor('#ffffff', '#181818', themeMode)) :
|
||||||
|
'transparent',
|
||||||
|
cursor: 'default'
|
||||||
|
}}
|
||||||
|
className={customColumnStyles(col.key)}
|
||||||
|
onClick={
|
||||||
|
col.key === FinanceTableColumnKeys.FIXED_COST
|
||||||
|
? (e) => e.stopPropagation()
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{renderFinancialTableColumnContent(col.key, task)}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
</Skeleton>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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' ? (
|
||||||
|
|||||||
@@ -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')}
|
||||||
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
7
worklenz-frontend/src/utils/format-hours-to-readable.ts
Normal file
7
worklenz-frontend/src/utils/format-hours-to-readable.ts
Normal 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`;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user