feat(project-finance): implement project finance API and frontend integration for task retrieval

This commit is contained in:
chamiakJ
2025-05-22 06:24:00 +05:30
parent 533b59504f
commit d7a5f08058
10 changed files with 359 additions and 57 deletions

View File

@@ -0,0 +1,135 @@
import { IWorkLenzRequest } from "../interfaces/worklenz-request";
import { IWorkLenzResponse } from "../interfaces/worklenz-response";
import db from "../config/db";
import { ServerResponse } from "../models/server-response";
import WorklenzControllerBase from "./worklenz-controller-base";
import HandleExceptions from "../decorators/handle-exceptions";
export default class ProjectfinanceController extends WorklenzControllerBase {
@HandleExceptions()
public static async getTasks(
req: IWorkLenzRequest,
res: IWorkLenzResponse
): Promise<IWorkLenzResponse> {
const { project_id } = req.params;
const { group_by = "status" } = req.query;
const q = `
WITH task_data AS (
SELECT
t.id,
t.name,
t.status_id,
t.priority_id,
tp.phase_id,
(t.total_minutes / 3600.0) as estimated_hours,
(COALESCE((SELECT SUM(time_spent) FROM task_work_log WHERE task_id = t.id), 0) / 3600.0) as actual_hours,
t.completed_at,
t.created_at,
t.updated_at,
t.billable,
s.name as status_name,
p.name as priority_name,
ph.name as phase_name,
(SELECT color_code FROM sys_task_status_categories WHERE id = s.category_id) as status_color,
(SELECT color_code_dark FROM sys_task_status_categories WHERE id = s.category_id) as status_color_dark,
(SELECT color_code FROM task_priorities WHERE id = t.priority_id) as priority_color,
(SELECT color_code FROM project_phases WHERE id = tp.phase_id) as phase_color,
(SELECT get_task_assignees(t.id)) as assignees,
json_agg(
json_build_object(
'name', u.name,
'avatar_url', u.avatar_url,
'team_member_id', tm.id,
'color_code', '#1890ff'
)
) FILTER (WHERE u.id IS NOT NULL) as members
FROM tasks t
LEFT JOIN task_statuses s ON t.status_id = s.id
LEFT JOIN task_priorities p ON t.priority_id = p.id
LEFT JOIN task_phase tp ON t.id = tp.task_id
LEFT JOIN project_phases ph ON tp.phase_id = ph.id
LEFT JOIN tasks_assignees ta ON t.id = ta.task_id
LEFT JOIN project_members pm ON ta.project_member_id = pm.id
LEFT JOIN team_members tm ON pm.team_member_id = tm.id
LEFT JOIN finance_project_rate_card_roles pmr ON pm.project_rate_card_role_id = pmr.id
LEFT JOIN users u ON tm.user_id = u.id
LEFT JOIN job_titles jt ON tm.job_title_id = jt.id
WHERE t.project_id = $1
GROUP BY
t.id,
s.name,
p.name,
ph.name,
tp.phase_id,
s.category_id,
t.priority_id
)
SELECT
CASE
WHEN $2 = 'status' THEN status_id
WHEN $2 = 'priority' THEN priority_id
WHEN $2 = 'phases' THEN phase_id
END as group_id,
CASE
WHEN $2 = 'status' THEN status_name
WHEN $2 = 'priority' THEN priority_name
WHEN $2 = 'phases' THEN phase_name
END as group_name,
CASE
WHEN $2 = 'status' THEN status_color
WHEN $2 = 'priority' THEN priority_color
WHEN $2 = 'phases' THEN phase_color
END as color_code,
CASE
WHEN $2 = 'status' THEN status_color_dark
WHEN $2 = 'priority' THEN priority_color
WHEN $2 = 'phases' THEN phase_color
END as color_code_dark,
json_agg(
json_build_object(
'id', id,
'name', name,
'status_id', status_id,
'priority_id', priority_id,
'phase_id', phase_id,
'estimated_hours', estimated_hours,
'actual_hours', actual_hours,
'completed_at', completed_at,
'created_at', created_at,
'updated_at', updated_at,
'billable', billable,
'assignees', assignees,
'members', members
)
) as tasks
FROM task_data
GROUP BY
CASE
WHEN $2 = 'status' THEN status_id
WHEN $2 = 'priority' THEN priority_id
WHEN $2 = 'phases' THEN phase_id
END,
CASE
WHEN $2 = 'status' THEN status_name
WHEN $2 = 'priority' THEN priority_name
WHEN $2 = 'phases' THEN phase_name
END,
CASE
WHEN $2 = 'status' THEN status_color
WHEN $2 = 'priority' THEN priority_color
WHEN $2 = 'phases' THEN phase_color
END,
CASE
WHEN $2 = 'status' THEN status_color_dark
WHEN $2 = 'priority' THEN priority_color
WHEN $2 = 'phases' THEN phase_color
END
ORDER BY group_name;
`;
const result = await db.query(q, [project_id, group_by]);
return res.status(200).send(new ServerResponse(true, result.rows));
}
}

