feat(task-breakdown-api): implement task financial breakdown API and related enhancements
- Added a new API endpoint `GET /api/project-finance/task/:id/breakdown` to retrieve detailed financial breakdown for individual tasks, including labor hours and costs grouped by job roles. - Introduced a new SQL migration to add a `fixed_cost` column to the tasks table for improved financial calculations. - Updated the project finance controller to handle task breakdown logic, including calculations for estimated and actual costs. - Enhanced frontend components to integrate the new task breakdown API, providing real-time financial data in the finance drawer. - Updated localization files to reflect changes in financial terminology across English, Spanish, and Portuguese. - Implemented Redux state management for selected tasks in the finance drawer.
This commit is contained in:
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';
|
||||||
@@ -17,7 +17,24 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
|
|||||||
const projectId = req.params.project_id;
|
const projectId = req.params.project_id;
|
||||||
const groupBy = req.query.group || "status";
|
const groupBy = req.query.group || "status";
|
||||||
|
|
||||||
// Get all tasks with their financial data
|
// First, get the project rate cards for this project
|
||||||
|
const rateCardQuery = `
|
||||||
|
SELECT
|
||||||
|
fprr.id,
|
||||||
|
fprr.project_id,
|
||||||
|
fprr.job_title_id,
|
||||||
|
fprr.rate,
|
||||||
|
jt.name as job_title_name
|
||||||
|
FROM finance_project_rate_card_roles fprr
|
||||||
|
LEFT JOIN job_titles jt ON fprr.job_title_id = jt.id
|
||||||
|
WHERE fprr.project_id = $1
|
||||||
|
ORDER BY jt.name;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const rateCardResult = await db.query(rateCardQuery, [projectId]);
|
||||||
|
const projectRateCards = rateCardResult.rows;
|
||||||
|
|
||||||
|
// Get all tasks with their financial data - using project_members.project_rate_card_role_id
|
||||||
const q = `
|
const q = `
|
||||||
WITH task_costs AS (
|
WITH task_costs AS (
|
||||||
SELECT
|
SELECT
|
||||||
@@ -25,51 +42,94 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
|
|||||||
t.name,
|
t.name,
|
||||||
COALESCE(t.total_minutes, 0)::float as estimated_hours,
|
COALESCE(t.total_minutes, 0)::float as estimated_hours,
|
||||||
COALESCE((SELECT SUM(time_spent) FROM task_work_log WHERE task_id = t.id), 0) / 3600.0::float as total_time_logged,
|
COALESCE((SELECT SUM(time_spent) FROM task_work_log WHERE task_id = t.id), 0) / 3600.0::float as total_time_logged,
|
||||||
COALESCE((SELECT SUM(rate * (time_spent / 3600.0))
|
|
||||||
FROM task_work_log twl
|
|
||||||
LEFT JOIN users u ON twl.user_id = u.id
|
|
||||||
LEFT JOIN team_members tm ON u.id = tm.user_id
|
|
||||||
LEFT JOIN project_members pm ON tm.id = pm.team_member_id
|
|
||||||
LEFT JOIN finance_project_rate_card_roles pmr ON pm.project_rate_card_role_id = pmr.id
|
|
||||||
WHERE twl.task_id = t.id), 0)::float as estimated_cost,
|
|
||||||
COALESCE(t.fixed_cost, 0)::float as fixed_cost,
|
|
||||||
t.project_id,
|
t.project_id,
|
||||||
t.status_id,
|
t.status_id,
|
||||||
t.priority_id,
|
t.priority_id,
|
||||||
(SELECT phase_id FROM task_phase WHERE task_id = t.id) as phase_id,
|
(SELECT phase_id FROM task_phase WHERE task_id = t.id) as phase_id,
|
||||||
(SELECT get_task_assignees(t.id)) as assignees,
|
(SELECT get_task_assignees(t.id)) as assignees,
|
||||||
t.billable
|
t.billable,
|
||||||
|
COALESCE(t.fixed_cost, 0) as fixed_cost
|
||||||
FROM tasks t
|
FROM tasks t
|
||||||
WHERE t.project_id = $1 AND t.archived = false
|
WHERE t.project_id = $1 AND t.archived = false
|
||||||
|
),
|
||||||
|
task_estimated_costs AS (
|
||||||
|
SELECT
|
||||||
|
tc.*,
|
||||||
|
-- Calculate estimated cost based on estimated hours and assignee rates from project_members
|
||||||
|
COALESCE((
|
||||||
|
SELECT SUM(tc.estimated_hours * COALESCE(fprr.rate, 0))
|
||||||
|
FROM json_array_elements(tc.assignees) AS assignee_json
|
||||||
|
LEFT JOIN project_members pm ON pm.team_member_id = (assignee_json->>'team_member_id')::uuid
|
||||||
|
AND pm.project_id = tc.project_id
|
||||||
|
LEFT JOIN finance_project_rate_card_roles fprr ON fprr.id = pm.project_rate_card_role_id
|
||||||
|
WHERE assignee_json->>'team_member_id' IS NOT NULL
|
||||||
|
), 0) as estimated_cost,
|
||||||
|
-- Calculate actual cost based on time logged and assignee rates from project_members
|
||||||
|
COALESCE((
|
||||||
|
SELECT SUM(
|
||||||
|
COALESCE(fprr.rate, 0) * (twl.time_spent / 3600.0)
|
||||||
|
)
|
||||||
|
FROM task_work_log twl
|
||||||
|
LEFT JOIN users u ON twl.user_id = u.id
|
||||||
|
LEFT JOIN team_members tm ON u.id = tm.user_id
|
||||||
|
LEFT JOIN project_members pm ON pm.team_member_id = tm.id AND pm.project_id = tc.project_id
|
||||||
|
LEFT JOIN finance_project_rate_card_roles fprr ON fprr.id = pm.project_rate_card_role_id
|
||||||
|
WHERE twl.task_id = tc.id
|
||||||
|
), 0) as actual_cost_from_logs
|
||||||
|
FROM task_costs tc
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
tc.*,
|
tec.*,
|
||||||
(tc.estimated_cost + tc.fixed_cost)::float as total_budget,
|
(tec.estimated_cost + tec.fixed_cost) as total_budget,
|
||||||
COALESCE((SELECT SUM(rate * (time_spent / 3600.0))
|
(tec.actual_cost_from_logs + tec.fixed_cost) as total_actual,
|
||||||
FROM task_work_log twl
|
((tec.actual_cost_from_logs + tec.fixed_cost) - (tec.estimated_cost + tec.fixed_cost)) as variance
|
||||||
LEFT JOIN users u ON twl.user_id = u.id
|
FROM task_estimated_costs tec;
|
||||||
LEFT JOIN team_members tm ON u.id = tm.user_id
|
|
||||||
LEFT JOIN project_members pm ON tm.id = pm.team_member_id
|
|
||||||
LEFT JOIN finance_project_rate_card_roles pmr ON pm.project_rate_card_role_id = pmr.id
|
|
||||||
WHERE twl.task_id = tc.id), 0)::float + tc.fixed_cost as total_actual,
|
|
||||||
(COALESCE((SELECT SUM(rate * (time_spent / 3600.0))
|
|
||||||
FROM task_work_log twl
|
|
||||||
LEFT JOIN users u ON twl.user_id = u.id
|
|
||||||
LEFT JOIN team_members tm ON u.id = tm.user_id
|
|
||||||
LEFT JOIN project_members pm ON tm.id = pm.team_member_id
|
|
||||||
LEFT JOIN finance_project_rate_card_roles pmr ON pm.project_rate_card_role_id = pmr.id
|
|
||||||
WHERE twl.task_id = tc.id), 0)::float + tc.fixed_cost) - (tc.estimated_cost + tc.fixed_cost)::float as variance
|
|
||||||
FROM task_costs tc;
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const result = await db.query(q, [projectId]);
|
const result = await db.query(q, [projectId]);
|
||||||
const tasks = result.rows;
|
const tasks = result.rows;
|
||||||
|
|
||||||
// Add color_code to each assignee
|
// Add color_code to each assignee and include their rate information using project_members
|
||||||
for (const task of tasks) {
|
for (const task of tasks) {
|
||||||
if (Array.isArray(task.assignees)) {
|
if (Array.isArray(task.assignees)) {
|
||||||
for (const assignee of task.assignees) {
|
for (const assignee of task.assignees) {
|
||||||
assignee.color_code = getColor(assignee.name);
|
assignee.color_code = getColor(assignee.name);
|
||||||
|
|
||||||
|
// Get the rate for this assignee using project_members.project_rate_card_role_id
|
||||||
|
const memberRateQuery = `
|
||||||
|
SELECT
|
||||||
|
pm.project_rate_card_role_id,
|
||||||
|
fprr.rate,
|
||||||
|
fprr.job_title_id,
|
||||||
|
jt.name as job_title_name
|
||||||
|
FROM project_members pm
|
||||||
|
LEFT JOIN finance_project_rate_card_roles fprr ON fprr.id = pm.project_rate_card_role_id
|
||||||
|
LEFT JOIN job_titles jt ON fprr.job_title_id = jt.id
|
||||||
|
WHERE pm.team_member_id = $1 AND pm.project_id = $2
|
||||||
|
`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const memberRateResult = await db.query(memberRateQuery, [assignee.team_member_id, projectId]);
|
||||||
|
if (memberRateResult.rows.length > 0) {
|
||||||
|
const memberRate = memberRateResult.rows[0];
|
||||||
|
assignee.project_rate_card_role_id = memberRate.project_rate_card_role_id;
|
||||||
|
assignee.rate = memberRate.rate ? Number(memberRate.rate) : 0;
|
||||||
|
assignee.job_title_id = memberRate.job_title_id;
|
||||||
|
assignee.job_title_name = memberRate.job_title_name;
|
||||||
|
} else {
|
||||||
|
// Member doesn't have a rate card role assigned
|
||||||
|
assignee.project_rate_card_role_id = null;
|
||||||
|
assignee.rate = 0;
|
||||||
|
assignee.job_title_id = null;
|
||||||
|
assignee.job_title_name = null;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching member rate from project_members:", error);
|
||||||
|
assignee.project_rate_card_role_id = null;
|
||||||
|
assignee.rate = 0;
|
||||||
|
assignee.job_title_id = null;
|
||||||
|
assignee.job_title_name = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -151,6 +211,185 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.status(200).send(new ServerResponse(true, groupedTasks));
|
// Include project rate cards in the response for reference
|
||||||
|
const responseData = {
|
||||||
|
groups: groupedTasks,
|
||||||
|
project_rate_cards: projectRateCards
|
||||||
|
};
|
||||||
|
|
||||||
|
return res.status(200).send(new ServerResponse(true, responseData));
|
||||||
|
}
|
||||||
|
|
||||||
|
@HandleExceptions()
|
||||||
|
public static async updateTaskFixedCost(
|
||||||
|
req: IWorkLenzRequest,
|
||||||
|
res: IWorkLenzResponse
|
||||||
|
): Promise<IWorkLenzResponse> {
|
||||||
|
const taskId = req.params.task_id;
|
||||||
|
const { fixed_cost } = req.body;
|
||||||
|
|
||||||
|
if (typeof fixed_cost !== "number" || fixed_cost < 0) {
|
||||||
|
return res.status(400).send(new ServerResponse(false, null, "Invalid fixed cost value"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const q = `
|
||||||
|
UPDATE tasks
|
||||||
|
SET fixed_cost = $1, updated_at = NOW()
|
||||||
|
WHERE id = $2
|
||||||
|
RETURNING id, name, fixed_cost;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await db.query(q, [fixed_cost, taskId]);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return res.status(404).send(new ServerResponse(false, null, "Task not found"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(200).send(new ServerResponse(true, result.rows[0]));
|
||||||
|
}
|
||||||
|
|
||||||
|
@HandleExceptions()
|
||||||
|
public static async getTaskBreakdown(
|
||||||
|
req: IWorkLenzRequest,
|
||||||
|
res: IWorkLenzResponse
|
||||||
|
): Promise<IWorkLenzResponse> {
|
||||||
|
const taskId = req.params.id;
|
||||||
|
|
||||||
|
// Get task basic information and financial data
|
||||||
|
const taskQuery = `
|
||||||
|
SELECT
|
||||||
|
t.id,
|
||||||
|
t.name,
|
||||||
|
t.project_id,
|
||||||
|
COALESCE(t.total_minutes, 0) / 60.0 as estimated_hours,
|
||||||
|
COALESCE((SELECT SUM(time_spent) FROM task_work_log WHERE task_id = t.id), 0) / 3600.0 as total_time_logged,
|
||||||
|
COALESCE(t.fixed_cost, 0) as fixed_cost,
|
||||||
|
t.billable,
|
||||||
|
(SELECT get_task_assignees(t.id)) as assignees
|
||||||
|
FROM tasks t
|
||||||
|
WHERE t.id = $1 AND t.archived = false;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const taskResult = await db.query(taskQuery, [taskId]);
|
||||||
|
|
||||||
|
if (taskResult.rows.length === 0) {
|
||||||
|
return res.status(404).send(new ServerResponse(false, null, "Task not found"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const [task] = taskResult.rows;
|
||||||
|
|
||||||
|
// Get detailed member information with rates and job titles
|
||||||
|
const membersWithRates = [];
|
||||||
|
if (Array.isArray(task.assignees)) {
|
||||||
|
for (const assignee of task.assignees) {
|
||||||
|
const memberRateQuery = `
|
||||||
|
SELECT
|
||||||
|
tm.id as team_member_id,
|
||||||
|
u.name,
|
||||||
|
u.avatar_url,
|
||||||
|
pm.project_rate_card_role_id,
|
||||||
|
COALESCE(fprr.rate, 0) as hourly_rate,
|
||||||
|
fprr.job_title_id,
|
||||||
|
jt.name as job_title_name
|
||||||
|
FROM team_members tm
|
||||||
|
LEFT JOIN users u ON tm.user_id = u.id
|
||||||
|
LEFT JOIN project_members pm ON pm.team_member_id = tm.id AND pm.project_id = $1
|
||||||
|
LEFT JOIN finance_project_rate_card_roles fprr ON fprr.id = pm.project_rate_card_role_id
|
||||||
|
LEFT JOIN job_titles jt ON fprr.job_title_id = jt.id
|
||||||
|
WHERE tm.id = $2
|
||||||
|
`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const memberResult = await db.query(memberRateQuery, [task.project_id, assignee.team_member_id]);
|
||||||
|
if (memberResult.rows.length > 0) {
|
||||||
|
const [member] = memberResult.rows;
|
||||||
|
|
||||||
|
// Get actual time logged by this member for this task
|
||||||
|
const timeLogQuery = `
|
||||||
|
SELECT COALESCE(SUM(time_spent), 0) / 3600.0 as logged_hours
|
||||||
|
FROM task_work_log twl
|
||||||
|
LEFT JOIN users u ON twl.user_id = u.id
|
||||||
|
LEFT JOIN team_members tm ON u.id = tm.user_id
|
||||||
|
WHERE twl.task_id = $1 AND tm.id = $2
|
||||||
|
`;
|
||||||
|
|
||||||
|
const timeLogResult = await db.query(timeLogQuery, [taskId, member.team_member_id]);
|
||||||
|
const loggedHours = Number(timeLogResult.rows[0]?.logged_hours || 0);
|
||||||
|
|
||||||
|
membersWithRates.push({
|
||||||
|
team_member_id: member.team_member_id,
|
||||||
|
name: member.name || "Unknown User",
|
||||||
|
avatar_url: member.avatar_url,
|
||||||
|
hourly_rate: Number(member.hourly_rate || 0),
|
||||||
|
job_title_name: member.job_title_name || "Unassigned",
|
||||||
|
estimated_hours: task.assignees.length > 0 ? Number(task.estimated_hours) / task.assignees.length : 0,
|
||||||
|
logged_hours: loggedHours,
|
||||||
|
estimated_cost: (task.assignees.length > 0 ? Number(task.estimated_hours) / task.assignees.length : 0) * Number(member.hourly_rate || 0),
|
||||||
|
actual_cost: loggedHours * Number(member.hourly_rate || 0)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching member details:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group members by job title and calculate totals
|
||||||
|
const groupedMembers = membersWithRates.reduce((acc: any, member: any) => {
|
||||||
|
const jobRole = member.job_title_name || "Unassigned";
|
||||||
|
|
||||||
|
if (!acc[jobRole]) {
|
||||||
|
acc[jobRole] = {
|
||||||
|
jobRole,
|
||||||
|
estimated_hours: 0,
|
||||||
|
logged_hours: 0,
|
||||||
|
estimated_cost: 0,
|
||||||
|
actual_cost: 0,
|
||||||
|
members: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
acc[jobRole].estimated_hours += member.estimated_hours;
|
||||||
|
acc[jobRole].logged_hours += member.logged_hours;
|
||||||
|
acc[jobRole].estimated_cost += member.estimated_cost;
|
||||||
|
acc[jobRole].actual_cost += member.actual_cost;
|
||||||
|
acc[jobRole].members.push({
|
||||||
|
team_member_id: member.team_member_id,
|
||||||
|
name: member.name,
|
||||||
|
avatar_url: member.avatar_url,
|
||||||
|
hourly_rate: member.hourly_rate,
|
||||||
|
estimated_hours: member.estimated_hours,
|
||||||
|
logged_hours: member.logged_hours,
|
||||||
|
estimated_cost: member.estimated_cost,
|
||||||
|
actual_cost: member.actual_cost
|
||||||
|
});
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
// Calculate task totals
|
||||||
|
const taskTotals = {
|
||||||
|
estimated_hours: Number(task.estimated_hours || 0),
|
||||||
|
logged_hours: Number(task.total_time_logged || 0),
|
||||||
|
estimated_labor_cost: membersWithRates.reduce((sum, member) => sum + member.estimated_cost, 0),
|
||||||
|
actual_labor_cost: membersWithRates.reduce((sum, member) => sum + member.actual_cost, 0),
|
||||||
|
fixed_cost: Number(task.fixed_cost || 0),
|
||||||
|
total_estimated_cost: membersWithRates.reduce((sum, member) => sum + member.estimated_cost, 0) + Number(task.fixed_cost || 0),
|
||||||
|
total_actual_cost: membersWithRates.reduce((sum, member) => sum + member.actual_cost, 0) + Number(task.fixed_cost || 0)
|
||||||
|
};
|
||||||
|
|
||||||
|
const responseData = {
|
||||||
|
task: {
|
||||||
|
id: task.id,
|
||||||
|
name: task.name,
|
||||||
|
project_id: task.project_id,
|
||||||
|
billable: task.billable,
|
||||||
|
...taskTotals
|
||||||
|
},
|
||||||
|
grouped_members: Object.values(groupedMembers),
|
||||||
|
members: membersWithRates
|
||||||
|
};
|
||||||
|
|
||||||
|
return res.status(200).send(new ServerResponse(true, responseData));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,7 +11,7 @@
|
|||||||
|
|
||||||
"taskColumn": "Task",
|
"taskColumn": "Task",
|
||||||
"membersColumn": "Members",
|
"membersColumn": "Members",
|
||||||
"hoursColumn": "Hours",
|
"hoursColumn": "Estimated Hours",
|
||||||
"totalTimeLoggedColumn": "Total Time Logged",
|
"totalTimeLoggedColumn": "Total Time Logged",
|
||||||
"costColumn": "Cost",
|
"costColumn": "Cost",
|
||||||
"estimatedCostColumn": "Estimated Cost",
|
"estimatedCostColumn": "Estimated Cost",
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
"taskColumn": "Tarea",
|
"taskColumn": "Tarea",
|
||||||
"membersColumn": "Miembros",
|
"membersColumn": "Miembros",
|
||||||
"hoursColumn": "Horas",
|
"hoursColumn": "Horas Estimadas",
|
||||||
"totalTimeLoggedColumn": "Tiempo Total Registrado",
|
"totalTimeLoggedColumn": "Tiempo Total Registrado",
|
||||||
"costColumn": "Costo",
|
"costColumn": "Costo",
|
||||||
"estimatedCostColumn": "Costo Estimado",
|
"estimatedCostColumn": "Costo Estimado",
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
"taskColumn": "Tarefa",
|
"taskColumn": "Tarefa",
|
||||||
"membersColumn": "Membros",
|
"membersColumn": "Membros",
|
||||||
"hoursColumn": "Horas",
|
"hoursColumn": "Horas Estimadas",
|
||||||
"totalTimeLoggedColumn": "Tempo Total Registrado",
|
"totalTimeLoggedColumn": "Tempo Total Registrado",
|
||||||
"costColumn": "Custo",
|
"costColumn": "Custo",
|
||||||
"estimatedCostColumn": "Custo Estimado",
|
"estimatedCostColumn": "Custo Estimado",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { API_BASE_URL } from "@/shared/constants";
|
import { API_BASE_URL } from "@/shared/constants";
|
||||||
import { IServerResponse } from "@/types/common.types";
|
import { IServerResponse } from "@/types/common.types";
|
||||||
import apiClient from "../api-client";
|
import apiClient from "../api-client";
|
||||||
import { IProjectFinanceGroup } from "@/types/project/project-finance.types";
|
import { IProjectFinanceResponse, ITaskBreakdownResponse } from "@/types/project/project-finance.types";
|
||||||
|
|
||||||
const rootUrl = `${API_BASE_URL}/project-finance`;
|
const rootUrl = `${API_BASE_URL}/project-finance`;
|
||||||
|
|
||||||
@@ -9,8 +9,8 @@ export const projectFinanceApiService = {
|
|||||||
getProjectTasks: async (
|
getProjectTasks: async (
|
||||||
projectId: string,
|
projectId: string,
|
||||||
groupBy: 'status' | 'priority' | 'phases' = 'status'
|
groupBy: 'status' | 'priority' | 'phases' = 'status'
|
||||||
): Promise<IServerResponse<IProjectFinanceGroup[]>> => {
|
): Promise<IServerResponse<IProjectFinanceResponse>> => {
|
||||||
const response = await apiClient.get<IServerResponse<IProjectFinanceGroup[]>>(
|
const response = await apiClient.get<IServerResponse<IProjectFinanceResponse>>(
|
||||||
`${rootUrl}/project/${projectId}/tasks`,
|
`${rootUrl}/project/${projectId}/tasks`,
|
||||||
{
|
{
|
||||||
params: { group_by: groupBy }
|
params: { group_by: groupBy }
|
||||||
@@ -20,6 +20,15 @@ export const projectFinanceApiService = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getTaskBreakdown: async (
|
||||||
|
taskId: string
|
||||||
|
): Promise<IServerResponse<ITaskBreakdownResponse>> => {
|
||||||
|
const response = await apiClient.get<IServerResponse<ITaskBreakdownResponse>>(
|
||||||
|
`${rootUrl}/task/${taskId}/breakdown`
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
updateTaskFixedCost: async (
|
updateTaskFixedCost: async (
|
||||||
taskId: string,
|
taskId: string,
|
||||||
fixedCost: number
|
fixedCost: number
|
||||||
|
|||||||
@@ -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%',
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { projectFinanceApiService } from '@/api/project-finance-ratecard/project-finance.api.service';
|
import { projectFinanceApiService } from '@/api/project-finance-ratecard/project-finance.api.service';
|
||||||
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
|
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||||
import { IProjectFinanceGroup, IProjectFinanceTask } from '@/types/project/project-finance.types';
|
import { IProjectFinanceGroup, IProjectFinanceTask, IProjectRateCard } from '@/types/project/project-finance.types';
|
||||||
|
|
||||||
type FinanceTabType = 'finance' | 'ratecard';
|
type FinanceTabType = 'finance' | 'ratecard';
|
||||||
type GroupTypes = 'status' | 'priority' | 'phases';
|
type GroupTypes = 'status' | 'priority' | 'phases';
|
||||||
@@ -10,6 +10,7 @@ interface ProjectFinanceState {
|
|||||||
activeGroup: GroupTypes;
|
activeGroup: GroupTypes;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
taskGroups: IProjectFinanceGroup[];
|
taskGroups: IProjectFinanceGroup[];
|
||||||
|
projectRateCards: IProjectRateCard[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Utility functions for frontend calculations
|
// Utility functions for frontend calculations
|
||||||
@@ -67,6 +68,7 @@ const initialState: ProjectFinanceState = {
|
|||||||
activeGroup: 'status',
|
activeGroup: 'status',
|
||||||
loading: false,
|
loading: false,
|
||||||
taskGroups: [],
|
taskGroups: [],
|
||||||
|
projectRateCards: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fetchProjectFinances = createAsyncThunk(
|
export const fetchProjectFinances = createAsyncThunk(
|
||||||
@@ -77,6 +79,14 @@ export const fetchProjectFinances = createAsyncThunk(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const updateTaskFixedCostAsync = createAsyncThunk(
|
||||||
|
'projectFinances/updateTaskFixedCostAsync',
|
||||||
|
async ({ taskId, groupId, fixedCost }: { taskId: string; groupId: string; fixedCost: number }) => {
|
||||||
|
await projectFinanceApiService.updateTaskFixedCost(taskId, fixedCost);
|
||||||
|
return { taskId, groupId, fixedCost };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
export const projectFinancesSlice = createSlice({
|
export const projectFinancesSlice = createSlice({
|
||||||
name: 'projectFinances',
|
name: 'projectFinances',
|
||||||
initialState,
|
initialState,
|
||||||
@@ -140,10 +150,26 @@ export const projectFinancesSlice = createSlice({
|
|||||||
})
|
})
|
||||||
.addCase(fetchProjectFinances.fulfilled, (state, action) => {
|
.addCase(fetchProjectFinances.fulfilled, (state, action) => {
|
||||||
state.loading = false;
|
state.loading = false;
|
||||||
state.taskGroups = action.payload;
|
state.taskGroups = action.payload.groups;
|
||||||
|
state.projectRateCards = action.payload.project_rate_cards;
|
||||||
})
|
})
|
||||||
.addCase(fetchProjectFinances.rejected, (state) => {
|
.addCase(fetchProjectFinances.rejected, (state) => {
|
||||||
state.loading = false;
|
state.loading = false;
|
||||||
|
})
|
||||||
|
.addCase(updateTaskFixedCostAsync.fulfilled, (state, action) => {
|
||||||
|
const { taskId, groupId, fixedCost } = action.payload;
|
||||||
|
const group = state.taskGroups.find(g => g.group_id === groupId);
|
||||||
|
if (group) {
|
||||||
|
const task = group.tasks.find(t => t.id === taskId);
|
||||||
|
if (task) {
|
||||||
|
task.fixed_cost = fixedCost;
|
||||||
|
// Recalculate task costs after updating fixed cost
|
||||||
|
const { totalBudget, totalActual, variance } = calculateTaskCosts(task);
|
||||||
|
task.total_budget = totalBudget;
|
||||||
|
task.total_actual = totalActual;
|
||||||
|
task.variance = variance;
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -1,5 +1,18 @@
|
|||||||
|
export enum FinanceTableColumnKeys {
|
||||||
|
TASK = 'task',
|
||||||
|
MEMBERS = 'members',
|
||||||
|
HOURS = 'hours',
|
||||||
|
TOTAL_TIME_LOGGED = 'total_time_logged',
|
||||||
|
ESTIMATED_COST = 'estimated_cost',
|
||||||
|
COST = 'cost',
|
||||||
|
FIXED_COST = 'fixedCost',
|
||||||
|
TOTAL_BUDGET = 'totalBudget',
|
||||||
|
TOTAL_ACTUAL = 'totalActual',
|
||||||
|
VARIANCE = 'variance',
|
||||||
|
}
|
||||||
|
|
||||||
type FinanceTableColumnsType = {
|
type FinanceTableColumnsType = {
|
||||||
key: string;
|
key: FinanceTableColumnKeys;
|
||||||
name: string;
|
name: string;
|
||||||
width: number;
|
width: number;
|
||||||
type: 'string' | 'hours' | 'currency';
|
type: 'string' | 'hours' | 'currency';
|
||||||
@@ -9,61 +22,61 @@ type FinanceTableColumnsType = {
|
|||||||
// finance table columns
|
// finance table columns
|
||||||
export const financeTableColumns: FinanceTableColumnsType[] = [
|
export const financeTableColumns: FinanceTableColumnsType[] = [
|
||||||
{
|
{
|
||||||
key: 'task',
|
key: FinanceTableColumnKeys.TASK,
|
||||||
name: 'taskColumn',
|
name: 'taskColumn',
|
||||||
width: 240,
|
width: 240,
|
||||||
type: 'string',
|
type: 'string',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'members',
|
key: FinanceTableColumnKeys.MEMBERS,
|
||||||
name: 'membersColumn',
|
name: 'membersColumn',
|
||||||
width: 160,
|
width: 160,
|
||||||
type: 'string',
|
type: 'string',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'hours',
|
key: FinanceTableColumnKeys.HOURS,
|
||||||
name: 'hoursColumn',
|
name: 'hoursColumn',
|
||||||
width: 80,
|
width: 100,
|
||||||
type: 'hours',
|
type: 'hours',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'total_time_logged',
|
key: FinanceTableColumnKeys.TOTAL_TIME_LOGGED,
|
||||||
name: 'totalTimeLoggedColumn',
|
name: 'totalTimeLoggedColumn',
|
||||||
width: 120,
|
width: 120,
|
||||||
type: 'hours',
|
type: 'hours',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'estimated_cost',
|
key: FinanceTableColumnKeys.ESTIMATED_COST,
|
||||||
name: 'estimatedCostColumn',
|
name: 'estimatedCostColumn',
|
||||||
width: 120,
|
width: 120,
|
||||||
type: 'currency',
|
type: 'currency',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'cost',
|
key: FinanceTableColumnKeys.COST,
|
||||||
name: 'costColumn',
|
name: 'costColumn',
|
||||||
width: 120,
|
width: 120,
|
||||||
type: 'currency',
|
type: 'currency',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'fixedCost',
|
key: FinanceTableColumnKeys.FIXED_COST,
|
||||||
name: 'fixedCostColumn',
|
name: 'fixedCostColumn',
|
||||||
width: 120,
|
width: 120,
|
||||||
type: 'currency',
|
type: 'currency',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'totalBudget',
|
key: FinanceTableColumnKeys.TOTAL_BUDGET,
|
||||||
name: 'totalBudgetedCostColumn',
|
name: 'totalBudgetedCostColumn',
|
||||||
width: 120,
|
width: 120,
|
||||||
type: 'currency',
|
type: 'currency',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'totalActual',
|
key: FinanceTableColumnKeys.TOTAL_ACTUAL,
|
||||||
name: 'totalActualCostColumn',
|
name: 'totalActualCostColumn',
|
||||||
width: 120,
|
width: 120,
|
||||||
type: 'currency',
|
type: 'currency',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'variance',
|
key: FinanceTableColumnKeys.VARIANCE,
|
||||||
name: 'varianceColumn',
|
name: 'varianceColumn',
|
||||||
width: 120,
|
width: 120,
|
||||||
type: 'currency',
|
type: 'currency',
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import React from 'react';
|
|
||||||
import FinanceTableWrapper from './finance-table/finance-table-wrapper';
|
import FinanceTableWrapper from './finance-table/finance-table-wrapper';
|
||||||
import { IProjectFinanceGroup } from '@/types/project/project-finance.types';
|
import { IProjectFinanceGroup } from '@/types/project/project-finance.types';
|
||||||
|
|
||||||
@@ -23,11 +22,11 @@ const FinanceTab = ({
|
|||||||
id: task.id,
|
id: task.id,
|
||||||
name: task.name,
|
name: task.name,
|
||||||
hours: task.estimated_hours || 0,
|
hours: task.estimated_hours || 0,
|
||||||
cost: 0, // TODO: Calculate based on rate and hours
|
cost: task.estimated_cost || 0,
|
||||||
fixedCost: 0, // TODO: Add fixed cost field
|
fixedCost: task.fixed_cost || 0,
|
||||||
totalBudget: 0, // TODO: Calculate total budget
|
totalBudget: task.total_budget || 0,
|
||||||
totalActual: task.total_actual || 0,
|
totalActual: task.total_actual || 0,
|
||||||
variance: 0, // TODO: Calculate variance
|
variance: task.variance || 0,
|
||||||
members: task.members || [],
|
members: task.members || [],
|
||||||
isbBillable: task.billable,
|
isbBillable: task.billable,
|
||||||
total_time_logged: task.total_time_logged || 0,
|
total_time_logged: task.total_time_logged || 0,
|
||||||
|
|||||||
@@ -1,52 +1,34 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState, useMemo } from 'react';
|
||||||
import { Checkbox, Flex, Tooltip, Typography } from 'antd';
|
import { Flex, InputNumber, Tooltip, Typography } from 'antd';
|
||||||
import { themeWiseColor } from '../../../../../../utils/themeWiseColor';
|
import { themeWiseColor } from '@/utils/themeWiseColor';
|
||||||
import { useAppSelector } from '../../../../../../hooks/useAppSelector';
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useAppDispatch } from '../../../../../../hooks/useAppDispatch';
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
import { toggleFinanceDrawer } from '@/features/finance/finance-slice';
|
import { openFinanceDrawer } from '@/features/finance/finance-slice';
|
||||||
import { financeTableColumns } from '@/lib/project/project-view-finance-table-columns';
|
import { financeTableColumns, FinanceTableColumnKeys } from '@/lib/project/project-view-finance-table-columns';
|
||||||
import FinanceTable from './finance-table';
|
import FinanceTable from './finance-table';
|
||||||
import FinanceDrawer from '@/features/finance/finance-drawer/finance-drawer';
|
import FinanceDrawer from '@/features/finance/finance-drawer/finance-drawer';
|
||||||
import { convertToHoursMinutes, formatHoursToReadable } from '@/utils/format-hours-to-readable';
|
import { convertToHoursMinutes, formatHoursToReadable } from '@/utils/format-hours-to-readable';
|
||||||
|
import { IProjectFinanceGroup } from '@/types/project/project-finance.types';
|
||||||
|
import { updateTaskFixedCostAsync } from '@/features/projects/finance/project-finance.slice';
|
||||||
|
|
||||||
interface FinanceTableWrapperProps {
|
interface FinanceTableWrapperProps {
|
||||||
activeTablesList: {
|
activeTablesList: IProjectFinanceGroup[];
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
color_code: string;
|
|
||||||
color_code_dark: string;
|
|
||||||
tasks: {
|
|
||||||
taskId: string;
|
|
||||||
task: string;
|
|
||||||
hours: number;
|
|
||||||
cost: number;
|
|
||||||
fixedCost: number;
|
|
||||||
totalBudget: number;
|
|
||||||
totalActual: number;
|
|
||||||
variance: number;
|
|
||||||
members: any[];
|
|
||||||
isbBillable: boolean;
|
|
||||||
total_time_logged: number;
|
|
||||||
estimated_cost: number;
|
|
||||||
}[];
|
|
||||||
}[];
|
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FinanceTableWrapper: React.FC<FinanceTableWrapperProps> = ({
|
const FinanceTableWrapper: React.FC<FinanceTableWrapperProps> = ({ activeTablesList, loading }) => {
|
||||||
activeTablesList,
|
|
||||||
loading
|
|
||||||
}) => {
|
|
||||||
const [isScrolling, setIsScrolling] = useState(false);
|
const [isScrolling, setIsScrolling] = useState(false);
|
||||||
const [selectedTask, setSelectedTask] = useState(null);
|
const [editingFixedCost, setEditingFixedCost] = useState<{ taskId: string; groupId: string } | null>(null);
|
||||||
|
|
||||||
const { t } = useTranslation('project-view-finance');
|
const { t } = useTranslation('project-view-finance');
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
// Get selected task from Redux store
|
||||||
|
const selectedTask = useAppSelector(state => state.financeReducer.selectedTask);
|
||||||
|
|
||||||
const onTaskClick = (task: any) => {
|
const onTaskClick = (task: any) => {
|
||||||
setSelectedTask(task);
|
dispatch(openFinanceDrawer(task));
|
||||||
dispatch(toggleFinanceDrawer());
|
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -63,84 +45,89 @@ const FinanceTableWrapper: React.FC<FinanceTableWrapperProps> = ({
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const themeMode = useAppSelector((state) => state.themeReducer.mode);
|
// Handle click outside to close editing
|
||||||
const { currency } = useAppSelector((state) => state.financeReducer);
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (editingFixedCost && !(event.target as Element)?.closest('.fixed-cost-input')) {
|
||||||
|
setEditingFixedCost(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const totals = activeTablesList.reduce(
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
(
|
return () => {
|
||||||
acc: {
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
hours: number;
|
};
|
||||||
cost: number;
|
}, [editingFixedCost]);
|
||||||
fixedCost: number;
|
|
||||||
totalBudget: number;
|
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||||
totalActual: number;
|
const { currency } = useAppSelector(state => state.financeReducer);
|
||||||
variance: number;
|
const taskGroups = useAppSelector(state => state.projectFinances.taskGroups);
|
||||||
total_time_logged: number;
|
|
||||||
estimated_cost: number;
|
// Use Redux store data for totals calculation to ensure reactivity
|
||||||
|
const totals = useMemo(() => {
|
||||||
|
return taskGroups.reduce(
|
||||||
|
(
|
||||||
|
acc: {
|
||||||
|
hours: number;
|
||||||
|
cost: number;
|
||||||
|
fixedCost: number;
|
||||||
|
totalBudget: number;
|
||||||
|
totalActual: number;
|
||||||
|
variance: number;
|
||||||
|
total_time_logged: number;
|
||||||
|
estimated_cost: number;
|
||||||
|
},
|
||||||
|
table: IProjectFinanceGroup
|
||||||
|
) => {
|
||||||
|
table.tasks.forEach((task) => {
|
||||||
|
acc.hours += (task.estimated_hours / 60) || 0;
|
||||||
|
acc.cost += task.estimated_cost || 0;
|
||||||
|
acc.fixedCost += task.fixed_cost || 0;
|
||||||
|
acc.totalBudget += task.total_budget || 0;
|
||||||
|
acc.totalActual += task.total_actual || 0;
|
||||||
|
acc.variance += task.variance || 0;
|
||||||
|
acc.total_time_logged += (task.total_time_logged / 60) || 0;
|
||||||
|
acc.estimated_cost += task.estimated_cost || 0;
|
||||||
|
});
|
||||||
|
return acc;
|
||||||
},
|
},
|
||||||
table: { tasks: any[] }
|
{
|
||||||
) => {
|
hours: 0,
|
||||||
table.tasks.forEach((task: any) => {
|
cost: 0,
|
||||||
acc.hours += task.hours || 0;
|
fixedCost: 0,
|
||||||
acc.cost += task.cost || 0;
|
totalBudget: 0,
|
||||||
acc.fixedCost += task.fixedCost || 0;
|
totalActual: 0,
|
||||||
acc.totalBudget += task.totalBudget || 0;
|
variance: 0,
|
||||||
acc.totalActual += task.totalActual || 0;
|
total_time_logged: 0,
|
||||||
acc.variance += task.variance || 0;
|
estimated_cost: 0,
|
||||||
acc.total_time_logged += task.total_time_logged || 0;
|
}
|
||||||
acc.estimated_cost += task.estimated_cost || 0;
|
);
|
||||||
});
|
}, [taskGroups]);
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{
|
|
||||||
hours: 0,
|
|
||||||
cost: 0,
|
|
||||||
fixedCost: 0,
|
|
||||||
totalBudget: 0,
|
|
||||||
totalActual: 0,
|
|
||||||
variance: 0,
|
|
||||||
total_time_logged: 0,
|
|
||||||
estimated_cost: 0,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log("totals", totals);
|
const handleFixedCostChange = (value: number | null, taskId: string, groupId: string) => {
|
||||||
|
dispatch(updateTaskFixedCostAsync({ taskId, groupId, fixedCost: value || 0 }));
|
||||||
|
setEditingFixedCost(null);
|
||||||
|
};
|
||||||
|
|
||||||
const renderFinancialTableHeaderContent = (columnKey: any) => {
|
const renderFinancialTableHeaderContent = (columnKey: FinanceTableColumnKeys) => {
|
||||||
switch (columnKey) {
|
switch (columnKey) {
|
||||||
case 'hours':
|
case FinanceTableColumnKeys.HOURS:
|
||||||
return (
|
return (
|
||||||
<Typography.Text style={{ fontSize: 18 }}>
|
<Typography.Text style={{ fontSize: 18 }}>
|
||||||
<Tooltip title={convertToHoursMinutes(totals.hours)}>
|
<Tooltip title={convertToHoursMinutes(totals.hours)}>
|
||||||
{formatHoursToReadable(totals.hours)}
|
{formatHoursToReadable(totals.hours).toFixed(2)}
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
);
|
);
|
||||||
case 'cost':
|
case FinanceTableColumnKeys.COST:
|
||||||
return (
|
return <Typography.Text style={{ fontSize: 18 }}>{`${totals.cost?.toFixed(2)}`}</Typography.Text>;
|
||||||
<Typography.Text style={{ fontSize: 18 }}>
|
case FinanceTableColumnKeys.FIXED_COST:
|
||||||
{totals.cost}
|
return <Typography.Text style={{ fontSize: 18 }}>{totals.fixedCost?.toFixed(2)}</Typography.Text>;
|
||||||
</Typography.Text>
|
case FinanceTableColumnKeys.TOTAL_BUDGET:
|
||||||
);
|
return <Typography.Text style={{ fontSize: 18 }}>{totals.totalBudget?.toFixed(2)}</Typography.Text>;
|
||||||
case 'fixedCost':
|
case FinanceTableColumnKeys.TOTAL_ACTUAL:
|
||||||
return (
|
return <Typography.Text style={{ fontSize: 18 }}>{totals.totalActual?.toFixed(2)}</Typography.Text>;
|
||||||
<Typography.Text style={{ fontSize: 18 }}>
|
case FinanceTableColumnKeys.VARIANCE:
|
||||||
{totals.fixedCost}
|
|
||||||
</Typography.Text>
|
|
||||||
);
|
|
||||||
case 'totalBudget':
|
|
||||||
return (
|
|
||||||
<Typography.Text style={{ fontSize: 18 }}>
|
|
||||||
{totals.totalBudget}
|
|
||||||
</Typography.Text>
|
|
||||||
);
|
|
||||||
case 'totalActual':
|
|
||||||
return (
|
|
||||||
<Typography.Text style={{ fontSize: 18 }}>
|
|
||||||
{totals.totalActual}
|
|
||||||
</Typography.Text>
|
|
||||||
);
|
|
||||||
case 'variance':
|
|
||||||
return (
|
return (
|
||||||
<Typography.Text
|
<Typography.Text
|
||||||
style={{
|
style={{
|
||||||
@@ -148,19 +135,19 @@ const FinanceTableWrapper: React.FC<FinanceTableWrapperProps> = ({
|
|||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{totals.variance}
|
{`${totals.variance?.toFixed(2)}`}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
);
|
);
|
||||||
case 'total_time_logged':
|
case FinanceTableColumnKeys.TOTAL_TIME_LOGGED:
|
||||||
return (
|
return (
|
||||||
<Typography.Text style={{ fontSize: 18 }}>
|
<Typography.Text style={{ fontSize: 18 }}>
|
||||||
{totals.total_time_logged?.toFixed(2)}
|
{totals.total_time_logged?.toFixed(2)}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
);
|
);
|
||||||
case 'estimated_cost':
|
case FinanceTableColumnKeys.ESTIMATED_COST:
|
||||||
return (
|
return (
|
||||||
<Typography.Text style={{ fontSize: 18 }}>
|
<Typography.Text style={{ fontSize: 18 }}>
|
||||||
{`${currency.toUpperCase()} ${totals.estimated_cost?.toFixed(2)}`}
|
{`${totals.estimated_cost?.toFixed(2)}`}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
@@ -168,54 +155,37 @@ const FinanceTableWrapper: React.FC<FinanceTableWrapperProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const customColumnHeaderStyles = (key: string) =>
|
const customColumnHeaderStyles = (key: FinanceTableColumnKeys) =>
|
||||||
`px-2 text-left ${key === 'selector' && 'sticky left-0 z-10'} ${key === 'task' && 'sticky left-[48px] z-10'} ${key === 'members' && `sticky left-[288px] z-10 ${isScrolling ? 'after:content after:absolute after:top-0 after:-right-1 after:-z-10 after:h-[68px] after:w-1.5 after:bg-transparent after:bg-gradient-to-r after:from-[rgba(0,0,0,0.12)] after:to-transparent' : ''}`} ${themeMode === 'dark' ? 'bg-[#1d1d1d] border-[#303030]' : 'bg-[#fafafa]'}`;
|
`px-2 text-left ${key === FinanceTableColumnKeys.TASK && 'sticky left-0 z-10'} ${key === FinanceTableColumnKeys.MEMBERS && `sticky left-[240px] z-10 ${isScrolling ? 'after:content after:absolute after:top-0 after:-right-1 after:-z-10 after:h-[68px] after:w-1.5 after:bg-transparent after:bg-gradient-to-r after:from-[rgba(0,0,0,0.12)] after:to-transparent' : ''}`} ${themeMode === 'dark' ? 'bg-[#1d1d1d] border-[#303030]' : 'bg-[#fafafa]'}`;
|
||||||
|
|
||||||
const customColumnStyles = (key: string) =>
|
const customColumnStyles = (key: FinanceTableColumnKeys) =>
|
||||||
`px-2 text-left ${key === 'totalRow' && `sticky left-0 z-10 ${isScrolling ? 'after:content after:absolute after:top-0 after:-right-1 after:-z-10 after:h-[68px] after:w-1.5 after:bg-transparent after:bg-gradient-to-r after:from-[rgba(0,0,0,0.12)] after:to-transparent' : ''}`}`;
|
`px-2 text-left ${key === FinanceTableColumnKeys.TASK && `sticky left-0 z-10 ${isScrolling ? 'after:content after:absolute after:top-0 after:-right-1 after:-z-10 after:h-[68px] after:w-1.5 after:bg-transparent after:bg-gradient-to-r after:from-[rgba(0,0,0,0.12)] after:to-transparent' : ''}`} ${themeMode === 'dark' ? 'bg-[#141414]' : 'bg-[#fbfbfb]'}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Flex
|
<Flex vertical className="tasklist-container min-h-0 max-w-full overflow-x-auto">
|
||||||
vertical
|
|
||||||
className="tasklist-container min-h-0 max-w-full overflow-x-auto"
|
|
||||||
>
|
|
||||||
<table>
|
<table>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr
|
<tr
|
||||||
style={{
|
style={{
|
||||||
height: 56,
|
height: 56,
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
backgroundColor: themeWiseColor(
|
backgroundColor: themeWiseColor('#fafafa', '#1d1d1d', themeMode),
|
||||||
'#fafafa',
|
|
||||||
'#1d1d1d',
|
|
||||||
themeMode
|
|
||||||
),
|
|
||||||
borderBlockEnd: `2px solid rgb(0 0 0 / 0.05)`,
|
borderBlockEnd: `2px solid rgb(0 0 0 / 0.05)`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<td
|
{financeTableColumns.map(col => (
|
||||||
style={{ width: 32, paddingInline: 16 }}
|
|
||||||
className={customColumnHeaderStyles('selector')}
|
|
||||||
>
|
|
||||||
<Checkbox />
|
|
||||||
</td>
|
|
||||||
{financeTableColumns.map((col) => (
|
|
||||||
<td
|
<td
|
||||||
key={col.key}
|
key={col.key}
|
||||||
style={{
|
style={{
|
||||||
minWidth: col.width,
|
minWidth: col.width,
|
||||||
paddingInline: 16,
|
paddingInline: 16,
|
||||||
textAlign:
|
textAlign: col.type === 'hours' || col.type === 'currency' ? 'center' : 'start',
|
||||||
col.type === 'hours' || col.type === 'currency'
|
|
||||||
? 'center'
|
|
||||||
: 'start',
|
|
||||||
}}
|
}}
|
||||||
className={`${customColumnHeaderStyles(col.key)} before:constent relative before:absolute before:left-0 before:top-1/2 before:h-[36px] before:w-0.5 before:-translate-y-1/2 ${themeMode === 'dark' ? 'before:bg-white/10' : 'before:bg-black/5'}`}
|
className={`${customColumnHeaderStyles(col.key)} before:constent relative before:absolute before:left-0 before:top-1/2 before:h-[36px] before:w-0.5 before:-translate-y-1/2 ${themeMode === 'dark' ? 'before:bg-white/10' : 'before:bg-black/5'}`}
|
||||||
>
|
>
|
||||||
<Typography.Text>
|
<Typography.Text>
|
||||||
{t(`${col.name}`)}{' '}
|
{t(`${col.name}`)} {col.type === 'currency' && `(${currency.toUpperCase()})`}
|
||||||
{col.type === 'currency' && `(${currency.toUpperCase()})`}
|
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</td>
|
</td>
|
||||||
))}
|
))}
|
||||||
@@ -225,59 +195,43 @@ const FinanceTableWrapper: React.FC<FinanceTableWrapperProps> = ({
|
|||||||
style={{
|
style={{
|
||||||
height: 56,
|
height: 56,
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
backgroundColor: themeWiseColor(
|
backgroundColor: themeWiseColor('#fbfbfb', '#141414', themeMode),
|
||||||
'#fbfbfb',
|
|
||||||
'#141414',
|
|
||||||
themeMode
|
|
||||||
),
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<td
|
{financeTableColumns.map((col, index) => (
|
||||||
colSpan={3}
|
<td
|
||||||
style={{
|
key={col.key}
|
||||||
paddingInline: 16,
|
style={{
|
||||||
backgroundColor: themeWiseColor(
|
minWidth: col.width,
|
||||||
'#fbfbfb',
|
paddingInline: 16,
|
||||||
'#141414',
|
textAlign: col.key === FinanceTableColumnKeys.TASK ? 'left' : 'right',
|
||||||
themeMode
|
backgroundColor: themeWiseColor('#fbfbfb', '#141414', themeMode),
|
||||||
),
|
}}
|
||||||
}}
|
className={customColumnStyles(col.key)}
|
||||||
className={customColumnStyles('totalRow')}
|
>
|
||||||
>
|
{col.key === FinanceTableColumnKeys.TASK ? (
|
||||||
<Typography.Text style={{ fontSize: 18 }}>
|
<Typography.Text style={{ fontSize: 18 }}>{t('totalText')}</Typography.Text>
|
||||||
{t('totalText')}
|
) : col.key === FinanceTableColumnKeys.MEMBERS ? null : (
|
||||||
</Typography.Text>
|
(col.type === 'hours' || col.type === 'currency') && renderFinancialTableHeaderContent(col.key)
|
||||||
</td>
|
)}
|
||||||
{financeTableColumns.map(
|
</td>
|
||||||
(col) =>
|
))}
|
||||||
(col.type === 'hours' || col.type === 'currency') && (
|
|
||||||
<td
|
|
||||||
key={col.key}
|
|
||||||
style={{
|
|
||||||
minWidth: col.width,
|
|
||||||
paddingInline: 16,
|
|
||||||
textAlign: 'end',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{renderFinancialTableHeaderContent(col.key)}
|
|
||||||
</td>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
{activeTablesList.map((table, index) => (
|
{activeTablesList.map((table) => (
|
||||||
<FinanceTable
|
<FinanceTable
|
||||||
key={index}
|
key={table.group_id}
|
||||||
table={table}
|
table={table}
|
||||||
isScrolling={isScrolling}
|
isScrolling={isScrolling}
|
||||||
onTaskClick={onTaskClick}
|
onTaskClick={onTaskClick}
|
||||||
|
loading={loading}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
{selectedTask && <FinanceDrawer task={selectedTask} />}
|
<FinanceDrawer />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,20 +8,24 @@ import {
|
|||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { themeWiseColor } from '@/utils/themeWiseColor';
|
import { themeWiseColor } from '@/utils/themeWiseColor';
|
||||||
import { colors } from '@/styles/colors';
|
import { colors } from '@/styles/colors';
|
||||||
import { financeTableColumns } from '@/lib/project/project-view-finance-table-columns';
|
import { financeTableColumns, FinanceTableColumnKeys } from '@/lib/project/project-view-finance-table-columns';
|
||||||
import Avatars from '@/components/avatars/avatars';
|
import Avatars from '@/components/avatars/avatars';
|
||||||
import { IProjectFinanceGroup, IProjectFinanceTask } from '@/types/project/project-finance.types';
|
import { IProjectFinanceGroup, IProjectFinanceTask } from '@/types/project/project-finance.types';
|
||||||
import { updateTaskFixedCost } from '@/features/projects/finance/project-finance.slice';
|
import { updateTaskFixedCostAsync, updateTaskFixedCost } from '@/features/projects/finance/project-finance.slice';
|
||||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
|
|
||||||
type FinanceTableProps = {
|
type FinanceTableProps = {
|
||||||
table: IProjectFinanceGroup;
|
table: IProjectFinanceGroup;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
|
isScrolling: boolean;
|
||||||
|
onTaskClick: (task: any) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const FinanceTable = ({
|
const FinanceTable = ({
|
||||||
table,
|
table,
|
||||||
loading,
|
loading,
|
||||||
|
isScrolling,
|
||||||
|
onTaskClick,
|
||||||
}: FinanceTableProps) => {
|
}: FinanceTableProps) => {
|
||||||
const [isCollapse, setIsCollapse] = useState<boolean>(false);
|
const [isCollapse, setIsCollapse] = useState<boolean>(false);
|
||||||
const [selectedTask, setSelectedTask] = useState<IProjectFinanceTask | null>(null);
|
const [selectedTask, setSelectedTask] = useState<IProjectFinanceTask | null>(null);
|
||||||
@@ -41,6 +45,20 @@ const FinanceTable = ({
|
|||||||
}
|
}
|
||||||
}, [table.tasks, taskGroups, table.group_id]);
|
}, [table.tasks, taskGroups, table.group_id]);
|
||||||
|
|
||||||
|
// Handle click outside to close editing
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (selectedTask && !(event.target as Element)?.closest('.fixed-cost-input')) {
|
||||||
|
setSelectedTask(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
};
|
||||||
|
}, [selectedTask]);
|
||||||
|
|
||||||
// get theme data from theme reducer
|
// get theme data from theme reducer
|
||||||
const themeMode = useAppSelector((state) => state.themeReducer.mode);
|
const themeMode = useAppSelector((state) => state.themeReducer.mode);
|
||||||
|
|
||||||
@@ -49,19 +67,28 @@ const FinanceTable = ({
|
|||||||
return value.toFixed(2);
|
return value.toFixed(2);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderFinancialTableHeaderContent = (columnKey: string) => {
|
// Custom column styles for sticky positioning
|
||||||
|
const customColumnStyles = (key: FinanceTableColumnKeys) =>
|
||||||
|
`px-2 text-left ${key === FinanceTableColumnKeys.TASK && 'sticky left-0 z-10'} ${key === FinanceTableColumnKeys.MEMBERS && `sticky left-[240px] z-10 ${isScrolling ? 'after:content after:absolute after:top-0 after:-right-1 after:-z-10 after:h-[40px] after:w-1.5 after:bg-transparent after:bg-gradient-to-r after:from-[rgba(0,0,0,0.12)] after:to-transparent' : ''}`} ${themeMode === 'dark' ? 'bg-[#1d1d1d] border-[#303030]' : 'bg-white'}`;
|
||||||
|
|
||||||
|
const customHeaderColumnStyles = (key: FinanceTableColumnKeys) =>
|
||||||
|
`px-2 text-left ${key === FinanceTableColumnKeys.TASK && 'sticky left-0 z-10'} ${key === FinanceTableColumnKeys.MEMBERS && `sticky left-[240px] z-10 ${isScrolling ? 'after:content after:absolute after:top-0 after:-right-1 after:-z-10 after:h-[40px] after:w-1.5 after:bg-transparent after:bg-gradient-to-r after:from-[rgba(0,0,0,0.12)] after:to-transparent' : ''}`}`;
|
||||||
|
|
||||||
|
const renderFinancialTableHeaderContent = (columnKey: FinanceTableColumnKeys) => {
|
||||||
switch (columnKey) {
|
switch (columnKey) {
|
||||||
case 'hours':
|
case FinanceTableColumnKeys.HOURS:
|
||||||
return <Typography.Text>{formatNumber(totals.hours)}</Typography.Text>;
|
return <Typography.Text>{formatNumber(totals.hours)}</Typography.Text>;
|
||||||
case 'total_time_logged':
|
case FinanceTableColumnKeys.TOTAL_TIME_LOGGED:
|
||||||
return <Typography.Text>{formatNumber(totals.total_time_logged)}</Typography.Text>;
|
return <Typography.Text>{formatNumber(totals.total_time_logged)}</Typography.Text>;
|
||||||
case 'estimated_cost':
|
case FinanceTableColumnKeys.ESTIMATED_COST:
|
||||||
return <Typography.Text>{formatNumber(totals.estimated_cost)}</Typography.Text>;
|
return <Typography.Text>{formatNumber(totals.estimated_cost)}</Typography.Text>;
|
||||||
case 'totalBudget':
|
case FinanceTableColumnKeys.FIXED_COST:
|
||||||
|
return <Typography.Text>{formatNumber(totals.fixed_cost)}</Typography.Text>;
|
||||||
|
case FinanceTableColumnKeys.TOTAL_BUDGET:
|
||||||
return <Typography.Text>{formatNumber(totals.total_budget)}</Typography.Text>;
|
return <Typography.Text>{formatNumber(totals.total_budget)}</Typography.Text>;
|
||||||
case 'totalActual':
|
case FinanceTableColumnKeys.TOTAL_ACTUAL:
|
||||||
return <Typography.Text>{formatNumber(totals.total_actual)}</Typography.Text>;
|
return <Typography.Text>{formatNumber(totals.total_actual)}</Typography.Text>;
|
||||||
case 'variance':
|
case FinanceTableColumnKeys.VARIANCE:
|
||||||
return <Typography.Text>{formatNumber(totals.variance)}</Typography.Text>;
|
return <Typography.Text>{formatNumber(totals.variance)}</Typography.Text>;
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
@@ -69,12 +96,18 @@ const FinanceTable = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleFixedCostChange = (value: number | null, taskId: string) => {
|
const handleFixedCostChange = (value: number | null, taskId: string) => {
|
||||||
dispatch(updateTaskFixedCost({ taskId, groupId: table.group_id, fixedCost: value || 0 }));
|
const fixedCost = value || 0;
|
||||||
|
|
||||||
|
// Optimistic update for immediate UI feedback
|
||||||
|
dispatch(updateTaskFixedCost({ taskId, groupId: table.group_id, fixedCost }));
|
||||||
|
|
||||||
|
// Then make the API call to persist the change
|
||||||
|
dispatch(updateTaskFixedCostAsync({ taskId, groupId: table.group_id, fixedCost }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderFinancialTableColumnContent = (columnKey: string, task: IProjectFinanceTask) => {
|
const renderFinancialTableColumnContent = (columnKey: FinanceTableColumnKeys, task: IProjectFinanceTask) => {
|
||||||
switch (columnKey) {
|
switch (columnKey) {
|
||||||
case 'task':
|
case FinanceTableColumnKeys.TASK:
|
||||||
return (
|
return (
|
||||||
<Tooltip title={task.name}>
|
<Tooltip title={task.name}>
|
||||||
<Flex gap={8} align="center">
|
<Flex gap={8} align="center">
|
||||||
@@ -88,22 +121,43 @@ const FinanceTable = ({
|
|||||||
</Flex>
|
</Flex>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
case 'members':
|
case FinanceTableColumnKeys.MEMBERS:
|
||||||
return task.members && (
|
return task.members && (
|
||||||
<Avatars
|
<div
|
||||||
members={task.members.map(member => ({
|
onClick={(e) => {
|
||||||
...member,
|
e.stopPropagation();
|
||||||
avatar_url: member.avatar_url || undefined
|
onTaskClick(task);
|
||||||
}))}
|
}}
|
||||||
/>
|
style={{
|
||||||
|
cursor: 'pointer',
|
||||||
|
width: '100%',
|
||||||
|
padding: '4px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
transition: 'background-color 0.2s'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = themeWiseColor('#f0f0f0', '#333', themeMode);
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = 'transparent';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Avatars
|
||||||
|
members={task.members.map(member => ({
|
||||||
|
...member,
|
||||||
|
avatar_url: member.avatar_url || undefined
|
||||||
|
}))}
|
||||||
|
allowClickThrough={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
case 'hours':
|
case FinanceTableColumnKeys.HOURS:
|
||||||
return <Typography.Text>{formatNumber(task.estimated_hours / 60)}</Typography.Text>;
|
return <Typography.Text>{formatNumber(task.estimated_hours / 60)}</Typography.Text>;
|
||||||
case 'total_time_logged':
|
case FinanceTableColumnKeys.TOTAL_TIME_LOGGED:
|
||||||
return <Typography.Text>{formatNumber(task.total_time_logged / 60)}</Typography.Text>;
|
return <Typography.Text>{formatNumber(task.total_time_logged / 60)}</Typography.Text>;
|
||||||
case 'estimated_cost':
|
case FinanceTableColumnKeys.ESTIMATED_COST:
|
||||||
return <Typography.Text>{formatNumber(task.estimated_cost)}</Typography.Text>;
|
return <Typography.Text>{formatNumber(task.estimated_cost)}</Typography.Text>;
|
||||||
case 'fixedCost':
|
case FinanceTableColumnKeys.FIXED_COST:
|
||||||
return selectedTask?.id === task.id ? (
|
return selectedTask?.id === task.id ? (
|
||||||
<InputNumber
|
<InputNumber
|
||||||
value={task.fixed_cost}
|
value={task.fixed_cost}
|
||||||
@@ -111,24 +165,37 @@ const FinanceTable = ({
|
|||||||
handleFixedCostChange(Number(e.target.value), task.id);
|
handleFixedCostChange(Number(e.target.value), task.id);
|
||||||
setSelectedTask(null);
|
setSelectedTask(null);
|
||||||
}}
|
}}
|
||||||
|
onPressEnter={(e) => {
|
||||||
|
handleFixedCostChange(Number((e.target as HTMLInputElement).value), task.id);
|
||||||
|
setSelectedTask(null);
|
||||||
|
}}
|
||||||
autoFocus
|
autoFocus
|
||||||
style={{ width: '100%', textAlign: 'right' }}
|
style={{ width: '100%', textAlign: 'right' }}
|
||||||
formatter={(value) => `${value}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',')}
|
formatter={(value) => `${value}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',')}
|
||||||
parser={(value) => Number(value!.replace(/\$\s?|(,*)/g, ''))}
|
parser={(value) => Number(value!.replace(/\$\s?|(,*)/g, ''))}
|
||||||
min={0}
|
min={0}
|
||||||
precision={2}
|
precision={2}
|
||||||
|
className="fixed-cost-input"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Typography.Text>{formatNumber(task.fixed_cost)}</Typography.Text>
|
<Typography.Text
|
||||||
|
style={{ cursor: 'pointer', width: '100%', display: 'block' }}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setSelectedTask(task);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatNumber(task.fixed_cost)}
|
||||||
|
</Typography.Text>
|
||||||
);
|
);
|
||||||
case 'variance':
|
case FinanceTableColumnKeys.VARIANCE:
|
||||||
return <Typography.Text>{formatNumber(task.variance)}</Typography.Text>;
|
return <Typography.Text>{formatNumber(task.variance)}</Typography.Text>;
|
||||||
case 'totalBudget':
|
case FinanceTableColumnKeys.TOTAL_BUDGET:
|
||||||
return <Typography.Text>{formatNumber(task.total_budget)}</Typography.Text>;
|
return <Typography.Text>{formatNumber(task.total_budget)}</Typography.Text>;
|
||||||
case 'totalActual':
|
case FinanceTableColumnKeys.TOTAL_ACTUAL:
|
||||||
return <Typography.Text>{formatNumber(task.total_actual)}</Typography.Text>;
|
return <Typography.Text>{formatNumber(task.total_actual)}</Typography.Text>;
|
||||||
case 'cost':
|
case FinanceTableColumnKeys.COST:
|
||||||
return <Typography.Text>{formatNumber(task.cost || 0)}</Typography.Text>;
|
return <Typography.Text>{formatNumber(task.estimated_cost || 0)}</Typography.Text>;
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -141,6 +208,7 @@ const FinanceTable = ({
|
|||||||
hours: acc.hours + (task.estimated_hours / 60),
|
hours: acc.hours + (task.estimated_hours / 60),
|
||||||
total_time_logged: acc.total_time_logged + (task.total_time_logged / 60),
|
total_time_logged: acc.total_time_logged + (task.total_time_logged / 60),
|
||||||
estimated_cost: acc.estimated_cost + (task.estimated_cost || 0),
|
estimated_cost: acc.estimated_cost + (task.estimated_cost || 0),
|
||||||
|
fixed_cost: acc.fixed_cost + (task.fixed_cost || 0),
|
||||||
total_budget: acc.total_budget + (task.total_budget || 0),
|
total_budget: acc.total_budget + (task.total_budget || 0),
|
||||||
total_actual: acc.total_actual + (task.total_actual || 0),
|
total_actual: acc.total_actual + (task.total_actual || 0),
|
||||||
variance: acc.variance + (task.variance || 0)
|
variance: acc.variance + (task.variance || 0)
|
||||||
@@ -149,6 +217,7 @@ const FinanceTable = ({
|
|||||||
hours: 0,
|
hours: 0,
|
||||||
total_time_logged: 0,
|
total_time_logged: 0,
|
||||||
estimated_cost: 0,
|
estimated_cost: 0,
|
||||||
|
fixed_cost: 0,
|
||||||
total_budget: 0,
|
total_budget: 0,
|
||||||
total_actual: 0,
|
total_actual: 0,
|
||||||
variance: 0
|
variance: 0
|
||||||
@@ -172,43 +241,33 @@ const FinanceTable = ({
|
|||||||
}}
|
}}
|
||||||
className="group"
|
className="group"
|
||||||
>
|
>
|
||||||
<td
|
|
||||||
colSpan={3}
|
|
||||||
style={{
|
|
||||||
width: 48,
|
|
||||||
textTransform: 'capitalize',
|
|
||||||
textAlign: 'left',
|
|
||||||
paddingInline: 16,
|
|
||||||
backgroundColor: themeWiseColor(
|
|
||||||
table.color_code,
|
|
||||||
table.color_code_dark,
|
|
||||||
themeMode
|
|
||||||
),
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
onClick={() => setIsCollapse((prev) => !prev)}
|
|
||||||
>
|
|
||||||
<Flex gap={8} align="center" style={{ color: colors.darkGray }}>
|
|
||||||
{isCollapse ? <RightOutlined /> : <DownOutlined />}
|
|
||||||
{table.group_name} ({tasks.length})
|
|
||||||
</Flex>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
{financeTableColumns.map(
|
{financeTableColumns.map(
|
||||||
(col) =>
|
(col, index) => (
|
||||||
col.key !== 'task' &&
|
<td
|
||||||
col.key !== 'members' && (
|
key={`header-${col.key}`}
|
||||||
<td
|
style={{
|
||||||
key={`header-${col.key}`}
|
width: col.width,
|
||||||
style={{
|
paddingInline: 16,
|
||||||
width: col.width,
|
textAlign: col.key === FinanceTableColumnKeys.TASK || col.key === FinanceTableColumnKeys.MEMBERS ? 'left' : 'right',
|
||||||
paddingInline: 16,
|
backgroundColor: themeWiseColor(
|
||||||
textAlign: 'end',
|
table.color_code,
|
||||||
}}
|
table.color_code_dark,
|
||||||
>
|
themeMode
|
||||||
{renderFinancialTableHeaderContent(col.key)}
|
),
|
||||||
</td>
|
cursor: col.key === FinanceTableColumnKeys.TASK ? 'pointer' : 'default',
|
||||||
)
|
textTransform: col.key === FinanceTableColumnKeys.TASK ? 'capitalize' : 'none',
|
||||||
|
}}
|
||||||
|
className={customHeaderColumnStyles(col.key)}
|
||||||
|
onClick={col.key === FinanceTableColumnKeys.TASK ? () => setIsCollapse((prev) => !prev) : undefined}
|
||||||
|
>
|
||||||
|
{col.key === FinanceTableColumnKeys.TASK ? (
|
||||||
|
<Flex gap={8} align="center" style={{ color: colors.darkGray }}>
|
||||||
|
{isCollapse ? <RightOutlined /> : <DownOutlined />}
|
||||||
|
{table.group_name} ({tasks.length})
|
||||||
|
</Flex>
|
||||||
|
) : col.key === FinanceTableColumnKeys.MEMBERS ? null : renderFinancialTableHeaderContent(col.key)}
|
||||||
|
</td>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
@@ -218,17 +277,12 @@ const FinanceTable = ({
|
|||||||
key={task.id}
|
key={task.id}
|
||||||
style={{
|
style={{
|
||||||
height: 40,
|
height: 40,
|
||||||
background: idx % 2 === 0 ? '#232323' : '#181818',
|
background: idx % 2 === 0 ? themeWiseColor('#fafafa', '#232323', themeMode) : themeWiseColor('#ffffff', '#181818', themeMode),
|
||||||
transition: 'background 0.2s',
|
transition: 'background 0.2s',
|
||||||
cursor: 'pointer'
|
|
||||||
}}
|
}}
|
||||||
onMouseEnter={e => e.currentTarget.style.background = '#333'}
|
onMouseEnter={e => e.currentTarget.style.background = themeWiseColor('#f0f0f0', '#333', themeMode)}
|
||||||
onMouseLeave={e => e.currentTarget.style.background = idx % 2 === 0 ? '#232323' : '#181818'}
|
onMouseLeave={e => e.currentTarget.style.background = idx % 2 === 0 ? themeWiseColor('#fafafa', '#232323', themeMode) : themeWiseColor('#ffffff', '#181818', themeMode)}
|
||||||
onClick={() => setSelectedTask(task)}
|
|
||||||
>
|
>
|
||||||
<td style={{ width: 48, paddingInline: 16 }}>
|
|
||||||
<Checkbox />
|
|
||||||
</td>
|
|
||||||
{financeTableColumns.map((col) => (
|
{financeTableColumns.map((col) => (
|
||||||
<td
|
<td
|
||||||
key={`${task.id}-${col.key}`}
|
key={`${task.id}-${col.key}`}
|
||||||
@@ -236,7 +290,17 @@ const FinanceTable = ({
|
|||||||
width: col.width,
|
width: col.width,
|
||||||
paddingInline: 16,
|
paddingInline: 16,
|
||||||
textAlign: col.type === 'string' ? 'left' : 'right',
|
textAlign: col.type === 'string' ? 'left' : 'right',
|
||||||
|
backgroundColor: (col.key === FinanceTableColumnKeys.TASK || col.key === FinanceTableColumnKeys.MEMBERS) ?
|
||||||
|
(idx % 2 === 0 ? themeWiseColor('#fafafa', '#232323', themeMode) : themeWiseColor('#ffffff', '#181818', themeMode)) :
|
||||||
|
'transparent',
|
||||||
|
cursor: 'default'
|
||||||
}}
|
}}
|
||||||
|
className={customColumnStyles(col.key)}
|
||||||
|
onClick={
|
||||||
|
col.key === FinanceTableColumnKeys.FIXED_COST
|
||||||
|
? (e) => e.stopPropagation()
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{renderFinancialTableColumnContent(col.key, task)}
|
{renderFinancialTableColumnContent(col.key, task)}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -14,12 +14,13 @@ const ProjectViewFinance = () => {
|
|||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
const { activeTab, activeGroup, loading, taskGroups } = useAppSelector((state: RootState) => state.projectFinances);
|
const { activeTab, activeGroup, loading, taskGroups } = useAppSelector((state: RootState) => state.projectFinances);
|
||||||
|
const { refreshTimestamp } = useAppSelector((state: RootState) => state.projectReducer);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (projectId) {
|
if (projectId) {
|
||||||
dispatch(fetchProjectFinances({ projectId, groupBy: activeGroup }));
|
dispatch(fetchProjectFinances({ projectId, groupBy: activeGroup }));
|
||||||
}
|
}
|
||||||
}, [projectId, activeGroup, dispatch]);
|
}, [projectId, activeGroup, dispatch, refreshTimestamp]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex vertical gap={16} style={{ overflowX: 'hidden' }}>
|
<Flex vertical gap={16} style={{ overflowX: 'hidden' }}>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,8 +17,13 @@ export interface IProjectFinanceMember {
|
|||||||
avatar_url: string | null;
|
avatar_url: string | null;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
email: string;
|
email: string;
|
||||||
socket_id: string;
|
socket_id: string | null;
|
||||||
team_id: string;
|
team_id: string;
|
||||||
|
color_code: string;
|
||||||
|
project_rate_card_role_id: string | null;
|
||||||
|
rate: number;
|
||||||
|
job_title_id: string | null;
|
||||||
|
job_title_name: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IProjectFinanceTask {
|
export interface IProjectFinanceTask {
|
||||||
@@ -29,11 +34,10 @@ export interface IProjectFinanceTask {
|
|||||||
estimated_cost: number;
|
estimated_cost: number;
|
||||||
members: IProjectFinanceMember[];
|
members: IProjectFinanceMember[];
|
||||||
billable: boolean;
|
billable: boolean;
|
||||||
fixed_cost?: number;
|
fixed_cost: number;
|
||||||
variance?: number;
|
variance: number;
|
||||||
total_budget?: number;
|
total_budget: number;
|
||||||
total_actual?: number;
|
total_actual: number;
|
||||||
cost?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IProjectFinanceGroup {
|
export interface IProjectFinanceGroup {
|
||||||
@@ -44,4 +48,57 @@ export interface IProjectFinanceGroup {
|
|||||||
tasks: IProjectFinanceTask[];
|
tasks: IProjectFinanceTask[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IProjectRateCard {
|
||||||
|
id: string;
|
||||||
|
project_id: string;
|
||||||
|
job_title_id: string;
|
||||||
|
rate: string;
|
||||||
|
job_title_name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IProjectFinanceResponse {
|
||||||
|
groups: IProjectFinanceGroup[];
|
||||||
|
project_rate_cards: IProjectRateCard[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ITaskBreakdownMember {
|
||||||
|
team_member_id: string;
|
||||||
|
name: string;
|
||||||
|
avatar_url: string;
|
||||||
|
hourly_rate: number;
|
||||||
|
estimated_hours: number;
|
||||||
|
logged_hours: number;
|
||||||
|
estimated_cost: number;
|
||||||
|
actual_cost: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ITaskBreakdownJobRole {
|
||||||
|
jobRole: string;
|
||||||
|
estimated_hours: number;
|
||||||
|
logged_hours: number;
|
||||||
|
estimated_cost: number;
|
||||||
|
actual_cost: number;
|
||||||
|
members: ITaskBreakdownMember[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ITaskBreakdownTask {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
project_id: string;
|
||||||
|
billable: boolean;
|
||||||
|
estimated_hours: number;
|
||||||
|
logged_hours: number;
|
||||||
|
estimated_labor_cost: number;
|
||||||
|
actual_labor_cost: number;
|
||||||
|
fixed_cost: number;
|
||||||
|
total_estimated_cost: number;
|
||||||
|
total_actual_cost: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ITaskBreakdownResponse {
|
||||||
|
task: ITaskBreakdownTask;
|
||||||
|
grouped_members: ITaskBreakdownJobRole[];
|
||||||
|
members: Array<ITaskBreakdownMember & { job_title_name: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
export type ProjectFinanceGroupType = 'status' | 'priority' | 'phases';
|
export type ProjectFinanceGroupType = 'status' | 'priority' | 'phases';
|
||||||
|
|||||||
Reference in New Issue
Block a user