From 6847eec6036b1d3f07421364ced66bffb5bb7275 Mon Sep 17 00:00:00 2001 From: shancds Date: Wed, 14 May 2025 22:20:50 +0530 Subject: [PATCH 01/10] feat: Implement Ratecard Drawer and Finance Table --- .../locales/en/project-view-finance.json | 31 ++ .../locales/es/project-view-finance.json | 31 ++ .../locales/pt/project-view-finance.json | 31 ++ worklenz-frontend/src/app/store.ts | 3 +- .../finance/finance-drawer/finance-drawer.tsx | 195 ++++++++++++ .../src/features/finance/finance-slice.ts | 42 +++ .../import-ratecards-drawer.tsx | 114 +++++++ .../ratecard-drawer/ratecard-drawer.tsx | 181 +++++++++++ .../src/lib/project/project-view-constants.ts | 7 + .../project-view-finance-table-columns.ts | 59 ++++ .../finance/finance-tab/finance-tab.tsx | 44 +++ .../finance-table/finance-table-wrapper.tsx | 253 +++++++++++++++ .../finance-table/finance-table.tsx | 287 ++++++++++++++++++ .../group-by-filter-dropdown.tsx | 56 ++++ .../project-view-finance-header.tsx | 86 ++++++ .../finance/project-view-finance.tsx | 32 ++ .../finance/ratecard-tab/ratecard-tab.tsx | 38 +++ .../reatecard-table/ratecard-table.tsx | 150 +++++++++ .../src/types/project/job.types.ts | 6 + .../src/types/project/ratecard.types.ts | 14 + 20 files changed, 1659 insertions(+), 1 deletion(-) create mode 100644 worklenz-frontend/public/locales/en/project-view-finance.json create mode 100644 worklenz-frontend/public/locales/es/project-view-finance.json create mode 100644 worklenz-frontend/public/locales/pt/project-view-finance.json create mode 100644 worklenz-frontend/src/features/finance/finance-drawer/finance-drawer.tsx create mode 100644 worklenz-frontend/src/features/finance/finance-slice.ts create mode 100644 worklenz-frontend/src/features/finance/ratecard-drawer/import-ratecards-drawer.tsx create mode 100644 worklenz-frontend/src/features/finance/ratecard-drawer/ratecard-drawer.tsx create mode 100644 worklenz-frontend/src/lib/project/project-view-finance-table-columns.ts create mode 100644 worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-tab.tsx create mode 100644 worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-table/finance-table-wrapper.tsx create mode 100644 worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-table/finance-table.tsx create mode 100644 worklenz-frontend/src/pages/projects/projectView/finance/project-view-finance-header/group-by-filter-dropdown.tsx create mode 100644 worklenz-frontend/src/pages/projects/projectView/finance/project-view-finance-header/project-view-finance-header.tsx create mode 100644 worklenz-frontend/src/pages/projects/projectView/finance/project-view-finance.tsx create mode 100644 worklenz-frontend/src/pages/projects/projectView/finance/ratecard-tab/ratecard-tab.tsx create mode 100644 worklenz-frontend/src/pages/projects/projectView/finance/ratecard-tab/reatecard-table/ratecard-table.tsx create mode 100644 worklenz-frontend/src/types/project/job.types.ts create mode 100644 worklenz-frontend/src/types/project/ratecard.types.ts diff --git a/worklenz-frontend/public/locales/en/project-view-finance.json b/worklenz-frontend/public/locales/en/project-view-finance.json new file mode 100644 index 00000000..ed43b4bf --- /dev/null +++ b/worklenz-frontend/public/locales/en/project-view-finance.json @@ -0,0 +1,31 @@ +{ + "financeText": "Finance", + "ratecardSingularText": "Rate Card", + "groupByText": "Group by", + "statusText": "Status", + "phaseText": "Phase", + "priorityText": "Priority", + "exportButton": "Export", + "currencyText": "Currency", + "importButton": "Import", + + "taskColumn": "Task", + "membersColumn": "Members", + "hoursColumn": "Hours", + "costColumn": "Cost", + "fixedCostColumn": "Fixed Cost", + "totalBudgetedCostColumn": "Total Budgeted Cost", + "totalActualCostColumn": "Total Actual Cost", + "varianceColumn": "Variance", + "totalText": "Total", + + "addRoleButton": "+ Add Role", + "ratecardImportantNotice": "* This rate card is generated based on the company's standard job titles and rates. However, you have the flexibility to modify it according to the project. These changes will not impact the organization's standard job titles and rates.", + "saveButton": "Save", + + "jobTitleColumn": "Job Title", + "ratePerHourColumn": "Rate per hour", + "ratecardPluralText": "Rate Cards", + "labourHoursColumn": "Labour Hours" + } + \ No newline at end of file diff --git a/worklenz-frontend/public/locales/es/project-view-finance.json b/worklenz-frontend/public/locales/es/project-view-finance.json new file mode 100644 index 00000000..fdf9849d --- /dev/null +++ b/worklenz-frontend/public/locales/es/project-view-finance.json @@ -0,0 +1,31 @@ +{ + "financeText": "Finanzas", + "ratecardSingularText": "Tarifa", + "groupByText": "Agrupar por", + "statusText": "Estado", + "phaseText": "Fase", + "priorityText": "Prioridad", + "exportButton": "Exportar", + "currencyText": "Moneda", + "importButton": "Importar", + + "taskColumn": "Tarea", + "membersColumn": "Miembros", + "hoursColumn": "Horas", + "costColumn": "Costo", + "fixedCostColumn": "Costo Fijo", + "totalBudgetedCostColumn": "Costo Total Presupuestado", + "totalActualCostColumn": "Costo Total Real", + "varianceColumn": "Diferencia", + "totalText": "Total", + + "addRoleButton": "+ Agregar Rol", + "ratecardImportantNotice": "* Esta tarifa se genera en función de los títulos de trabajo y tarifas estándar de la empresa. Sin embargo, tienes la flexibilidad de modificarla según el proyecto. Estos cambios no afectarán los títulos de trabajo y tarifas estándar de la organización.", + "saveButton": "Guardar", + + "jobTitleColumn": "Título del Trabajo", + "ratePerHourColumn": "Tarifa por hora", + "ratecardPluralText": "Tarifas", + "labourHoursColumn": "Horas de Trabajo" +} + \ No newline at end of file diff --git a/worklenz-frontend/public/locales/pt/project-view-finance.json b/worklenz-frontend/public/locales/pt/project-view-finance.json new file mode 100644 index 00000000..db5c67c6 --- /dev/null +++ b/worklenz-frontend/public/locales/pt/project-view-finance.json @@ -0,0 +1,31 @@ +{ + "financeText": "Finanças", + "ratecardSingularText": "Tabela de Taxas", + "groupByText": "Agrupar por", + "statusText": "Status", + "phaseText": "Fase", + "priorityText": "Prioridade", + "exportButton": "Exportar", + "currencyText": "Moeda", + "importButton": "Importar", + + "taskColumn": "Tarefa", + "membersColumn": "Membros", + "hoursColumn": "Horas", + "costColumn": "Custo", + "fixedCostColumn": "Custo Fixo", + "totalBudgetedCostColumn": "Custo Total Orçado", + "totalActualCostColumn": "Custo Total Real", + "varianceColumn": "Variação", + "totalText": "Total", + + "addRoleButton": "+ Adicionar Função", + "ratecardImportantNotice": "* Esta tabela de taxas é gerada com base nos cargos e taxas padrão da empresa. No entanto, você tem a flexibilidade de modificá-la de acordo com o projeto. Essas alterações não impactarão os cargos e taxas padrão da organização.", + "saveButton": "Salvar", + + "jobTitleColumn": "Título do Cargo", + "ratePerHourColumn": "Taxa por Hora", + "ratecardPluralText": "Tabelas de Taxas", + "labourHoursColumn": "Horas de Trabalho" +} + \ No newline at end of file diff --git a/worklenz-frontend/src/app/store.ts b/worklenz-frontend/src/app/store.ts index 6bf7adcf..2a34813a 100644 --- a/worklenz-frontend/src/app/store.ts +++ b/worklenz-frontend/src/app/store.ts @@ -69,7 +69,7 @@ import projectReportsTableColumnsReducer from '../features/reporting/projectRepo import projectReportsReducer from '../features/reporting/projectReports/project-reports-slice'; import membersReportsReducer from '../features/reporting/membersReports/membersReportsSlice'; import timeReportsOverviewReducer from '@features/reporting/time-reports/time-reports-overview.slice'; - +import financeReducer from '../features/finance/finance-slice'; import roadmapReducer from '../features/roadmap/roadmap-slice'; import teamMembersReducer from '@features/team-members/team-members.slice'; import groupByFilterDropdownReducer from '../features/group-by-filter-dropdown/group-by-filter-dropdown-slice'; @@ -155,6 +155,7 @@ export const store = configureStore({ roadmapReducer: roadmapReducer, groupByFilterDropdownReducer: groupByFilterDropdownReducer, timeReportsOverviewReducer: timeReportsOverviewReducer, + financeReducer: financeReducer, }, }); diff --git a/worklenz-frontend/src/features/finance/finance-drawer/finance-drawer.tsx b/worklenz-frontend/src/features/finance/finance-drawer/finance-drawer.tsx new file mode 100644 index 00000000..851f6d76 --- /dev/null +++ b/worklenz-frontend/src/features/finance/finance-drawer/finance-drawer.tsx @@ -0,0 +1,195 @@ +import React, { useEffect, useState } from 'react'; +import { Drawer, Typography } from 'antd'; +import { useTranslation } from 'react-i18next'; +import { useAppSelector } from '../../../hooks/useAppSelector'; +import { useAppDispatch } from '../../../hooks/useAppDispatch'; +import { themeWiseColor } from '../../../utils/themeWiseColor'; +import { toggleFinanceDrawer } from '../finance-slice'; + +const FinanceDrawer = ({ task }: { task: any }) => { + const [selectedTask, setSelectedTask] = useState(task); + + useEffect(() => { + setSelectedTask(task); + }, [task]); + + // localization + const { t } = useTranslation('project-view-finance'); + + // get theme data from theme reducer + const themeMode = useAppSelector((state) => state.themeReducer.mode); + + const isDrawerOpen = useAppSelector( + (state) => state.financeReducer.isFinanceDrawerOpen + ); + const dispatch = useAppDispatch(); + const currency = useAppSelector( + (state) => state.financeReducer.currency + ).toUpperCase(); + + // function handle drawer close + const handleClose = () => { + setSelectedTask(null); + dispatch(toggleFinanceDrawer()); + }; + + // 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 ( + + {selectedTask?.task || t('noTaskSelected')} + + } + open={isDrawerOpen} + onClose={handleClose} + destroyOnClose={true} + width={480} + > +
+ + + + + + + + + +
+ + + {Object.values(groupedMembers).map((group: any) => ( + + {/* Group Header */} + + + + + + {/* Member Rows */} + {group.members.map((member: any, index: number) => ( + + + + + + ))} + + ))} + +
+ {t('labourHoursColumn')} + + {t('costColumn')} ({currency}) +
{group.jobRole} + {group.laborHours} + + {group.cost} +
+ {member.name} + + {member.laborHours} + + {member.cost} +
+
+
+ ); +}; + +export default FinanceDrawer; diff --git a/worklenz-frontend/src/features/finance/finance-slice.ts b/worklenz-frontend/src/features/finance/finance-slice.ts new file mode 100644 index 00000000..9a2bce12 --- /dev/null +++ b/worklenz-frontend/src/features/finance/finance-slice.ts @@ -0,0 +1,42 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; + +type financeState = { + isRatecardDrawerOpen: boolean; + isFinanceDrawerOpen: boolean; + isImportRatecardsDrawerOpen: boolean; + currency: string; +}; + +const initialState: financeState = { + isRatecardDrawerOpen: false, + isFinanceDrawerOpen: false, + isImportRatecardsDrawerOpen: false, + currency: 'LKR', +}; + +const financeSlice = createSlice({ + name: 'financeReducer', + initialState, + reducers: { + toggleRatecardDrawer: (state) => { + state.isRatecardDrawerOpen = !state.isRatecardDrawerOpen; + }, + toggleFinanceDrawer: (state) => { + state.isFinanceDrawerOpen = !state.isFinanceDrawerOpen; + }, + toggleImportRatecardsDrawer: (state) => { + state.isImportRatecardsDrawerOpen = !state.isImportRatecardsDrawerOpen; + }, + changeCurrency: (state, action: PayloadAction) => { + state.currency = action.payload; + }, + }, +}); + +export const { + toggleRatecardDrawer, + toggleFinanceDrawer, + toggleImportRatecardsDrawer, + changeCurrency, +} = financeSlice.actions; +export default financeSlice.reducer; diff --git a/worklenz-frontend/src/features/finance/ratecard-drawer/import-ratecards-drawer.tsx b/worklenz-frontend/src/features/finance/ratecard-drawer/import-ratecards-drawer.tsx new file mode 100644 index 00000000..c5888fb4 --- /dev/null +++ b/worklenz-frontend/src/features/finance/ratecard-drawer/import-ratecards-drawer.tsx @@ -0,0 +1,114 @@ +import { Drawer, Typography, Button, Table, Menu, Flex } from 'antd'; +import React, { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useAppSelector } from '../../../hooks/useAppSelector'; +import { useAppDispatch } from '../../../hooks/useAppDispatch'; +import { fetchData } from '../../../utils/fetchData'; +import { toggleImportRatecardsDrawer } from '../finance-slice'; +import { RatecardType } from '@/types/project/ratecard.types'; +const ImportRatecardsDrawer: React.FC = () => { + const [ratecardsList, setRatecardsList] = useState([]); + const [selectedRatecardId, setSelectedRatecardId] = useState( + null + ); + + // localization + const { t } = useTranslation('project-view-finance'); + + // get drawer state from client reducer + const isDrawerOpen = useAppSelector( + (state) => state.financeReducer.isImportRatecardsDrawerOpen + ); + const dispatch = useAppDispatch(); + + // fetch rate cards data + useEffect(() => { + fetchData('/finance-mock-data/ratecards-data.json', setRatecardsList); + }, []); + + // get currently using currency from finance reducer + const currency = useAppSelector( + (state) => state.financeReducer.currency + ).toUpperCase(); + + // find the selected rate card's job roles + const selectedRatecard = + ratecardsList.find( + (ratecard) => ratecard.ratecardId === selectedRatecardId + ) || null; + + // table columns + const columns = [ + { + title: t('jobTitleColumn'), + dataIndex: 'jobTitle', + render: (text: string) => ( + + {text} + + ), + }, + { + title: `${t('ratePerHourColumn')} (${currency})`, + dataIndex: 'ratePerHour', + render: (text: number) => {text}, + }, + ]; + + return ( + + {t('ratecardsPluralText')} + + } + footer={ +
+ +
+ } + open={isDrawerOpen} + onClose={() => dispatch(toggleImportRatecardsDrawer())} + width={1000} + > + + {/* sidebar menu */} + setSelectedRatecardId(key)} + > + {ratecardsList.map((ratecard) => ( + + {ratecard.ratecardName} + + ))} + + + {/* table for job roles */} + record.jobId} + onRow={() => { + return { + className: 'group', + style: { + cursor: 'pointer', + }, + }; + }} + pagination={false} + /> + + + ); +}; + +export default ImportRatecardsDrawer; diff --git a/worklenz-frontend/src/features/finance/ratecard-drawer/ratecard-drawer.tsx b/worklenz-frontend/src/features/finance/ratecard-drawer/ratecard-drawer.tsx new file mode 100644 index 00000000..181eb719 --- /dev/null +++ b/worklenz-frontend/src/features/finance/ratecard-drawer/ratecard-drawer.tsx @@ -0,0 +1,181 @@ +import { Drawer, Select, Typography, Flex, Button, Input, Table } from 'antd'; +import React, { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useAppSelector } from '../../../hooks/useAppSelector'; +import { useAppDispatch } from '../../../hooks/useAppDispatch'; +import { fetchData } from '../../../utils/fetchData'; +import { toggleRatecardDrawer } from '../finance-slice'; +import { RatecardType } from '@/types/project/ratecard.types'; +import { JobType } from '@/types/project/job.types'; + +const RatecardDrawer = ({ + type, + ratecardId, +}: { + type: 'create' | 'update'; + ratecardId: string; +}) => { + const [ratecardsList, setRatecardsList] = useState([]); + // initial Job Roles List (dummy data) + const [roles, setRoles] = useState([]); + + // localization + const { t } = useTranslation('ratecard-settings'); + + // get drawer state from client reducer + const isDrawerOpen = useAppSelector( + (state) => state.financeReducer.isRatecardDrawerOpen + ); + const dispatch = useAppDispatch(); + + // fetch rate cards data + useEffect(() => { + fetchData('/finance-mock-data/ratecards-data.json', setRatecardsList); + }, []); + + // get currently selected ratecard + const selectedRatecard = ratecardsList.find( + (ratecard) => ratecard.ratecardId === ratecardId + ); + + useEffect(() => { + type === 'update' + ? setRoles(selectedRatecard?.jobRolesList || []) + : setRoles([ + { + jobId: 'J001', + jobTitle: 'Project Manager', + ratePerHour: 50, + }, + { + jobId: 'J002', + jobTitle: 'Senior Software Engineer', + ratePerHour: 40, + }, + { + jobId: 'J003', + jobTitle: 'Junior Software Engineer', + ratePerHour: 25, + }, + { + jobId: 'J004', + jobTitle: 'UI/UX Designer', + ratePerHour: 30, + }, + ]); + }, [selectedRatecard?.jobRolesList, type]); + + // get currently using currency from finance reducer + const currency = useAppSelector( + (state) => state.financeReducer.currency + ).toUpperCase(); + + // add new job role handler + const handleAddRole = () => { + const newRole = { + jobId: `J00${roles.length + 1}`, + jobTitle: 'New Role', + ratePerHour: 0, + }; + setRoles([...roles, newRole]); + }; + + // table columns + const columns = [ + { + title: t('jobTitleColumn'), + dataIndex: 'jobTitle', + render: (text: string, record: any, index: number) => ( + { + const updatedRoles = [...roles]; + updatedRoles[index].jobTitle = e.target.value; + setRoles(updatedRoles); + }} + /> + ), + }, + { + title: `${t('ratePerHourColumn')} (${currency})`, + dataIndex: 'ratePerHour', + render: (text: number, record: any, index: number) => ( + { + const updatedRoles = [...roles]; + updatedRoles[index].ratePerHour = parseInt(e.target.value, 10) || 0; + setRoles(updatedRoles); + }} + /> + ), + }, + ]; + + return ( + + + {type === 'update' + ? selectedRatecard?.ratecardName + : 'Untitled Rate Card'} + + + + {t('currency')} +
record.jobId} + pagination={false} + footer={() => ( + + )} + /> + + + + + + ); +}; + +export default RatecardDrawer; diff --git a/worklenz-frontend/src/lib/project/project-view-constants.ts b/worklenz-frontend/src/lib/project/project-view-constants.ts index fc4b8e87..43571cc5 100644 --- a/worklenz-frontend/src/lib/project/project-view-constants.ts +++ b/worklenz-frontend/src/lib/project/project-view-constants.ts @@ -5,6 +5,7 @@ import ProjectViewMembers from '@/pages/projects/projectView/members/project-vie import ProjectViewUpdates from '@/pages/projects/project-view-1/updates/project-view-updates'; import ProjectViewTaskList from '@/pages/projects/projectView/taskList/project-view-task-list'; import ProjectViewBoard from '@/pages/projects/projectView/board/project-view-board'; +import ProjectViewFinance from '@/pages/projects/projectView/finance/project-view-finance'; // type of a tab items type TabItems = { @@ -67,4 +68,10 @@ export const tabItems: TabItems[] = [ label: 'Updates', element: React.createElement(ProjectViewUpdates), }, + { + index: 8, + key: 'finance', + label: 'Finance', + element: React.createElement(ProjectViewFinance), + }, ]; diff --git a/worklenz-frontend/src/lib/project/project-view-finance-table-columns.ts b/worklenz-frontend/src/lib/project/project-view-finance-table-columns.ts new file mode 100644 index 00000000..e08bd430 --- /dev/null +++ b/worklenz-frontend/src/lib/project/project-view-finance-table-columns.ts @@ -0,0 +1,59 @@ +type FinanceTableColumnsType = { + key: string; + name: string; + width: number; + type: 'string' | 'hours' | 'currency'; + }; + + // finance table columns + export const financeTableColumns: FinanceTableColumnsType[] = [ + { + key: 'task', + name: 'task', + width: 240, + type: 'string', + }, + { + key: 'members', + name: 'members', + width: 160, + type: 'string', + }, + { + key: 'hours', + name: 'hours', + width: 80, + type: 'hours', + }, + { + key: 'cost', + name: 'cost', + width: 120, + type: 'currency', + }, + { + key: 'fixedCost', + name: 'fixedCost', + width: 120, + type: 'currency', + }, + { + key: 'totalBudget', + name: 'totalBudgetedCost', + width: 120, + type: 'currency', + }, + { + key: 'totalActual', + name: 'totalActualCost', + width: 120, + type: 'currency', + }, + { + key: 'variance', + name: 'variance', + width: 120, + type: 'currency', + }, + ]; + \ No newline at end of file diff --git a/worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-tab.tsx b/worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-tab.tsx new file mode 100644 index 00000000..b421d9de --- /dev/null +++ b/worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-tab.tsx @@ -0,0 +1,44 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import FinanceTableWrapper from './finance-table/finance-table-wrapper'; +import { fetchData } from '../../../../../utils/fetchData'; + +const FinanceTab = ({ + groupType, +}: { + groupType: 'status' | 'priority' | 'phases'; +}) => { + // Save each table's list according to the groups + const [statusTables, setStatusTables] = useState([]); + const [priorityTables, setPriorityTables] = useState([]); + const [activeTablesList, setActiveTablesList] = useState([]); + + // 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]); + + return ( +
+ +
+ ); +}; + +export default FinanceTab; diff --git a/worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-table/finance-table-wrapper.tsx b/worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-table/finance-table-wrapper.tsx new file mode 100644 index 00000000..60054604 --- /dev/null +++ b/worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-table/finance-table-wrapper.tsx @@ -0,0 +1,253 @@ +import React, { useEffect, useState } from 'react'; +import { Checkbox, Flex, Typography } from 'antd'; +import { themeWiseColor } from '../../../../../../utils/themeWiseColor'; +import { useAppSelector } from '../../../../../../hooks/useAppSelector'; +import { useTranslation } from 'react-i18next'; +import { useAppDispatch } from '../../../../../../hooks/useAppDispatch'; +import { toggleFinanceDrawer } from '@/features/finance/finance-slice'; +import { financeTableColumns } from '@/lib/project/project-view-finance-table-columns'; +import FinanceTable from './finance-table'; +import FinanceDrawer from '@/features/finance/finance-drawer/finance-drawer'; + +const FinanceTableWrapper = ({ + activeTablesList, +}: { + activeTablesList: any; +}) => { + 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 = () => { + if (tableContainer) { + setIsScrolling(tableContainer.scrollLeft > 0); + } + }; + + // 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: { + hours: number; + cost: number; + fixedCost: number; + totalBudget: number; + totalActual: number; + variance: number; + }, + table: { tasks: any[] } + ) => { + table.tasks.forEach((task: any) => { + acc.hours += task.hours || 0; + acc.cost += task.cost || 0; + acc.fixedCost += task.fixedCost || 0; + acc.totalBudget += task.totalBudget || 0; + acc.totalActual += task.totalActual || 0; + acc.variance += task.variance || 0; + }); + return acc; + }, + { + hours: 0, + cost: 0, + fixedCost: 0, + totalBudget: 0, + totalActual: 0, + variance: 0, + } + ); + + const renderFinancialTableHeaderContent = (columnKey: any) => { + switch (columnKey) { + case 'hours': + return ( + + {totals.hours} + + ); + case 'cost': + return ( + + {totals.cost} + + ); + case 'fixedCost': + return ( + + {totals.fixedCost} + + ); + case 'totalBudget': + return ( + + {totals.totalBudget} + + ); + case 'totalActual': + return ( + + {totals.totalActual} + + ); + case 'variance': + return ( + + {totals.variance} + + ); + default: + return null; + } + }; + + // 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]'}`; + + const customColumnStyles = (key: string) => + `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' : ''}`}`; + + return ( + <> + +
+ + + + {financeTableColumns.map((col) => ( + + ))} + + + + + {financeTableColumns.map( + (col) => + (col.type === 'hours' || col.type === 'currency') && ( + + ) + )} + + + {activeTablesList.map((table: any, index: number) => ( + + ))} + +
+ + + + {t(`${col.name}Column`)}{' '} + {col.type === 'currency' && `(${currency.toUpperCase()})`} + +
+ + {t('totalText')} + + + {renderFinancialTableHeaderContent(col.key)} +
+
+ + {selectedTask && } + + ); +}; + +export default FinanceTableWrapper; diff --git a/worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-table/finance-table.tsx b/worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-table/finance-table.tsx new file mode 100644 index 00000000..b6ea67ad --- /dev/null +++ b/worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-table/finance-table.tsx @@ -0,0 +1,287 @@ +import { Avatar, Checkbox, Flex, Input, Tooltip, Typography } from 'antd'; +import React, { useEffect, useMemo, useState } from 'react'; +import CustomAvatar from '../../../../../../components/CustomAvatar'; +import { useAppSelector } from '../../../../../../hooks/useAppSelector'; +import { + DollarCircleOutlined, + DownOutlined, + RightOutlined, +} from '@ant-design/icons'; +import { themeWiseColor } from '../../../../../../utils/themeWiseColor'; +import { colors } from '../../../../../../styles/colors'; +import { financeTableColumns } from '@/lib/project/project-view-finance-table-columns'; + +type FinanceTableProps = { + table: any; + isScrolling: boolean; + onTaskClick: (task: any) => void; +}; + +const FinanceTable = ({ + table, + isScrolling, + onTaskClick, +}: FinanceTableProps) => { + const [isCollapse, setIsCollapse] = useState(false); + const [selectedTask, setSelectedTask] = useState(null); + + // get theme data from theme reducer + const themeMode = useAppSelector((state) => state.themeReducer.mode); + + // totals of the current table + const totals = useMemo( + () => ({ + hours: (table?.tasks || []).reduce( + (sum: any, task: { hours: any }) => sum + task.hours, + 0 + ), + cost: (table?.tasks || []).reduce( + (sum: any, task: { cost: any }) => sum + task.cost, + 0 + ), + fixedCost: (table?.tasks || []).reduce( + (sum: any, task: { fixedCost: any }) => sum + task.fixedCost, + 0 + ), + totalBudget: (table?.tasks || []).reduce( + (sum: any, task: { totalBudget: any }) => sum + task.totalBudget, + 0 + ), + totalActual: (table?.tasks || []).reduce( + (sum: any, task: { totalActual: any }) => sum + task.totalActual, + 0 + ), + variance: (table?.tasks || []).reduce( + (sum: any, task: { variance: any }) => sum + task.variance, + 0 + ), + }), + [table] + ); + + useEffect(() => { + console.log('Selected Task:', selectedTask); + }, [selectedTask]); + + const renderFinancialTableHeaderContent = (columnKey: any) => { + switch (columnKey) { + case 'hours': + return ( + + {totals.hours} + + ); + case 'cost': + return ( + + {totals.cost} + + ); + case 'fixedCost': + return ( + + {totals.fixedCost} + + ); + case 'totalBudget': + return ( + + {totals.totalBudget} + + ); + case 'totalActual': + return ( + + {totals.totalActual} + + ); + case 'variance': + return ( + + {totals.variance} + + ); + default: + return null; + } + }; + + const renderFinancialTableColumnContent = (columnKey: any, task: any) => { + switch (columnKey) { + case 'task': + return ( + + + + {task.task} + + + {task.isbBillable && } + + + ); + case 'members': + return ( + + {task.members.map((member: any) => ( + + ))} + + ); + case 'hours': + return {task.hours}; + case 'cost': + return {task.cost}; + case 'fixedCost': + return ( + + ); + case 'totalBudget': + return ( + + ); + case 'totalActual': + return {task.totalActual}; + case 'variance': + return ( + + {task.variance} + + ); + default: + return null; + } + }; + + // layout styles for table and the columns + const customColumnHeaderStyles = (key: string) => + `px-2 text-left ${key === 'tableTitle' && `sticky left-0 z-10 ${isScrolling ? 'after:content after:absolute after:top-0 after:-right-1 after:-z-10 after:h-[40px] after:w-1.5 after:bg-transparent after:bg-gradient-to-r after:from-[rgba(0,0,0,0.12)] after:to-transparent' : ''}`}`; + + const customColumnStyles = (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-[52px] after:w-1.5 after:bg-transparent after:bg-gradient-to-r after:from-[rgba(0,0,0,0.12)] after:to-transparent' : ''}`} ${themeMode === 'dark' ? 'bg-[#1d1d1d] border-[#303030]' : 'bg-[#fafafa]'}`; + + return ( + <> + {/* header row */} + + setIsCollapse((prev) => !prev)} + > + + {isCollapse ? : } + {table.name} ({table.tasks.length}) + + + + {financeTableColumns.map( + (col) => + col.key !== 'task' && + col.key !== 'members' && ( + + {renderFinancialTableHeaderContent(col.key)} + + ) + )} + + + {/* task rows */} + {table.tasks.map((task: any) => ( + onTaskClick(task)} + > + + + + {financeTableColumns.map((col) => ( + + {renderFinancialTableColumnContent(col.key, task)} + + ))} + + ))} + + ); +}; + +export default FinanceTable; diff --git a/worklenz-frontend/src/pages/projects/projectView/finance/project-view-finance-header/group-by-filter-dropdown.tsx b/worklenz-frontend/src/pages/projects/projectView/finance/project-view-finance-header/group-by-filter-dropdown.tsx new file mode 100644 index 00000000..fad9365d --- /dev/null +++ b/worklenz-frontend/src/pages/projects/projectView/finance/project-view-finance-header/group-by-filter-dropdown.tsx @@ -0,0 +1,56 @@ +import { CaretDownFilled } from '@ant-design/icons'; +import { Flex, Select } from 'antd'; +import React from 'react'; +import { useSelectedProject } from '../../../../../hooks/useSelectedProject'; +import { useAppSelector } from '../../../../../hooks/useAppSelector'; +import { useTranslation } from 'react-i18next'; + +type GroupByFilterDropdownProps = { + activeGroup: 'status' | 'priority' | 'phases'; + setActiveGroup: (group: 'status' | 'priority' | 'phases') => void; +}; + +const GroupByFilterDropdown = ({ + activeGroup, + setActiveGroup, +}: GroupByFilterDropdownProps) => { + // localization + const { t } = useTranslation('project-view-finance'); + + const handleChange = (value: string) => { + setActiveGroup(value as 'status' | 'priority' | 'phases'); + }; + + // get selected project from useSelectedPro + const selectedProject = useSelectedProject(); + + //get phases details from phases slice + const phase = + useAppSelector((state) => state.phaseReducer.phaseList).find( + (phase) => phase?.projectId === selectedProject?.projectId + ) || null; + + const groupDropdownMenuItems = [ + { key: 'status', value: 'status', label: t('statusText') }, + { key: 'priority', value: 'priority', label: t('priorityText') }, + { + key: 'phase', + value: 'phase', + label: phase ? phase?.phase : t('phaseText'), + }, + ]; + + return ( + + {t('groupByText')}: + dispatch(changeCurrency(value))} + /> + + + + )} + + + ); +}; + +export default ProjectViewFinanceHeader; diff --git a/worklenz-frontend/src/pages/projects/projectView/finance/project-view-finance.tsx b/worklenz-frontend/src/pages/projects/projectView/finance/project-view-finance.tsx new file mode 100644 index 00000000..d2c685f7 --- /dev/null +++ b/worklenz-frontend/src/pages/projects/projectView/finance/project-view-finance.tsx @@ -0,0 +1,32 @@ +import { Flex } from 'antd'; +import React, { useState } from 'react'; +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'; + +type FinanceTabType = 'finance' | 'ratecard'; +type GroupTypes = 'status' | 'priority' | 'phases'; + +const ProjectViewFinance = () => { + const [activeTab, setActiveTab] = useState('finance'); + const [activeGroup, setActiveGroup] = useState('status'); + + return ( + + + + {activeTab === 'finance' ? ( + + ) : ( + + )} + + ); +}; + +export default ProjectViewFinance; diff --git a/worklenz-frontend/src/pages/projects/projectView/finance/ratecard-tab/ratecard-tab.tsx b/worklenz-frontend/src/pages/projects/projectView/finance/ratecard-tab/ratecard-tab.tsx new file mode 100644 index 00000000..6119c8b9 --- /dev/null +++ b/worklenz-frontend/src/pages/projects/projectView/finance/ratecard-tab/ratecard-tab.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import RatecardTable from './reatecard-table/ratecard-table'; +import { Button, Flex, Typography } from 'antd'; +import { useTranslation } from 'react-i18next'; +import ImportRatecardsDrawer from '@/features/finance/ratecard-drawer/import-ratecards-drawer'; + +const RatecardTab = () => { + // localization + const { t } = useTranslation('project-view-finance'); + + return ( + + + + + {t('ratecardImportantNotice')} + + + + {/* import ratecards drawer */} + + + ); +}; + +export default RatecardTab; diff --git a/worklenz-frontend/src/pages/projects/projectView/finance/ratecard-tab/reatecard-table/ratecard-table.tsx b/worklenz-frontend/src/pages/projects/projectView/finance/ratecard-tab/reatecard-table/ratecard-table.tsx new file mode 100644 index 00000000..85d73b25 --- /dev/null +++ b/worklenz-frontend/src/pages/projects/projectView/finance/ratecard-tab/reatecard-table/ratecard-table.tsx @@ -0,0 +1,150 @@ +import { Avatar, Button, Input, Table, TableProps } from 'antd'; +import React, { useState } from 'react'; +import CustomAvatar from '../../../../../../components/CustomAvatar'; +import { PlusOutlined } from '@ant-design/icons'; +import { useAppSelector } from '../../../../../../hooks/useAppSelector'; +import { useTranslation } from 'react-i18next'; +import { JobRoleType } from '@/types/project/ratecard.types'; + +const initialJobRolesList: JobRoleType[] = [ + { + jobId: 'J001', + jobTitle: 'Project Manager', + ratePerHour: 50, + members: ['Alice Johnson', 'Bob Smith'], + }, + { + jobId: 'J002', + jobTitle: 'Senior Software Engineer', + ratePerHour: 40, + members: ['Charlie Brown', 'Diana Prince'], + }, + { + jobId: 'J003', + jobTitle: 'Junior Software Engineer', + ratePerHour: 25, + members: ['Eve Davis', 'Frank Castle'], + }, + { + jobId: 'J004', + jobTitle: 'UI/UX Designer', + ratePerHour: 30, + members: null, + }, +]; + +const RatecardTable: React.FC = () => { + const [roles, setRoles] = useState(initialJobRolesList); + + // localization + const { t } = useTranslation('project-view-finance'); + + // get currently using currency from finance reducer + const currency = useAppSelector( + (state) => state.financeReducer.currency + ).toUpperCase(); + + const handleAddRole = () => { + const newRole: JobRoleType = { + jobId: `J00${roles.length + 1}`, + jobTitle: 'New Role', + ratePerHour: 0, + members: [], + }; + setRoles([...roles, newRole]); + }; + + const columns: TableProps['columns'] = [ + { + title: t('jobTitleColumn'), + dataIndex: 'jobTitle', + render: (text: string, record: JobRoleType, index: number) => ( + { + const updatedRoles = [...roles]; + updatedRoles[index].jobTitle = e.target.value; + setRoles(updatedRoles); + }} + /> + ), + }, + { + title: `${t('ratePerHourColumn')} (${currency})`, + dataIndex: 'ratePerHour', + render: (text: number, record: JobRoleType, index: number) => ( + { + const updatedRoles = [...roles]; + updatedRoles[index].ratePerHour = parseInt(e.target.value, 10) || 0; + setRoles(updatedRoles); + }} + /> + ), + }, + { + title: t('membersColumn'), + dataIndex: 'members', + render: (members: string[]) => + members?.length > 0 ? ( + + {members.map((member, i) => ( + + ))} + + ) : ( +