View File

@@ -60,6 +60,7 @@ import taskRecurringApiRouter from "./task-recurring-api-router";
import customColumnsApiRouter from "./custom-columns-api-router";
import ratecardApiRouter from "./ratecard-api-router";
import projectRatecardApiRouter from "./project-ratecard-api-router";
import projectFinanceApiRouter from "./project-finance-api-router";
const api = express.Router();
@@ -122,4 +123,6 @@ api.use("/task-recurring", taskRecurringApiRouter);
api.use("/custom-columns", customColumnsApiRouter);
api.use("/project-finance", projectFinanceApiRouter);
export default api;

View File

@@ -0,0 +1,9 @@
import express from "express";
import ProjectfinanceController from "../../controllers/project-finance-controller";
const projectFinanceApiRouter = express.Router();
projectFinanceApiRouter.get("/project/:project_id/tasks", ProjectfinanceController.getTasks);
export default projectFinanceApiRouter;

View File

@@ -0,0 +1,21 @@
import { API_BASE_URL } from "@/shared/constants";
import { IServerResponse } from "@/types/common.types";
import apiClient from "../api-client";
import { IProjectFinanceGroup } from "@/types/project/project-finance.types";
const rootUrl = `${API_BASE_URL}/project-finance`;
export const projectFinanceApiService = {
getProjectTasks: async (
projectId: string,
groupBy: 'status' | 'priority' | 'phases' = 'status'
): Promise<IServerResponse<IProjectFinanceGroup[]>> => {
const response = await apiClient.get<IServerResponse<IProjectFinanceGroup[]>>(
`${rootUrl}/project/${projectId}/tasks`,
{
params: { group_by: groupBy }
}
);
return response.data;
},
}

View File

@@ -1,42 +1,41 @@
import React, { useEffect, useMemo, useState } from 'react';
import React from 'react';
import FinanceTableWrapper from './finance-table/finance-table-wrapper';
import { fetchData } from '../../../../../utils/fetchData';
import { IProjectFinanceGroup } from '@/types/project/project-finance.types';
interface FinanceTabProps {
groupType: 'status' | 'priority' | 'phases';
taskGroups: IProjectFinanceGroup[];
loading: boolean;
}
const FinanceTab = ({
groupType,
}: {
groupType: 'status' | 'priority' | 'phases';
}) => {
// Save each table's list according to the groups
const [statusTables, setStatusTables] = useState<any[]>([]);
const [priorityTables, setPriorityTables] = useState<any[]>([]);
const [activeTablesList, setActiveTablesList] = useState<any[]>([]);
// Fetch data for status tables
useMemo(() => {
fetchData('/finance-mock-data/finance-task-status.json', setStatusTables);
}, []);
// Fetch data for priority tables
useMemo(() => {
fetchData(
'/finance-mock-data/finance-task-priority.json',
setPriorityTables
);
}, []);
// Update activeTablesList based on groupType and fetched data
useEffect(() => {
if (groupType === 'status') {
setActiveTablesList(statusTables);
} else if (groupType === 'priority') {
setActiveTablesList(priorityTables);
}
}, [groupType, priorityTables, statusTables]);
taskGroups,
loading
}: FinanceTabProps) => {
// Transform taskGroups into the format expected by FinanceTableWrapper
const activeTablesList = taskGroups.map(group => ({
id: group.group_id,
name: group.group_name,
color_code: group.color_code,
color_code_dark: group.color_code_dark,
tasks: group.tasks.map(task => ({
taskId: task.id,
task: task.name,
hours: task.estimated_hours || 0,
cost: 0, // TODO: Calculate based on rate and hours
fixedCost: 0, // TODO: Add fixed cost field
totalBudget: 0, // TODO: Calculate total budget
totalActual: task.actual_hours || 0,
variance: 0, // TODO: Calculate variance
members: task.members || [],
isbBillable: task.billable
}))
}));
return (
<div>
<FinanceTableWrapper activeTablesList={activeTablesList} />
<FinanceTableWrapper activeTablesList={activeTablesList} loading={loading} />
</div>
);
};

View File

@@ -0,0 +1,44 @@
import React from "react";
import { Card, Col, Row, Spin } from "antd";
import { useThemeContext } from "../../../../../context/theme-context";
import { FinanceTable } from "./finance-table";
import { IFinanceTable } from "./finance-table.interface";
import { IProjectFinanceGroup } from "../../../../../types/project/project-finance.types";
interface Props {
activeTablesList: IProjectFinanceGroup[];
loading: boolean;
}
export const FinanceTableWrapper: React.FC<Props> = ({ activeTablesList, loading }) => {
const { isDarkMode } = useThemeContext();
const getTableColor = (table: IProjectFinanceGroup) => {
return isDarkMode ? table.color_code_dark : table.color_code;
};
return (
<div className="finance-table-wrapper">
<Row gutter={[16, 16]}>
{activeTablesList.map((table) => (
<Col key={table.group_id} xs={24} sm={24} md={24} lg={24} xl={24}>
<Card
className="finance-table-card"
style={{
borderTop: `3px solid ${getTableColor(table)}`,
}}
>
<div className="finance-table-header">
<h3>{table.group_name}</h3>
</div>
<FinanceTable
table={table as unknown as IFinanceTable}
loading={loading}
/>
</Card>
</Col>
))}
</Row>
</div>
);
};

View File

@@ -9,28 +9,43 @@ import { financeTableColumns } from '@/lib/project/project-view-finance-table-co
import FinanceTable from './finance-table';
import FinanceDrawer from '@/features/finance/finance-drawer/finance-drawer';
const FinanceTableWrapper = ({
interface FinanceTableWrapperProps {
activeTablesList: {
id: string;
name: string;
color_code: string;
color_code_dark: string;
tasks: {
taskId: string;
task: string;
hours: number;
cost: number;
fixedCost: number;
totalBudget: number;
totalActual: number;
variance: number;
members: any[];
isbBillable: boolean;
}[];
}[];
loading: boolean;
}
const FinanceTableWrapper: React.FC<FinanceTableWrapperProps> = ({
activeTablesList,
}: {
activeTablesList: any;
loading
}) => {
const [isScrolling, setIsScrolling] = useState(false);
//? this state for inside this state individualy in finance table only display the data of the last table's task when a task is clicked The selectedTask state does not synchronize across tables so thats why move the selectedTask state to a parent component
const [selectedTask, setSelectedTask] = useState(null);
// localization
const { t } = useTranslation('project-view-finance');
const dispatch = useAppDispatch();
// function on task click
const onTaskClick = (task: any) => {
setSelectedTask(task);
dispatch(toggleFinanceDrawer());
};
// trigger the table scrolling
useEffect(() => {
const tableContainer = document.querySelector('.tasklist-container');
const handleScroll = () => {
@@ -39,22 +54,15 @@ const FinanceTableWrapper = ({
}
};
// add the scroll event listener
tableContainer?.addEventListener('scroll', handleScroll);
// cleanup on unmount
return () => {
tableContainer?.removeEventListener('scroll', handleScroll);
};
}, []);
// get theme data from theme reducer
const themeMode = useAppSelector((state) => state.themeReducer.mode);
// get tasklist and currently using currency from finance reducer
const { currency } = useAppSelector((state) => state.financeReducer);
// totals of all the tasks
const totals = activeTablesList.reduce(
(
acc: {
@@ -135,7 +143,6 @@ const FinanceTableWrapper = ({
}
};
// layout styles for table and the columns
const customColumnHeaderStyles = (key: string) =>
`px-2 text-left ${key === 'selector' && 'sticky left-0 z-10'} ${key === 'task' && 'sticky left-[48px] z-10'} ${key === 'members' && `sticky left-[288px] z-10 ${isScrolling ? 'after:content after:absolute after:top-0 after:-right-1 after:-z-10 after:h-[68px] after:w-1.5 after:bg-transparent after:bg-gradient-to-r after:from-[rgba(0,0,0,0.12)] after:to-transparent' : ''}`} ${themeMode === 'dark' ? 'bg-[#1d1d1d] border-[#303030]' : 'bg-[#fafafa]'}`;
@@ -233,7 +240,7 @@ const FinanceTableWrapper = ({
)}
</tr>
{activeTablesList.map((table: any, index: number) => (
{activeTablesList.map((table, index) => (
<FinanceTable
key={index}
table={table}

View File

@@ -10,6 +10,7 @@ import {
import { themeWiseColor } from '../../../../../../utils/themeWiseColor';
import { colors } from '../../../../../../styles/colors';
import { financeTableColumns } from '@/lib/project/project-view-finance-table-columns';
import Avatars from '@/components/avatars/avatars';
type FinanceTableProps = {
table: any;
@@ -132,11 +133,7 @@ const FinanceTable = ({
);
case 'members':
return (
<Avatar.Group>
{task.members.map((member: any) => (
<CustomAvatar avatarName={member.name} size={26} />
))}
</Avatar.Group>
task?.assignees && <Avatars members={task.assignees} />
);
case 'hours':
return <Typography.Text>{task.hours}</Typography.Text>;

View File

@@ -1,15 +1,53 @@
import { Flex } from 'antd';
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { useParams } from 'react-router-dom';
import ProjectViewFinanceHeader from './project-view-finance-header/project-view-finance-header';
import FinanceTab from './finance-tab/finance-tab';
import RatecardTab from './ratecard-tab/ratecard-tab';
import { projectFinanceApiService } from '@/api/project-finance-ratecard/project-finance.api.service';
import { IProjectFinanceGroup } from '@/types/project/project-finance.types';
type FinanceTabType = 'finance' | 'ratecard';
type GroupTypes = 'status' | 'priority' | 'phases';
interface TaskGroup {
group_id: string;
group_name: string;
tasks: any[];
}
interface FinanceTabProps {
groupType: GroupTypes;
taskGroups: TaskGroup[];
loading: boolean;
}
const ProjectViewFinance = () => {
const { projectId } = useParams<{ projectId: string }>();
const [activeTab, setActiveTab] = useState<FinanceTabType>('finance');
const [activeGroup, setActiveGroup] = useState<GroupTypes>('status');
const [loading, setLoading] = useState(false);
const [taskGroups, setTaskGroups] = useState<IProjectFinanceGroup[]>([]);
const fetchTasks = async () => {
if (!projectId) return;
try {
setLoading(true);
const response = await projectFinanceApiService.getProjectTasks(projectId, activeGroup);
if (response.done) {
setTaskGroups(response.body);
}
} catch (error) {
console.error('Error fetching tasks:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchTasks();
}, [projectId, activeGroup]);
return (
<Flex vertical gap={16} style={{ overflowX: 'hidden' }}>
@@ -21,7 +59,11 @@ const ProjectViewFinance = () => {
/>
{activeTab === 'finance' ? (
<FinanceTab groupType={activeGroup} />
<FinanceTab
groupType={activeGroup}
taskGroups={taskGroups}
loading={loading}
/>
) : (
<RatecardTab />
)}

View File

@@ -0,0 +1,45 @@
export interface IProjectFinanceUser {
id: string;
name: string;
avatar_url: string | null;
}
export interface IProjectFinanceJobTitle {
id: string;
name: string;
}
export interface IProjectFinanceMember {
id: string;
team_member_id: string;
job_title_id: string;
rate: number | null;
user: IProjectFinanceUser;
job_title: IProjectFinanceJobTitle;
}
export interface IProjectFinanceTask {
id: string;
name: string;
status_id: string;
priority_id: string;
phase_id: string;
estimated_hours: number;
actual_hours: number;
completed_at: string | null;
created_at: string;
updated_at: string;
billable: boolean;
assignees: any[]; // Using any[] since we don't have the assignee structure yet
members: IProjectFinanceMember[];
}
export interface IProjectFinanceGroup {
group_id: string;
group_name: string;
color_code: string;
color_code_dark: string;
tasks: IProjectFinanceTask[];
}
export type ProjectFinanceGroupType = 'status' | 'priority' | 'phases';