From 6847eec6036b1d3f07421364ced66bffb5bb7275 Mon Sep 17 00:00:00 2001 From: shancds Date: Wed, 14 May 2025 22:20:50 +0530 Subject: [PATCH 001/105] 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 000000000..ed43b4bff --- /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 000000000..fdf9849dc --- /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 000000000..db5c67c68 --- /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 6bf7adcf1..2a34813a1 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 000000000..851f6d760 --- /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 000000000..9a2bce124 --- /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 000000000..c5888fb4b --- /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 000000000..181eb7192 --- /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 fc4b8e87b..43571cc59 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 000000000..e08bd430c --- /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 000000000..b421d9de4 --- /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 000000000..60054604d --- /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 000000000..b6ea67ad3 --- /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 000000000..fad9365d6 --- /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 000000000..d2c685f7f --- /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 000000000..6119c8b97 --- /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 000000000..85d73b255 --- /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) => ( + + ))} + + ) : ( + @@ -359,11 +383,32 @@ const RatecardDrawer = ({ width={700} footer={ - + } > - {/* ratecard Table directly inside the Drawer */} + {showUnsavedAlert && ( + setShowUnsavedAlert(false)} + action={ + + + + + } + style={{ marginBottom: 16 }} + /> + )} + + ); }; -export default RatecardDrawer; +export default RatecardDrawer; \ No newline at end of file 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 index 410aa1ca8..9413e05d4 100644 --- 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 @@ -11,6 +11,7 @@ import { deleteProjectRateCardRoleById, fetchProjectRateCardRoles, insertProjectRateCardRole, + updateProjectRateCardRoleById, updateProjectRateCardRolesByProjectId, } from '@/features/finance/project-finance-slice'; import { useParams } from 'react-router-dom'; @@ -18,6 +19,7 @@ import { jobTitlesApiService } from '@/api/settings/job-titles/job-titles.api.se import RateCardAssigneeSelector from '@/components/project-ratecard/ratecard-assignee-selector'; import { projectsApiService } from '@/api/projects/projects.api.service'; import { IProjectMemberViewModel } from '@/types/projectMember.types'; +import { parse } from 'path'; const RatecardTable: React.FC = () => { const dispatch = useAppDispatch(); @@ -226,6 +228,17 @@ const RatecardTable: React.FC = () => { textAlign: 'right', }} onChange={(e) => handleRateChange(e.target.value, index)} + onBlur={(e) => { + if (e.target.value !== roles[index].rate) { + dispatch(updateProjectRateCardRoleById({ + id: roles[index].id!, + body: { + job_title_id: roles[index].job_title_id, + rate: e.target.value, + } + })); + } + }} /> ), }, diff --git a/worklenz-frontend/src/pages/settings/ratecard/ratecard-settings.tsx b/worklenz-frontend/src/pages/settings/ratecard/ratecard-settings.tsx index d236e91ad..19a00b6d2 100644 --- a/worklenz-frontend/src/pages/settings/ratecard/ratecard-settings.tsx +++ b/worklenz-frontend/src/pages/settings/ratecard/ratecard-settings.tsx @@ -42,7 +42,7 @@ const RatecardSettings: React.FC = () => { const { t } = useTranslation('/settings/ratecard-settings'); const dispatch = useAppDispatch(); useDocumentTitle('Manage Rate Cards'); - + const isDrawerOpen = useAppSelector(state => state.financeReducer.isRatecardDrawerOpen); const [loading, setLoading] = useState(false); const [ratecardsList, setRatecardsList] = useState([]); const [searchQuery, setSearchQuery] = useState(''); @@ -87,7 +87,7 @@ const RatecardSettings: React.FC = () => { useEffect(() => { fetchRateCards(); - }, [toggleRatecardDrawer, dispatch]); + }, [toggleRatecardDrawer, isDrawerOpen]); From cf0eaad077c786325a1550e6fabb7c3f091bcd84 Mon Sep 17 00:00:00 2001 From: shancds Date: Tue, 27 May 2025 14:16:45 +0530 Subject: [PATCH 042/105] feat(ratecard-table): improve rate handling and focus management in the ratecard table --- .../project-ratecard-controller.ts | 8 +-- .../reatecard-table/ratecard-table.tsx | 62 ++++++++++--------- 2 files changed, 38 insertions(+), 32 deletions(-) diff --git a/worklenz-backend/src/controllers/project-ratecard-controller.ts b/worklenz-backend/src/controllers/project-ratecard-controller.ts index 8da36de61..79069006c 100644 --- a/worklenz-backend/src/controllers/project-ratecard-controller.ts +++ b/worklenz-backend/src/controllers/project-ratecard-controller.ts @@ -57,14 +57,14 @@ export default class ProjectRateCardController extends WorklenzControllerBase { fprr.*, jt.name as jobtitle, ( - SELECT COALESCE(json_agg(pm.id), '[]'::json) - FROM project_members pm - WHERE pm.project_rate_card_role_id = fprr.id + SELECT COALESCE(json_agg(pm.id), '[]'::json) + FROM project_members pm + WHERE pm.project_rate_card_role_id = fprr.id ) AS members 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; + ORDER BY fprr.created_at; `; const result = await db.query(q, [project_id]); return res.status(200).send(new ServerResponse(true, result.rows)); 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 index 9413e05d4..51818231f 100644 --- 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 @@ -30,13 +30,14 @@ const RatecardTable: React.FC = () => { const rolesRedux = useAppSelector((state) => state.projectFinanceRateCard.rateCardRoles) || []; const isLoading = useAppSelector((state) => state.projectFinanceRateCard.isLoading); const currency = useAppSelector((state) => state.financeReducer.currency).toUpperCase(); - + const rateInputRefs = React.useRef>([]); // Local state for editing const [roles, setRoles] = useState(rolesRedux); const [addingRow, setAddingRow] = useState(false); const [jobTitles, setJobTitles] = useState([]); const [members, setMembers] = useState([]); const [isLoadingMembers, setIsLoading] = useState(false); + const [focusRateIndex, setFocusRateIndex] = useState(null); const pagination = { current: 1, @@ -91,6 +92,13 @@ const RatecardTable: React.FC = () => { } }, [dispatch, projectId]); + useEffect(() => { + if (focusRateIndex !== null && rateInputRefs.current[focusRateIndex]) { + rateInputRefs.current[focusRateIndex]?.focus(); + setFocusRateIndex(null); + } + }, [roles, focusRateIndex]); + // Add new role row const handleAddRole = () => { setAddingRow(true); @@ -110,7 +118,7 @@ const RatecardTable: React.FC = () => { } }; - // Handle job title select for new row + // In handleSelectJobTitle, after successful insert, update the rate if needed const handleSelectJobTitle = async (jobTitleId: string) => { const jobTitle = jobTitles.find((jt) => jt.id === jobTitleId); if (!jobTitle || !projectId) return; @@ -120,27 +128,21 @@ const RatecardTable: React.FC = () => { ); if (insertProjectRateCardRole.fulfilled.match(resultAction)) { - const newRole = resultAction.payload; - setRoles([ - ...roles, - { - id: newRole.id, - job_title_id: newRole.job_title_id, - jobtitle: newRole.jobtitle, - rate: newRole.rate, - members: [], // Initialize members array - }, - ]); + // Re-fetch roles and focus the last one (newly added) + dispatch(fetchProjectRateCardRoles(projectId)).then(() => { + setFocusRateIndex(roles.length); // The new row will be at the end + }); } setAddingRow(false); }; - // Handle rate change + // Update handleRateChange to always update local state const handleRateChange = (value: string | number, index: number) => { - const updatedRoles = roles.map((role, idx) => - idx === index ? { ...role, rate: Number(value) } : role + setRoles(prev => + prev.map((role, idx) => + idx === index ? { ...role, rate: Number(value) } : role + ) ); - setRoles(updatedRoles); }; // Handle delete @@ -176,6 +178,18 @@ const RatecardTable: React.FC = () => { console.error('Error assigning member:', error); } }; + // Separate function for updating rate if changed + const handleRateBlur = (value: string, index: number) => { + if (value !== roles[index].rate) { + dispatch(updateProjectRateCardRoleById({ + id: roles[index].id!, + body: { + job_title_id: roles[index].job_title_id, + rate: value, + } + })); + } + }; // Columns const columns: TableProps['columns'] = [ @@ -216,6 +230,7 @@ const RatecardTable: React.FC = () => { align: 'right', render: (value: number, record: JobRoleType, index: number) => ( rateInputRefs.current[index] = el} type="number" value={roles[index]?.rate ?? 0} min={0} @@ -228,17 +243,8 @@ const RatecardTable: React.FC = () => { textAlign: 'right', }} onChange={(e) => handleRateChange(e.target.value, index)} - onBlur={(e) => { - if (e.target.value !== roles[index].rate) { - dispatch(updateProjectRateCardRoleById({ - id: roles[index].id!, - body: { - job_title_id: roles[index].job_title_id, - rate: e.target.value, - } - })); - } - }} + onBlur={(e) => handleRateBlur(e.target.value, index)} + onPressEnter={(e) => handleRateBlur(e.target.value, index)} /> ), }, From ca0c958918320d1ae0233300022d9a353e551abf Mon Sep 17 00:00:00 2001 From: chamiakJ Date: Tue, 27 May 2025 16:42:51 +0530 Subject: [PATCH 043/105] fix(project-finance-controller): correct estimated hours calculation to reflect hours instead of minutes --- worklenz-backend/src/controllers/project-finance-controller.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/worklenz-backend/src/controllers/project-finance-controller.ts b/worklenz-backend/src/controllers/project-finance-controller.ts index 5a9903be5..f0b2bbb5f 100644 --- a/worklenz-backend/src/controllers/project-finance-controller.ts +++ b/worklenz-backend/src/controllers/project-finance-controller.ts @@ -40,7 +40,7 @@ export default class ProjectfinanceController extends WorklenzControllerBase { SELECT t.id, t.name, - COALESCE(t.total_minutes, 0)::float as estimated_hours, + COALESCE(t.total_minutes, 0) / 60.0::float as estimated_hours, COALESCE((SELECT SUM(time_spent) FROM task_work_log WHERE task_id = t.id), 0) / 3600.0::float as total_time_logged, t.project_id, t.status_id, From 010cbe1af8c3e48dc87401390b9dd2ce8431bb50 Mon Sep 17 00:00:00 2001 From: shancds Date: Wed, 28 May 2025 10:50:35 +0530 Subject: [PATCH 044/105] feat(ratecard-drawer): enhance drawer close logic to handle unsaved changes and delete empty rate cards --- .../ratecard-drawer/ratecard-drawer.tsx | 191 +++++++++--------- 1 file changed, 98 insertions(+), 93 deletions(-) diff --git a/worklenz-frontend/src/features/finance/ratecard-drawer/ratecard-drawer.tsx b/worklenz-frontend/src/features/finance/ratecard-drawer/ratecard-drawer.tsx index f36457528..b968f3e3c 100644 --- a/worklenz-frontend/src/features/finance/ratecard-drawer/ratecard-drawer.tsx +++ b/worklenz-frontend/src/features/finance/ratecard-drawer/ratecard-drawer.tsx @@ -307,16 +307,21 @@ const RatecardDrawer = ({ }, ]; - const handleDrawerClose = () => { - if (!name || name.trim() === '' || name === 'Untitled Rate Card') { + const handleDrawerClose = async() => { + if (!name || name.trim() === '') { messageApi.open({ - type: 'warning', - content: t('ratecardNameRequired') || 'Rate card name is required.', - }); - return; - } else if (hasChanges) { + type: 'warning', + content: t('ratecardNameRequired') || 'Rate card name is required.', + }); + return; + } else if (hasChanges) { setShowUnsavedAlert(true); - } else { + } + else if (name === 'Untitled Rate Card' && roles.length === 0){ + await dispatch(deleteRateCard(ratecardId)); + dispatch(toggleRatecardDrawer()); + } + else { dispatch(toggleRatecardDrawer()); } }; @@ -339,95 +344,95 @@ const RatecardDrawer = ({ return ( <> - {contextHolder} - - - { - setName(e.target.value); - }} - /> - - - {t('currency')} - { + setName(e.target.value); + }} + /> + + + {t('currency')} +
record.job_title_id} - pagination={false} - footer={() => ( - + } + > + {showUnsavedAlert && ( + setShowUnsavedAlert(false)} + action={ + + + + + } + style={{ marginBottom: 16 }} + /> )} - /> - +
record.job_title_id} + pagination={false} + footer={() => ( + + )} + /> + - + ); }; From 5cb6548889bd5bd4b6dc60f59be3508f2ff125e4 Mon Sep 17 00:00:00 2001 From: shancds Date: Wed, 28 May 2025 12:12:33 +0530 Subject: [PATCH 045/105] feat(import-ratecards-drawer): add alert for already imported rate cards and update button logic --- .../locales/en/project-view-finance.json | 3 +- .../import-ratecards-drawer.tsx | 67 +++++++++++-------- 2 files changed, 42 insertions(+), 28 deletions(-) diff --git a/worklenz-frontend/public/locales/en/project-view-finance.json b/worklenz-frontend/public/locales/en/project-view-finance.json index 2c2d0325d..642422b21 100644 --- a/worklenz-frontend/public/locales/en/project-view-finance.json +++ b/worklenz-frontend/public/locales/en/project-view-finance.json @@ -35,7 +35,8 @@ "ratecardsPluralText": "Rate Card Templates", "deleteConfirm": "Are you sure ?", "yes": "Yes", - "no": "No" + "no": "No", + "alreadyImportedRateCardMessage": "A rate card has already been imported. Clear all imported rate cards to add a new one." } \ No newline at end of file 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 index ff44f9f04..3e9e4d108 100644 --- a/worklenz-frontend/src/features/finance/ratecard-drawer/import-ratecards-drawer.tsx +++ b/worklenz-frontend/src/features/finance/ratecard-drawer/import-ratecards-drawer.tsx @@ -1,4 +1,4 @@ -import { Drawer, Typography, Button, Table, Menu, Flex, Spin } from 'antd'; +import { Drawer, Typography, Button, Table, Menu, Flex, Spin, Alert } from 'antd'; import React, { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useAppSelector } from '../../../hooks/useAppSelector'; @@ -85,32 +85,45 @@ const ImportRatecardsDrawer: React.FC = () => { } footer={
- + {/* Alert message */} + {rolesRedux.length !== 0 ? ( +
+ +
+ ) : ( +
+ +
+ )}
} open={isDrawerOpen} From 07bc5e6030bb78e41b8861c2d01f3b680f1a8a23 Mon Sep 17 00:00:00 2001 From: chamiakJ Date: Wed, 28 May 2025 12:28:03 +0530 Subject: [PATCH 046/105] feat(project-finance): implement time formatting utilities and update task time handling - Added utility functions to format time in hours, minutes, and seconds, and to parse time strings back to seconds. - Updated the project finance controller to use seconds for estimated time and total time logged, improving accuracy in calculations. - Modified frontend components to reflect changes in time handling, ensuring consistent display of time in both seconds and formatted strings. - Adjusted Redux slice and types to accommodate new time formats, enhancing data integrity across the application. --- .../controllers/project-finance-controller.ts | 44 ++++++++-- .../projects/finance/project-finance.slice.ts | 14 +-- .../finance/finance-tab/finance-tab.tsx | 23 +---- .../finance-table/finance-table-wrapper.tsx | 28 ++++-- .../finance-table/finance-table.tsx | 88 ++++++++++++++++--- .../types/project/project-finance.types.ts | 6 +- worklenz-frontend/src/utils/timeUtils.ts | 15 ++++ 7 files changed, 163 insertions(+), 55 deletions(-) diff --git a/worklenz-backend/src/controllers/project-finance-controller.ts b/worklenz-backend/src/controllers/project-finance-controller.ts index f0b2bbb5f..fdc987892 100644 --- a/worklenz-backend/src/controllers/project-finance-controller.ts +++ b/worklenz-backend/src/controllers/project-finance-controller.ts @@ -8,6 +8,38 @@ import HandleExceptions from "../decorators/handle-exceptions"; import { TASK_STATUS_COLOR_ALPHA } from "../shared/constants"; import { getColor } from "../shared/utils"; +// Utility function to format time in hours, minutes, seconds format +const formatTimeToHMS = (totalSeconds: number): string => { + if (!totalSeconds || totalSeconds === 0) return "0s"; + + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + const parts = []; + if (hours > 0) parts.push(`${hours}h`); + if (minutes > 0) parts.push(`${minutes}m`); + if (seconds > 0 || parts.length === 0) parts.push(`${seconds}s`); + + return parts.join(' '); +}; + +// Utility function to parse time string back to seconds for calculations +const parseTimeToSeconds = (timeString: string): number => { + if (!timeString || timeString === "0s") return 0; + + let totalSeconds = 0; + const hourMatch = timeString.match(/(\d+)h/); + const minuteMatch = timeString.match(/(\d+)m/); + const secondMatch = timeString.match(/(\d+)s/); + + if (hourMatch) totalSeconds += parseInt(hourMatch[1]) * 3600; + if (minuteMatch) totalSeconds += parseInt(minuteMatch[1]) * 60; + if (secondMatch) totalSeconds += parseInt(secondMatch[1]); + + return totalSeconds; +}; + export default class ProjectfinanceController extends WorklenzControllerBase { @HandleExceptions() public static async getTasks( @@ -40,8 +72,8 @@ export default class ProjectfinanceController extends WorklenzControllerBase { SELECT t.id, t.name, - COALESCE(t.total_minutes, 0) / 60.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(t.total_minutes * 60, 0) as estimated_seconds, + COALESCE((SELECT SUM(time_spent) FROM task_work_log WHERE task_id = t.id), 0) as total_time_logged_seconds, t.project_id, t.status_id, t.priority_id, @@ -57,7 +89,7 @@ export default class ProjectfinanceController extends WorklenzControllerBase { tc.*, -- Calculate estimated cost based on estimated hours and assignee rates from project_members COALESCE(( - SELECT SUM(tc.estimated_hours * COALESCE(fprr.rate, 0)) + SELECT SUM((tc.estimated_seconds / 3600.0) * 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 @@ -198,8 +230,10 @@ export default class ProjectfinanceController extends WorklenzControllerBase { tasks: groupTasks.map(task => ({ id: task.id, name: task.name, - estimated_hours: Number(task.estimated_hours) || 0, - total_time_logged: Number(task.total_time_logged) || 0, + estimated_seconds: Number(task.estimated_seconds) || 0, + estimated_hours: formatTimeToHMS(Number(task.estimated_seconds) || 0), + total_time_logged_seconds: Number(task.total_time_logged_seconds) || 0, + total_time_logged: formatTimeToHMS(Number(task.total_time_logged_seconds) || 0), estimated_cost: Number(task.estimated_cost) || 0, fixed_cost: Number(task.fixed_cost) || 0, total_budget: Number(task.total_budget) || 0, diff --git a/worklenz-frontend/src/features/projects/finance/project-finance.slice.ts b/worklenz-frontend/src/features/projects/finance/project-finance.slice.ts index 914aae76a..1e2ede86a 100644 --- a/worklenz-frontend/src/features/projects/finance/project-finance.slice.ts +++ b/worklenz-frontend/src/features/projects/finance/project-finance.slice.ts @@ -1,6 +1,7 @@ import { projectFinanceApiService } from '@/api/project-finance-ratecard/project-finance.api.service'; import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit'; import { IProjectFinanceGroup, IProjectFinanceTask, IProjectRateCard } from '@/types/project/project-finance.types'; +import { parseTimeToSeconds } from '@/utils/timeUtils'; type FinanceTabType = 'finance' | 'ratecard'; type GroupTypes = 'status' | 'priority' | 'phases'; @@ -14,11 +15,11 @@ interface ProjectFinanceState { } // Utility functions for frontend calculations -const minutesToHours = (minutes: number) => minutes / 60; +const secondsToHours = (seconds: number) => seconds / 3600; const calculateTaskCosts = (task: IProjectFinanceTask) => { - const hours = minutesToHours(task.estimated_hours || 0); - const timeLoggedHours = minutesToHours(task.total_time_logged || 0); + const hours = secondsToHours(task.estimated_seconds || 0); + const timeLoggedHours = secondsToHours(task.total_time_logged_seconds || 0); const fixedCost = task.fixed_cost || 0; // Calculate total budget (estimated hours * rate + fixed cost) @@ -127,13 +128,14 @@ export const projectFinancesSlice = createSlice({ } } }, - updateTaskTimeLogged: (state, action: PayloadAction<{ taskId: string; groupId: string; timeLogged: number }>) => { - const { taskId, groupId, timeLogged } = action.payload; + updateTaskTimeLogged: (state, action: PayloadAction<{ taskId: string; groupId: string; timeLoggedSeconds: number; timeLoggedString: string }>) => { + const { taskId, groupId, timeLoggedSeconds, timeLoggedString } = action.payload; const group = state.taskGroups.find(g => g.group_id === groupId); if (group) { const task = group.tasks.find(t => t.id === taskId); if (task) { - task.total_time_logged = timeLogged; + task.total_time_logged_seconds = timeLoggedSeconds; + task.total_time_logged = timeLoggedString; // Recalculate task costs after updating time logged const { totalBudget, totalActual, variance } = calculateTaskCosts(task); task.total_budget = totalBudget; 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 index 95af2101e..d57a50aa6 100644 --- 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 @@ -12,31 +12,10 @@ const FinanceTab = ({ taskGroups = [], loading }: FinanceTabProps) => { - // Transform taskGroups into the format expected by FinanceTableWrapper - const activeTablesList = (taskGroups || []).map(group => ({ - group_id: group.group_id, - group_name: group.group_name, - color_code: group.color_code, - color_code_dark: group.color_code_dark, - tasks: (group.tasks || []).map(task => ({ - id: task.id, - name: task.name, - hours: task.estimated_hours || 0, - cost: task.estimated_cost || 0, - fixedCost: task.fixed_cost || 0, - totalBudget: task.total_budget || 0, - totalActual: task.total_actual || 0, - variance: task.variance || 0, - members: task.members || [], - isbBillable: task.billable, - total_time_logged: task.total_time_logged || 0, - estimated_cost: task.estimated_cost || 0 - })) - })); return (
- +
); }; 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 index dff26f542..62d4a38ce 100644 --- 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 @@ -17,6 +17,22 @@ interface FinanceTableWrapperProps { loading: boolean; } +// Utility function to format seconds to time string +const formatSecondsToTimeString = (totalSeconds: number): string => { + if (!totalSeconds || totalSeconds === 0) return "0s"; + + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + const parts = []; + if (hours > 0) parts.push(`${hours}h`); + if (minutes > 0) parts.push(`${minutes}m`); + if (seconds > 0 || parts.length === 0) parts.push(`${seconds}s`); + + return parts.join(' '); +}; + const FinanceTableWrapper: React.FC = ({ activeTablesList, loading }) => { const [isScrolling, setIsScrolling] = useState(false); const [editingFixedCost, setEditingFixedCost] = useState<{ taskId: string; groupId: string } | null>(null); @@ -80,13 +96,13 @@ const FinanceTableWrapper: React.FC = ({ activeTablesL table: IProjectFinanceGroup ) => { table.tasks.forEach((task) => { - acc.hours += (task.estimated_hours / 60) || 0; + acc.hours += (task.estimated_seconds) || 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.total_time_logged += (task.total_time_logged_seconds) || 0; acc.estimated_cost += task.estimated_cost || 0; }); return acc; @@ -114,9 +130,7 @@ const FinanceTableWrapper: React.FC = ({ activeTablesL case FinanceTableColumnKeys.HOURS: return ( - - {formatHoursToReadable(totals.hours).toFixed(2)} - + {formatSecondsToTimeString(totals.hours)} ); case FinanceTableColumnKeys.COST: @@ -131,7 +145,7 @@ const FinanceTableWrapper: React.FC = ({ activeTablesL return ( 0 ? '#FF0000' : '#6DC376', fontSize: 18, }} > @@ -141,7 +155,7 @@ const FinanceTableWrapper: React.FC = ({ activeTablesL case FinanceTableColumnKeys.TOTAL_TIME_LOGGED: return ( - {totals.total_time_logged?.toFixed(2)} + {formatSecondsToTimeString(totals.total_time_logged)} ); case FinanceTableColumnKeys.ESTIMATED_COST: 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 index b882c55ae..5c6328205 100644 --- 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 @@ -13,6 +13,9 @@ import Avatars from '@/components/avatars/avatars'; import { IProjectFinanceGroup, IProjectFinanceTask } from '@/types/project/project-finance.types'; import { updateTaskFixedCostAsync, updateTaskFixedCost } from '@/features/projects/finance/project-finance.slice'; import { useAppDispatch } from '@/hooks/useAppDispatch'; +import { setSelectedTaskId, setShowTaskDrawer, fetchTask } from '@/features/task-drawer/task-drawer.slice'; +import { useParams } from 'react-router-dom'; +import { parseTimeToSeconds } from '@/utils/timeUtils'; import './finance-table.css'; type FinanceTableProps = { @@ -78,19 +81,19 @@ const FinanceTable = ({ const renderFinancialTableHeaderContent = (columnKey: FinanceTableColumnKeys) => { switch (columnKey) { case FinanceTableColumnKeys.HOURS: - return {formatNumber(totals.hours)}; + return {formattedTotals.hours}; case FinanceTableColumnKeys.TOTAL_TIME_LOGGED: - return {formatNumber(totals.total_time_logged)}; + return {formattedTotals.total_time_logged}; case FinanceTableColumnKeys.ESTIMATED_COST: - return {formatNumber(totals.estimated_cost)}; + return {formatNumber(formattedTotals.estimated_cost)}; case FinanceTableColumnKeys.FIXED_COST: - return {formatNumber(totals.fixed_cost)}; + return {formatNumber(formattedTotals.fixed_cost)}; case FinanceTableColumnKeys.TOTAL_BUDGET: - return {formatNumber(totals.total_budget)}; + return {formatNumber(formattedTotals.total_budget)}; case FinanceTableColumnKeys.TOTAL_ACTUAL: - return {formatNumber(totals.total_actual)}; + return {formatNumber(formattedTotals.total_actual)}; case FinanceTableColumnKeys.VARIANCE: - return {formatNumber(totals.variance)}; + return 0 ? '#FF0000' : '#6DC376' }}>{formatNumber(formattedTotals.variance)}; default: return null; } @@ -106,6 +109,16 @@ const FinanceTable = ({ dispatch(updateTaskFixedCostAsync({ taskId, groupId: table.group_id, fixedCost })); }; + const { projectId } = useParams<{ projectId: string }>(); + + const handleTaskNameClick = (taskId: string) => { + if (!taskId || !projectId) return; + + dispatch(setSelectedTaskId(taskId)); + dispatch(setShowTaskDrawer(true)); + dispatch(fetchTask({ taskId, projectId })); + }; + const renderFinancialTableColumnContent = (columnKey: FinanceTableColumnKeys, task: IProjectFinanceTask) => { switch (columnKey) { case FinanceTableColumnKeys.TASK: @@ -114,7 +127,21 @@ const FinanceTable = ({ { + e.stopPropagation(); + handleTaskNameClick(task.id); + }} + onMouseEnter={(e) => { + e.currentTarget.style.textDecoration = 'underline'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.textDecoration = 'none'; + }} > {task.name} @@ -144,9 +171,9 @@ const FinanceTable = ({ ); case FinanceTableColumnKeys.HOURS: - return {formatNumber(task.estimated_hours / 60)}; + return {task.estimated_hours}; case FinanceTableColumnKeys.TOTAL_TIME_LOGGED: - return {formatNumber(task.total_time_logged / 60)}; + return {task.total_time_logged}; case FinanceTableColumnKeys.ESTIMATED_COST: return {formatNumber(task.estimated_cost)}; case FinanceTableColumnKeys.FIXED_COST: @@ -181,7 +208,15 @@ const FinanceTable = ({ ); case FinanceTableColumnKeys.VARIANCE: - return {formatNumber(task.variance)}; + return ( + 0 ? '#FF0000' : '#6DC376' + }} + > + {formatNumber(formattedTotals.variance)} + + ); case FinanceTableColumnKeys.TOTAL_BUDGET: return {formatNumber(task.total_budget)}; case FinanceTableColumnKeys.TOTAL_ACTUAL: @@ -193,12 +228,28 @@ const FinanceTable = ({ } }; + // Utility function to format seconds to time string + const formatSecondsToTimeString = (totalSeconds: number): string => { + if (!totalSeconds || totalSeconds === 0) return "0s"; + + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + const parts = []; + if (hours > 0) parts.push(`${hours}h`); + if (minutes > 0) parts.push(`${minutes}m`); + if (seconds > 0 || parts.length === 0) parts.push(`${seconds}s`); + + return parts.join(' '); + }; + // Calculate totals for the current table const totals = useMemo(() => { return tasks.reduce( (acc, task) => ({ - hours: acc.hours + (task.estimated_hours / 60), - total_time_logged: acc.total_time_logged + (task.total_time_logged / 60), + hours: acc.hours + (task.estimated_seconds || 0), + total_time_logged: acc.total_time_logged + (task.total_time_logged_seconds || 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), @@ -217,6 +268,17 @@ const FinanceTable = ({ ); }, [tasks]); + // Format the totals for display + const formattedTotals = useMemo(() => ({ + hours: formatSecondsToTimeString(totals.hours), + total_time_logged: formatSecondsToTimeString(totals.total_time_logged), + estimated_cost: totals.estimated_cost, + fixed_cost: totals.fixed_cost, + total_budget: totals.total_budget, + total_actual: totals.total_actual, + variance: totals.variance + }), [totals]); + return ( <> diff --git a/worklenz-frontend/src/types/project/project-finance.types.ts b/worklenz-frontend/src/types/project/project-finance.types.ts index 125a573f1..f52a265ab 100644 --- a/worklenz-frontend/src/types/project/project-finance.types.ts +++ b/worklenz-frontend/src/types/project/project-finance.types.ts @@ -29,8 +29,10 @@ export interface IProjectFinanceMember { export interface IProjectFinanceTask { id: string; name: string; - estimated_hours: number; - total_time_logged: number; + estimated_seconds: number; + estimated_hours: string; // Formatted time string like "4h 30m 12s" + total_time_logged_seconds: number; + total_time_logged: string; // Formatted time string like "4h 30m 12s" estimated_cost: number; members: IProjectFinanceMember[]; billable: boolean; diff --git a/worklenz-frontend/src/utils/timeUtils.ts b/worklenz-frontend/src/utils/timeUtils.ts index 6f2f6dbef..168a38931 100644 --- a/worklenz-frontend/src/utils/timeUtils.ts +++ b/worklenz-frontend/src/utils/timeUtils.ts @@ -4,6 +4,21 @@ export function formatDate(date: Date): string { return dayjs(date).format('MMM DD, YYYY'); } +export function parseTimeToSeconds(timeString: string): number { + if (!timeString || timeString === "0s") return 0; + + let totalSeconds = 0; + const hourMatch = timeString.match(/(\d+)h/); + const minuteMatch = timeString.match(/(\d+)m/); + const secondMatch = timeString.match(/(\d+)s/); + + if (hourMatch) totalSeconds += parseInt(hourMatch[1]) * 3600; + if (minuteMatch) totalSeconds += parseInt(minuteMatch[1]) * 60; + if (secondMatch) totalSeconds += parseInt(secondMatch[1]); + + return totalSeconds; +} + export function buildTimeString(hours: number, minutes: number, seconds: number) { const h = hours > 0 ? `${hours}h` : ''; const m = `${minutes}m`; From cae55241687f544bb603345ba280a605981eaaf8 Mon Sep 17 00:00:00 2001 From: shancds Date: Wed, 28 May 2025 12:38:19 +0530 Subject: [PATCH 047/105] feat(ratecard-assignee-selector): add assignedMembers prop to handle member assignment across job titles --- .../ratecard-assignee-selector.tsx | 63 ++++++++++--------- .../reatecard-table/ratecard-table.tsx | 5 ++ 2 files changed, 40 insertions(+), 28 deletions(-) diff --git a/worklenz-frontend/src/components/project-ratecard/ratecard-assignee-selector.tsx b/worklenz-frontend/src/components/project-ratecard/ratecard-assignee-selector.tsx index 175ad48e2..dc2521d4b 100644 --- a/worklenz-frontend/src/components/project-ratecard/ratecard-assignee-selector.tsx +++ b/worklenz-frontend/src/components/project-ratecard/ratecard-assignee-selector.tsx @@ -23,7 +23,8 @@ const RateCardAssigneeSelector = ({ onChange, selectedMemberIds = [], memberlist = [], -}: RateCardAssigneeSelectorProps) => { + assignedMembers = [], // New prop: List of all assigned member IDs across all job titles +}: RateCardAssigneeSelectorProps & { assignedMembers: string[] }) => { const membersInputRef = useRef(null); const [searchQuery, setSearchQuery] = useState(''); const [members, setMembers] = useState(memberlist); @@ -46,33 +47,39 @@ const RateCardAssigneeSelector = ({ /> {filteredMembers.length ? ( - filteredMembers.map((member) => ( - - onChange?.(member.id || '')} - /> - - {member.name} - - )) + filteredMembers.map((member) => { + const isAssignedToAnotherJobTitle = + assignedMembers.includes(member.id || '') && + !selectedMemberIds.includes(member.id || ''); // Check if the member is assigned elsewhere + + return ( + + onChange?.(member.id || '')} + /> + + {member.name} + + ); + }) ) : ( )} 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 index 51818231f..3a621f106 100644 --- 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 @@ -191,6 +191,10 @@ const RatecardTable: React.FC = () => { } }; + const assignedMembers = roles + .flatMap((role) => role.members || []) + .filter((memberId, index, self) => self.indexOf(memberId) === index); + // Columns const columns: TableProps['columns'] = [ { @@ -267,6 +271,7 @@ const RatecardTable: React.FC = () => { selectedMemberIds={memberscol || []} onChange={(memberId) => handleMemberChange(memberId, index, record)} memberlist={members} + assignedMembers={assignedMembers} // Pass assigned members here /> From 5454c22bd15f6f7ce1b40b2ff3c1650d951d9cc1 Mon Sep 17 00:00:00 2001 From: chamiakJ Date: Wed, 28 May 2025 22:04:51 +0530 Subject: [PATCH 048/105] feat(project-finance): enhance fixed cost handling and add silent refresh functionality - Updated the fixed cost calculation logic to rely on backend values, avoiding unnecessary recalculations in the frontend. - Introduced a new `fetchProjectFinancesSilent` thunk for refreshing project finance data without altering the loading state. - Implemented debounced and immediate save functions for fixed cost changes in the FinanceTable component, improving user experience and data accuracy. - Adjusted the UI to reflect changes in fixed cost handling, ensuring accurate display of task variances. --- .../projects/finance/project-finance.slice.ts | 34 +++--- .../finance-table/finance-table.tsx | 100 ++++++++++++++---- 2 files changed, 99 insertions(+), 35 deletions(-) diff --git a/worklenz-frontend/src/features/projects/finance/project-finance.slice.ts b/worklenz-frontend/src/features/projects/finance/project-finance.slice.ts index 1e2ede86a..1bcbaa94d 100644 --- a/worklenz-frontend/src/features/projects/finance/project-finance.slice.ts +++ b/worklenz-frontend/src/features/projects/finance/project-finance.slice.ts @@ -22,13 +22,10 @@ const calculateTaskCosts = (task: IProjectFinanceTask) => { const timeLoggedHours = secondsToHours(task.total_time_logged_seconds || 0); const fixedCost = task.fixed_cost || 0; - // Calculate total budget (estimated hours * rate + fixed cost) - const totalBudget = task.estimated_cost + fixedCost; - - // Calculate total actual (time logged * rate + fixed cost) + // For fixed cost updates, we'll rely on the backend values + // and trigger a re-fetch to ensure accuracy + const totalBudget = (task.estimated_cost || 0) + fixedCost; const totalActual = task.total_actual || 0; - - // Calculate variance (total actual - total budget) const variance = totalActual - totalBudget; return { @@ -80,6 +77,14 @@ export const fetchProjectFinances = createAsyncThunk( } ); +export const fetchProjectFinancesSilent = createAsyncThunk( + 'projectFinances/fetchProjectFinancesSilent', + async ({ projectId, groupBy }: { projectId: string; groupBy: GroupTypes }) => { + const response = await projectFinanceApiService.getProjectTasks(projectId, groupBy); + return response.body; + } +); + export const updateTaskFixedCostAsync = createAsyncThunk( 'projectFinances/updateTaskFixedCostAsync', async ({ taskId, groupId, fixedCost }: { taskId: string; groupId: string; fixedCost: number }) => { @@ -105,11 +110,7 @@ export const projectFinancesSlice = createSlice({ 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; + // Don't recalculate here - let the backend handle it and we'll refresh } } }, @@ -158,6 +159,11 @@ export const projectFinancesSlice = createSlice({ .addCase(fetchProjectFinances.rejected, (state) => { state.loading = false; }) + .addCase(fetchProjectFinancesSilent.fulfilled, (state, action) => { + // Update data without changing loading state for silent refresh + state.taskGroups = action.payload.groups; + state.projectRateCards = action.payload.project_rate_cards; + }) .addCase(updateTaskFixedCostAsync.fulfilled, (state, action) => { const { taskId, groupId, fixedCost } = action.payload; const group = state.taskGroups.find(g => g.group_id === groupId); @@ -165,11 +171,7 @@ export const projectFinancesSlice = createSlice({ 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; + // Don't recalculate here - trigger a refresh instead for accuracy } } }); 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 index 5c6328205..67f4209b2 100644 --- 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 @@ -1,5 +1,5 @@ import { Checkbox, Flex, Input, InputNumber, Skeleton, Tooltip, Typography } from 'antd'; -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useState, useRef } from 'react'; import { useAppSelector } from '@/hooks/useAppSelector'; import { DollarCircleOutlined, @@ -11,7 +11,7 @@ import { colors } from '@/styles/colors'; import { financeTableColumns, FinanceTableColumnKeys } from '@/lib/project/project-view-finance-table-columns'; import Avatars from '@/components/avatars/avatars'; import { IProjectFinanceGroup, IProjectFinanceTask } from '@/types/project/project-finance.types'; -import { updateTaskFixedCostAsync, updateTaskFixedCost } from '@/features/projects/finance/project-finance.slice'; +import { updateTaskFixedCostAsync, updateTaskFixedCost, fetchProjectFinancesSilent } from '@/features/projects/finance/project-finance.slice'; import { useAppDispatch } from '@/hooks/useAppDispatch'; import { setSelectedTaskId, setShowTaskDrawer, fetchTask } from '@/features/task-drawer/task-drawer.slice'; import { useParams } from 'react-router-dom'; @@ -33,11 +33,14 @@ const FinanceTable = ({ }: FinanceTableProps) => { const [isCollapse, setIsCollapse] = useState(false); const [selectedTask, setSelectedTask] = useState(null); + const [editingFixedCostValue, setEditingFixedCostValue] = useState(null); const [tasks, setTasks] = useState(table.tasks); + const saveTimeoutRef = useRef(null); const dispatch = useAppDispatch(); // Get the latest task groups from Redux store const taskGroups = useAppSelector((state) => state.projectFinances.taskGroups); + const activeGroup = useAppSelector((state) => state.projectFinances.activeGroup); // Update local state when table.tasks or Redux store changes useEffect(() => { @@ -53,7 +56,13 @@ const FinanceTable = ({ useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if (selectedTask && !(event.target as Element)?.closest('.fixed-cost-input')) { - setSelectedTask(null); + // Save current value before closing + if (editingFixedCostValue !== null) { + immediateSaveFixedCost(editingFixedCostValue, selectedTask.id); + } else { + setSelectedTask(null); + setEditingFixedCostValue(null); + } } }; @@ -61,7 +70,16 @@ const FinanceTable = ({ return () => { document.removeEventListener('mousedown', handleClickOutside); }; - }, [selectedTask]); + }, [selectedTask, editingFixedCostValue]); + + // Cleanup timeout on unmount + useEffect(() => { + return () => { + if (saveTimeoutRef.current) { + clearTimeout(saveTimeoutRef.current); + } + }; + }, []); // get theme data from theme reducer const themeMode = useAppSelector((state) => state.themeReducer.mode); @@ -99,14 +117,20 @@ const FinanceTable = ({ } }; - const handleFixedCostChange = (value: number | null, taskId: string) => { + const handleFixedCostChange = async (value: number | null, taskId: string) => { const fixedCost = value || 0; - // Optimistic update for immediate UI feedback - dispatch(updateTaskFixedCost({ taskId, groupId: table.group_id, fixedCost })); - - // Then make the API call to persist the change - dispatch(updateTaskFixedCostAsync({ taskId, groupId: table.group_id, fixedCost })); + try { + // Make the API call to persist the change + await dispatch(updateTaskFixedCostAsync({ taskId, groupId: table.group_id, fixedCost })).unwrap(); + + // Silent refresh the data to get accurate calculations from backend without loading animation + if (projectId) { + dispatch(fetchProjectFinancesSilent({ projectId, groupBy: activeGroup })); + } + } catch (error) { + console.error('Failed to update fixed cost:', error); + } }; const { projectId } = useParams<{ projectId: string }>(); @@ -119,6 +143,38 @@ const FinanceTable = ({ dispatch(fetchTask({ taskId, projectId })); }; + // Debounced save function for fixed cost + const debouncedSaveFixedCost = (value: number | null, taskId: string) => { + // Clear existing timeout + if (saveTimeoutRef.current) { + clearTimeout(saveTimeoutRef.current); + } + + // Set new timeout + saveTimeoutRef.current = setTimeout(() => { + if (value !== null) { + handleFixedCostChange(value, taskId); + setSelectedTask(null); + setEditingFixedCostValue(null); + } + }, 1000); // Save after 1 second of inactivity + }; + + // Immediate save function (for enter/blur) + const immediateSaveFixedCost = (value: number | null, taskId: string) => { + // Clear any pending debounced save + if (saveTimeoutRef.current) { + clearTimeout(saveTimeoutRef.current); + saveTimeoutRef.current = null; + } + + if (value !== null) { + handleFixedCostChange(value, taskId); + } + setSelectedTask(null); + setEditingFixedCostValue(null); + }; + const renderFinancialTableColumnContent = (columnKey: FinanceTableColumnKeys, task: IProjectFinanceTask) => { switch (columnKey) { case FinanceTableColumnKeys.TASK: @@ -179,14 +235,19 @@ const FinanceTable = ({ case FinanceTableColumnKeys.FIXED_COST: return selectedTask?.id === task.id ? ( { - handleFixedCostChange(Number(e.target.value), task.id); - setSelectedTask(null); + value={editingFixedCostValue !== null ? editingFixedCostValue : task.fixed_cost} + onChange={(value) => { + setEditingFixedCostValue(value); + // Trigger debounced save for up/down arrow clicks + debouncedSaveFixedCost(value, task.id); + }} + onBlur={() => { + // Immediate save on blur + immediateSaveFixedCost(editingFixedCostValue, task.id); }} - onPressEnter={(e) => { - handleFixedCostChange(Number((e.target as HTMLInputElement).value), task.id); - setSelectedTask(null); + onPressEnter={() => { + // Immediate save on enter + immediateSaveFixedCost(editingFixedCostValue, task.id); }} autoFocus style={{ width: '100%', textAlign: 'right' }} @@ -202,6 +263,7 @@ const FinanceTable = ({ onClick={(e) => { e.stopPropagation(); setSelectedTask(task); + setEditingFixedCostValue(task.fixed_cost); }} > {formatNumber(task.fixed_cost)} @@ -211,10 +273,10 @@ const FinanceTable = ({ return ( 0 ? '#FF0000' : '#6DC376' + color: task.variance > 0 ? '#FF0000' : '#6DC376' }} > - {formatNumber(formattedTotals.variance)} + {formatNumber(task.variance)} ); case FinanceTableColumnKeys.TOTAL_BUDGET: From a87ea46b9729b45c8159e777ba15264f1c51cd21 Mon Sep 17 00:00:00 2001 From: chamiakJ Date: Thu, 29 May 2025 00:59:59 +0530 Subject: [PATCH 049/105] feat(project-finance): implement hierarchical task loading and subtasks retrieval - Enhanced the project finance controller to support hierarchical loading of tasks, allowing for better aggregation of financial data from parent and subtasks. - Introduced a new endpoint to fetch subtasks along with their financial details, improving the granularity of task management. - Updated the frontend to handle subtasks, including UI adjustments for displaying subtasks and their associated financial data. - Added necessary Redux actions and state management for fetching and displaying subtasks in the finance table. - Improved user experience by providing tooltips and disabling time estimation for tasks with subtasks, ensuring clarity in task management. --- .../controllers/project-finance-controller.ts | 255 ++++++++++++++++-- .../routes/apis/project-finance-api-router.ts | 1 + .../src/socket.io/commands/on-quick-task.ts | 17 +- .../locales/en/task-drawer/task-drawer.json | 1 + .../locales/es/task-drawer/task-drawer.json | 1 + .../locales/pt/task-drawer/task-drawer.json | 3 +- .../project-finance.api.service.ts | 12 +- .../task-drawer-estimation.tsx | 127 ++++++--- .../shared/info-tab/task-details-form.tsx | 15 +- .../shared/info-tab/task-drawer-info-tab.tsx | 2 +- .../projects/finance/project-finance.slice.ts | 37 ++- .../finance-table/finance-table.tsx | 61 ++++- .../types/project/project-finance.types.ts | 5 + 13 files changed, 454 insertions(+), 83 deletions(-) diff --git a/worklenz-backend/src/controllers/project-finance-controller.ts b/worklenz-backend/src/controllers/project-finance-controller.ts index fdc987892..1092d6aa3 100644 --- a/worklenz-backend/src/controllers/project-finance-controller.ts +++ b/worklenz-backend/src/controllers/project-finance-controller.ts @@ -66,56 +66,132 @@ export default class ProjectfinanceController extends WorklenzControllerBase { 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 + // Get tasks with their financial data - support hierarchical loading const q = ` - WITH task_costs AS ( + WITH RECURSIVE task_tree AS ( + -- Get the requested tasks (parent tasks or subtasks of a specific parent) SELECT t.id, t.name, + t.parent_task_id, + t.project_id, + t.status_id, + t.priority_id, + (SELECT phase_id FROM task_phase WHERE task_id = t.id) as phase_id, + (SELECT get_task_assignees(t.id)) as assignees, + t.billable, + COALESCE(t.fixed_cost, 0) as fixed_cost, COALESCE(t.total_minutes * 60, 0) as estimated_seconds, COALESCE((SELECT SUM(time_spent) FROM task_work_log WHERE task_id = t.id), 0) as total_time_logged_seconds, + (SELECT COUNT(*) FROM tasks WHERE parent_task_id = t.id AND archived = false) as sub_tasks_count, + 0 as level, + t.id as root_id + FROM tasks t + WHERE t.project_id = $1 + AND t.archived = false + AND t.parent_task_id IS NULL -- Only load parent tasks initially + + UNION ALL + + -- Get all descendant tasks for aggregation + SELECT + t.id, + t.name, + t.parent_task_id, t.project_id, t.status_id, t.priority_id, (SELECT phase_id FROM task_phase WHERE task_id = t.id) as phase_id, (SELECT get_task_assignees(t.id)) as assignees, t.billable, - COALESCE(t.fixed_cost, 0) as fixed_cost + COALESCE(t.fixed_cost, 0) as fixed_cost, + COALESCE(t.total_minutes * 60, 0) as estimated_seconds, + COALESCE((SELECT SUM(time_spent) FROM task_work_log WHERE task_id = t.id), 0) as total_time_logged_seconds, + 0 as sub_tasks_count, + tt.level + 1 as level, + tt.root_id FROM tasks t - WHERE t.project_id = $1 AND t.archived = false + INNER JOIN task_tree tt ON t.parent_task_id = tt.id + WHERE t.archived = false ), - task_estimated_costs AS ( + task_costs AS ( SELECT - tc.*, - -- Calculate estimated cost based on estimated hours and assignee rates from project_members + tt.*, + -- Calculate estimated cost based on estimated hours and assignee rates COALESCE(( - SELECT SUM((tc.estimated_seconds / 3600.0) * COALESCE(fprr.rate, 0)) - FROM json_array_elements(tc.assignees) AS assignee_json + SELECT SUM((tt.estimated_seconds / 3600.0) * COALESCE(fprr.rate, 0)) + FROM json_array_elements(tt.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 + AND pm.project_id = tt.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 + -- Calculate actual cost based on time logged and assignee rates COALESCE(( - SELECT SUM( - COALESCE(fprr.rate, 0) * (twl.time_spent / 3600.0) - ) + 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 project_members pm ON pm.team_member_id = tm.id AND pm.project_id = tt.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 + WHERE twl.task_id = tt.id ), 0) as actual_cost_from_logs + FROM task_tree tt + ), + aggregated_tasks AS ( + SELECT + tc.id, + tc.name, + tc.parent_task_id, + tc.status_id, + tc.priority_id, + tc.phase_id, + tc.assignees, + tc.billable, + tc.fixed_cost, + tc.sub_tasks_count, + -- For parent tasks, sum values from all descendants including self + CASE + WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN ( + SELECT SUM(sub_tc.estimated_seconds) + FROM task_costs sub_tc + WHERE sub_tc.root_id = tc.id + ) + ELSE tc.estimated_seconds + END as estimated_seconds, + CASE + WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN ( + SELECT SUM(sub_tc.total_time_logged_seconds) + FROM task_costs sub_tc + WHERE sub_tc.root_id = tc.id + ) + ELSE tc.total_time_logged_seconds + END as total_time_logged_seconds, + CASE + WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN ( + SELECT SUM(sub_tc.estimated_cost) + FROM task_costs sub_tc + WHERE sub_tc.root_id = tc.id + ) + ELSE tc.estimated_cost + END as estimated_cost, + CASE + WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN ( + SELECT SUM(sub_tc.actual_cost_from_logs) + FROM task_costs sub_tc + WHERE sub_tc.root_id = tc.id + ) + ELSE tc.actual_cost_from_logs + END as actual_cost_from_logs FROM task_costs tc + WHERE tc.level = 0 -- Only return the requested level ) SELECT - tec.*, - (tec.estimated_cost + tec.fixed_cost) as total_budget, - (tec.actual_cost_from_logs + tec.fixed_cost) as total_actual, - ((tec.actual_cost_from_logs + tec.fixed_cost) - (tec.estimated_cost + tec.fixed_cost)) as variance - FROM task_estimated_costs tec; + at.*, + (at.estimated_cost + at.fixed_cost) as total_budget, + (at.actual_cost_from_logs + at.fixed_cost) as total_actual, + ((at.actual_cost_from_logs + at.fixed_cost) - (at.estimated_cost + at.fixed_cost)) as variance + FROM aggregated_tasks at; `; const result = await db.query(q, [projectId]); @@ -240,7 +316,8 @@ export default class ProjectfinanceController extends WorklenzControllerBase { total_actual: Number(task.total_actual) || 0, variance: Number(task.variance) || 0, members: task.assignees, - billable: task.billable + billable: task.billable, + sub_tasks_count: Number(task.sub_tasks_count) || 0 })) }; }); @@ -426,4 +503,138 @@ export default class ProjectfinanceController extends WorklenzControllerBase { return res.status(200).send(new ServerResponse(true, responseData)); } + + @HandleExceptions() + public static async getSubTasks( + req: IWorkLenzRequest, + res: IWorkLenzResponse + ): Promise { + const projectId = req.params.project_id; + const parentTaskId = req.params.parent_task_id; + + if (!parentTaskId) { + return res.status(400).send(new ServerResponse(false, null, "Parent task ID is required")); + } + + // Get subtasks with their financial data + const q = ` + WITH task_costs AS ( + SELECT + t.id, + t.name, + t.parent_task_id, + t.project_id, + t.status_id, + t.priority_id, + (SELECT phase_id FROM task_phase WHERE task_id = t.id) as phase_id, + (SELECT get_task_assignees(t.id)) as assignees, + t.billable, + COALESCE(t.fixed_cost, 0) as fixed_cost, + COALESCE(t.total_minutes * 60, 0) as estimated_seconds, + COALESCE((SELECT SUM(time_spent) FROM task_work_log WHERE task_id = t.id), 0) as total_time_logged_seconds, + (SELECT COUNT(*) FROM tasks WHERE parent_task_id = t.id AND archived = false) as sub_tasks_count + FROM tasks t + WHERE t.project_id = $1 + AND t.archived = false + AND t.parent_task_id = $2 + ), + task_estimated_costs AS ( + SELECT + tc.*, + -- Calculate estimated cost based on estimated hours and assignee rates + COALESCE(( + SELECT SUM((tc.estimated_seconds / 3600.0) * 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 + 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 + tec.*, + (tec.estimated_cost + tec.fixed_cost) as total_budget, + (tec.actual_cost_from_logs + tec.fixed_cost) as total_actual, + ((tec.actual_cost_from_logs + tec.fixed_cost) - (tec.estimated_cost + tec.fixed_cost)) as variance + FROM task_estimated_costs tec; + `; + + const result = await db.query(q, [projectId, parentTaskId]); + const tasks = result.rows; + + // Add color_code to each assignee and include their rate information + for (const task of tasks) { + if (Array.isArray(task.assignees)) { + for (const assignee of task.assignees) { + assignee.color_code = getColor(assignee.name); + + // Get the rate for this assignee + 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 { + 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:", error); + assignee.project_rate_card_role_id = null; + assignee.rate = 0; + assignee.job_title_id = null; + assignee.job_title_name = null; + } + } + } + } + + // Format the response to match the expected structure + const formattedTasks = tasks.map(task => ({ + id: task.id, + name: task.name, + estimated_seconds: Number(task.estimated_seconds) || 0, + estimated_hours: formatTimeToHMS(Number(task.estimated_seconds) || 0), + total_time_logged_seconds: Number(task.total_time_logged_seconds) || 0, + total_time_logged: formatTimeToHMS(Number(task.total_time_logged_seconds) || 0), + estimated_cost: Number(task.estimated_cost) || 0, + fixed_cost: Number(task.fixed_cost) || 0, + total_budget: Number(task.total_budget) || 0, + total_actual: Number(task.total_actual) || 0, + variance: Number(task.variance) || 0, + members: task.assignees, + billable: task.billable, + sub_tasks_count: Number(task.sub_tasks_count) || 0 + })); + + return res.status(200).send(new ServerResponse(true, formattedTasks)); + } } diff --git a/worklenz-backend/src/routes/apis/project-finance-api-router.ts b/worklenz-backend/src/routes/apis/project-finance-api-router.ts index 6254baa08..9228705a4 100644 --- a/worklenz-backend/src/routes/apis/project-finance-api-router.ts +++ b/worklenz-backend/src/routes/apis/project-finance-api-router.ts @@ -7,6 +7,7 @@ import safeControllerFunction from "../../shared/safe-controller-function"; const projectFinanceApiRouter = express.Router(); projectFinanceApiRouter.get("/project/:project_id/tasks", ProjectfinanceController.getTasks); +projectFinanceApiRouter.get("/project/:project_id/tasks/:parent_task_id/subtasks", ProjectfinanceController.getSubTasks); projectFinanceApiRouter.get( "/task/:id/breakdown", idParamValidator, diff --git a/worklenz-backend/src/socket.io/commands/on-quick-task.ts b/worklenz-backend/src/socket.io/commands/on-quick-task.ts index 859cbf587..80e9f3816 100644 --- a/worklenz-backend/src/socket.io/commands/on-quick-task.ts +++ b/worklenz-backend/src/socket.io/commands/on-quick-task.ts @@ -1,11 +1,11 @@ -import {Server, Socket} from "socket.io"; +import { Server, Socket } from "socket.io"; import db from "../../config/db"; -import {getColor, toMinutes} from "../../shared/utils"; -import {SocketEvents} from "../events"; +import { getColor, toMinutes } from "../../shared/utils"; +import { SocketEvents } from "../events"; -import {log_error, notifyProjectUpdates} from "../util"; +import { log_error, notifyProjectUpdates } from "../util"; import TasksControllerV2 from "../../controllers/tasks-controller-v2"; -import {TASK_STATUS_COLOR_ALPHA, UNMAPPED} from "../../shared/constants"; +import { TASK_STATUS_COLOR_ALPHA, UNMAPPED } from "../../shared/constants"; import moment from "moment"; import momentTime from "moment-timezone"; import { logEndDateChange, logStartDateChange, logStatusChange } from "../../services/activity-logs/activity-logs.service"; @@ -18,8 +18,9 @@ export async function getTaskCompleteInfo(task: any) { const [d2] = result2.rows; task.completed_count = d2.res.total_completed || 0; - if (task.sub_tasks_count > 0) + if (task.sub_tasks_count > 0 && d2.res.total_tasks > 0) { task.sub_tasks_count = d2.res.total_tasks; + } return task; } @@ -97,8 +98,8 @@ export async function on_quick_task(_io: Server, socket: Socket, data?: string) logEndDateChange({ task_id: d.task.id, socket, - new_value: body.time_zone && d.task.end_date ? momentTime.tz(d.task.end_date, `${body.time_zone}`) : d.task.end_date, - old_value: null + new_value: body.time_zone && d.task.end_date ? momentTime.tz(d.task.end_date, `${body.time_zone}`) : d.task.end_date, + old_value: null }); } diff --git a/worklenz-frontend/public/locales/en/task-drawer/task-drawer.json b/worklenz-frontend/public/locales/en/task-drawer/task-drawer.json index 06575ee1a..771eaad22 100644 --- a/worklenz-frontend/public/locales/en/task-drawer/task-drawer.json +++ b/worklenz-frontend/public/locales/en/task-drawer/task-drawer.json @@ -23,6 +23,7 @@ "show-start-date": "Show Start Date", "hours": "Hours", "minutes": "Minutes", + "time-estimation-disabled-tooltip": "Time estimation is disabled because this task has {{count}} subtasks. The estimation shown is the sum of all subtasks.", "progressValue": "Progress Value", "progressValueTooltip": "Set the progress percentage (0-100%)", "progressValueRequired": "Please enter a progress value", diff --git a/worklenz-frontend/public/locales/es/task-drawer/task-drawer.json b/worklenz-frontend/public/locales/es/task-drawer/task-drawer.json index c3980da8d..96ea63626 100644 --- a/worklenz-frontend/public/locales/es/task-drawer/task-drawer.json +++ b/worklenz-frontend/public/locales/es/task-drawer/task-drawer.json @@ -23,6 +23,7 @@ "show-start-date": "Mostrar fecha de inicio", "hours": "Horas", "minutes": "Minutos", + "time-estimation-disabled-tooltip": "La estimación de tiempo está deshabilitada porque esta tarea tiene {{count}} subtareas. La estimación mostrada es la suma de todas las subtareas.", "progressValue": "Valor de Progreso", "progressValueTooltip": "Establecer el porcentaje de progreso (0-100%)", "progressValueRequired": "Por favor, introduce un valor de progreso", diff --git a/worklenz-frontend/public/locales/pt/task-drawer/task-drawer.json b/worklenz-frontend/public/locales/pt/task-drawer/task-drawer.json index 6288af92e..94862d03a 100644 --- a/worklenz-frontend/public/locales/pt/task-drawer/task-drawer.json +++ b/worklenz-frontend/public/locales/pt/task-drawer/task-drawer.json @@ -23,7 +23,8 @@ "show-start-date": "Mostrar data de início", "hours": "Horas", "minutes": "Minutos", - "progressValue": "Valor de Progresso", + "time-estimation-disabled-tooltip": "A estimativa de tempo está desabilitada porque esta tarefa tem {{count}} subtarefas. A estimativa mostrada é a soma de todas as subtarefas.", + "progressValue": "Valor do Progresso", "progressValueTooltip": "Definir a porcentagem de progresso (0-100%)", "progressValueRequired": "Por favor, insira um valor de progresso", "progressValueRange": "O progresso deve estar entre 0 e 100", diff --git a/worklenz-frontend/src/api/project-finance-ratecard/project-finance.api.service.ts b/worklenz-frontend/src/api/project-finance-ratecard/project-finance.api.service.ts index 991390702..6a6147788 100644 --- a/worklenz-frontend/src/api/project-finance-ratecard/project-finance.api.service.ts +++ b/worklenz-frontend/src/api/project-finance-ratecard/project-finance.api.service.ts @@ -1,7 +1,7 @@ import { API_BASE_URL } from "@/shared/constants"; import { IServerResponse } from "@/types/common.types"; import apiClient from "../api-client"; -import { IProjectFinanceResponse, ITaskBreakdownResponse } from "@/types/project/project-finance.types"; +import { IProjectFinanceResponse, ITaskBreakdownResponse, IProjectFinanceTask } from "@/types/project/project-finance.types"; const rootUrl = `${API_BASE_URL}/project-finance`; @@ -20,6 +20,16 @@ export const projectFinanceApiService = { return response.data; }, + getSubTasks: async ( + projectId: string, + parentTaskId: string + ): Promise> => { + const response = await apiClient.get>( + `${rootUrl}/project/${projectId}/tasks/${parentTaskId}/subtasks` + ); + return response.data; + }, + getTaskBreakdown: async ( taskId: string ): Promise> => { diff --git a/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-estimation/task-drawer-estimation.tsx b/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-estimation/task-drawer-estimation.tsx index f3732d423..cf43f4e0d 100644 --- a/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-estimation/task-drawer-estimation.tsx +++ b/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-estimation/task-drawer-estimation.tsx @@ -2,23 +2,41 @@ import { SocketEvents } from '@/shared/socket-events'; import { useSocket } from '@/socket/socketContext'; import { colors } from '@/styles/colors'; import { ITaskViewModel } from '@/types/tasks/task.types'; -import { Flex, Form, FormInstance, InputNumber, Typography } from 'antd'; +import { Flex, Form, FormInstance, InputNumber, Typography, Tooltip } from 'antd'; import { TFunction } from 'i18next'; -import { useState } from 'react'; +import { useState, useEffect } from 'react'; interface TaskDrawerEstimationProps { t: TFunction; task: ITaskViewModel; form: FormInstance; + subTasksEstimation?: { hours: number; minutes: number }; // Sum of subtasks estimation } -const TaskDrawerEstimation = ({ t, task, form }: TaskDrawerEstimationProps) => { +const TaskDrawerEstimation = ({ t, task, form, subTasksEstimation }: TaskDrawerEstimationProps) => { const { socket, connected } = useSocket(); const [hours, setHours] = useState(0); const [minutes, setMinutes] = useState(0); + + // Check if task has subtasks + const hasSubTasks = (task?.sub_tasks_count || 0) > 0; + + // Use subtasks estimation if available, otherwise use task's own estimation + const displayHours = hasSubTasks && subTasksEstimation ? subTasksEstimation.hours : (task?.total_hours || 0); + const displayMinutes = hasSubTasks && subTasksEstimation ? subTasksEstimation.minutes : (task?.total_minutes || 0); + + useEffect(() => { + // Update form values when subtasks estimation changes + if (hasSubTasks && subTasksEstimation) { + form.setFieldsValue({ + hours: subTasksEstimation.hours, + minutes: subTasksEstimation.minutes + }); + } + }, [subTasksEstimation, hasSubTasks, form]); const handleTimeEstimationBlur = (e: React.FocusEvent) => { - if (!connected || !task.id) return; + if (!connected || !task.id || hasSubTasks) return; // Get current form values instead of using state const currentHours = form.getFieldValue('hours') || 0; @@ -35,48 +53,69 @@ const TaskDrawerEstimation = ({ t, task, form }: TaskDrawerEstimationProps) => { ); }; + const tooltipTitle = hasSubTasks + ? t('taskInfoTab.details.time-estimation-disabled-tooltip', { + count: task?.sub_tasks_count || 0, + defaultValue: `Time estimation is disabled because this task has ${task?.sub_tasks_count || 0} subtasks. The estimation shown is the sum of all subtasks.` + }) + : ''; + return ( - - - {t('taskInfoTab.details.hours')} - - } - style={{ marginBottom: 36 }} - labelCol={{ style: { paddingBlock: 0 } }} - layout="vertical" - > - setHours(value || 0)} - /> - - - {t('taskInfoTab.details.minutes')} - - } - style={{ marginBottom: 36 }} - labelCol={{ style: { paddingBlock: 0 } }} - layout="vertical" - > - setMinutes(value || 0)} - /> - - + + + + {t('taskInfoTab.details.hours')} + + } + style={{ marginBottom: 36 }} + labelCol={{ style: { paddingBlock: 0 } }} + layout="vertical" + > + !hasSubTasks && setHours(value || 0)} + disabled={hasSubTasks} + value={displayHours} + style={{ + cursor: hasSubTasks ? 'not-allowed' : 'default', + opacity: hasSubTasks ? 0.6 : 1 + }} + /> + + + {t('taskInfoTab.details.minutes')} + + } + style={{ marginBottom: 36 }} + labelCol={{ style: { paddingBlock: 0 } }} + layout="vertical" + > + !hasSubTasks && setMinutes(value || 0)} + disabled={hasSubTasks} + value={displayMinutes} + style={{ + cursor: hasSubTasks ? 'not-allowed' : 'default', + opacity: hasSubTasks ? 0.6 : 1 + }} + /> + + + ); }; diff --git a/worklenz-frontend/src/components/task-drawer/shared/info-tab/task-details-form.tsx b/worklenz-frontend/src/components/task-drawer/shared/info-tab/task-details-form.tsx index a2dcaef1a..f01aa3da3 100644 --- a/worklenz-frontend/src/components/task-drawer/shared/info-tab/task-details-form.tsx +++ b/worklenz-frontend/src/components/task-drawer/shared/info-tab/task-details-form.tsx @@ -14,6 +14,7 @@ import { useTranslation } from 'react-i18next'; import { colors } from '@/styles/colors'; import { ITaskFormViewModel, ITaskViewModel } from '@/types/tasks/task.types'; import { IProjectTask } from '@/types/project/projectTasksViewModel.types'; +import { ISubTask } from '@/types/tasks/subTask.types'; import { simpleDateFormat } from '@/utils/simpleDateFormat'; import NotifyMemberSelector from './notify-member-selector'; @@ -33,6 +34,7 @@ import TaskDrawerRecurringConfig from './details/task-drawer-recurring-config/ta interface TaskDetailsFormProps { taskFormViewModel?: ITaskFormViewModel | null; + subTasks?: ISubTask[]; // Array of subtasks to calculate estimation sum } // Custom wrapper that enforces stricter rules for displaying progress input @@ -71,11 +73,20 @@ const ConditionalProgressInput = ({ task, form }: ConditionalProgressInputProps) return null; }; -const TaskDetailsForm = ({ taskFormViewModel = null }: TaskDetailsFormProps) => { +const TaskDetailsForm = ({ taskFormViewModel = null, subTasks = [] }: TaskDetailsFormProps) => { const { t } = useTranslation('task-drawer/task-drawer'); const [form] = Form.useForm(); const { project } = useAppSelector(state => state.projectReducer); + // Calculate sum of subtasks estimation + const subTasksEstimation = subTasks.reduce( + (acc, subTask) => ({ + hours: acc.hours + (subTask.total_hours || 0), + minutes: acc.minutes + (subTask.total_minutes || 0) + }), + { hours: 0, minutes: 0 } + ); + useEffect(() => { if (!taskFormViewModel) { form.resetFields(); @@ -157,7 +168,7 @@ const TaskDetailsForm = ({ taskFormViewModel = null }: TaskDetailsFormProps) => - + {taskFormViewModel?.task && ( { { key: 'details', label: {t('taskInfoTab.details.title')}, - children: , + children: , style: panelStyle, className: 'custom-task-drawer-info-collapse', }, diff --git a/worklenz-frontend/src/features/projects/finance/project-finance.slice.ts b/worklenz-frontend/src/features/projects/finance/project-finance.slice.ts index 1bcbaa94d..0f47b7556 100644 --- a/worklenz-frontend/src/features/projects/finance/project-finance.slice.ts +++ b/worklenz-frontend/src/features/projects/finance/project-finance.slice.ts @@ -85,6 +85,14 @@ export const fetchProjectFinancesSilent = createAsyncThunk( } ); +export const fetchSubTasks = createAsyncThunk( + 'projectFinances/fetchSubTasks', + async ({ projectId, parentTaskId }: { projectId: string; parentTaskId: string }) => { + const response = await projectFinanceApiService.getSubTasks(projectId, parentTaskId); + return { parentTaskId, subTasks: response.body }; + } +); + export const updateTaskFixedCostAsync = createAsyncThunk( 'projectFinances/updateTaskFixedCostAsync', async ({ taskId, groupId, fixedCost }: { taskId: string; groupId: string; fixedCost: number }) => { @@ -144,6 +152,16 @@ export const projectFinancesSlice = createSlice({ task.variance = variance; } } + }, + toggleTaskExpansion: (state, action: PayloadAction<{ taskId: string; groupId: string }>) => { + const { taskId, groupId } = 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.show_sub_tasks = !task.show_sub_tasks; + } + } } }, extraReducers: (builder) => { @@ -174,6 +192,22 @@ export const projectFinancesSlice = createSlice({ // Don't recalculate here - trigger a refresh instead for accuracy } } + }) + .addCase(fetchSubTasks.fulfilled, (state, action) => { + const { parentTaskId, subTasks } = action.payload; + // Find the parent task in any group and add the subtasks + for (const group of state.taskGroups) { + const parentTask = group.tasks.find(t => t.id === parentTaskId); + if (parentTask) { + parentTask.sub_tasks = subTasks.map(subTask => ({ + ...subTask, + is_sub_task: true, + parent_task_id: parentTaskId + })); + parentTask.show_sub_tasks = true; + break; + } + } }); }, }); @@ -183,7 +217,8 @@ export const { setActiveGroup, updateTaskFixedCost, updateTaskEstimatedCost, - updateTaskTimeLogged + updateTaskTimeLogged, + toggleTaskExpansion } = projectFinancesSlice.actions; export default projectFinancesSlice.reducer; 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 index 67f4209b2..1cbfe8a39 100644 --- 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 @@ -11,7 +11,13 @@ import { colors } from '@/styles/colors'; import { financeTableColumns, FinanceTableColumnKeys } from '@/lib/project/project-view-finance-table-columns'; import Avatars from '@/components/avatars/avatars'; import { IProjectFinanceGroup, IProjectFinanceTask } from '@/types/project/project-finance.types'; -import { updateTaskFixedCostAsync, updateTaskFixedCost, fetchProjectFinancesSilent } from '@/features/projects/finance/project-finance.slice'; +import { + updateTaskFixedCostAsync, + updateTaskFixedCost, + fetchProjectFinancesSilent, + toggleTaskExpansion, + fetchSubTasks +} from '@/features/projects/finance/project-finance.slice'; import { useAppDispatch } from '@/hooks/useAppDispatch'; import { setSelectedTaskId, setShowTaskDrawer, fetchTask } from '@/features/task-drawer/task-drawer.slice'; import { useParams } from 'react-router-dom'; @@ -143,6 +149,19 @@ const FinanceTable = ({ dispatch(fetchTask({ taskId, projectId })); }; + // Handle task expansion/collapse + const handleTaskExpansion = async (task: IProjectFinanceTask) => { + if (!projectId) return; + + // If task has subtasks but they're not loaded yet, load them + if (task.sub_tasks_count > 0 && !task.sub_tasks) { + dispatch(fetchSubTasks({ projectId, parentTaskId: task.id })); + } else { + // Just toggle the expansion state + dispatch(toggleTaskExpansion({ taskId: task.id, groupId: table.group_id })); + } + }; + // Debounced save function for fixed cost const debouncedSaveFixedCost = (value: number | null, taskId: string) => { // Clear existing timeout @@ -181,10 +200,27 @@ const FinanceTable = ({ return ( + {/* Indentation for subtasks */} + {task.is_sub_task &&
} + + {/* Expand/collapse icon for parent tasks */} + {task.sub_tasks_count > 0 && ( +
{ + e.stopPropagation(); + handleTaskExpansion(task); + }} + > + {task.show_sub_tasks ? : } +
+ )} + + {/* Task name */} 0 ? 140 : 160), cursor: 'pointer', color: '#1890ff' }} @@ -341,6 +377,25 @@ const FinanceTable = ({ variance: totals.variance }), [totals]); + // Flatten tasks to include subtasks for rendering + const flattenedTasks = useMemo(() => { + const flattened: IProjectFinanceTask[] = []; + + tasks.forEach(task => { + // Add the parent task + flattened.push(task); + + // Add subtasks if they are expanded and loaded + if (task.show_sub_tasks && task.sub_tasks) { + task.sub_tasks.forEach(subTask => { + flattened.push(subTask); + }); + } + }); + + return flattened; + }, [tasks]); + return ( <> @@ -388,7 +443,7 @@ const FinanceTable = ({ {/* task rows */} - {!isCollapse && tasks.map((task, idx) => ( + {!isCollapse && flattenedTasks.map((task, idx) => (
Date: Thu, 29 May 2025 01:17:05 +0530 Subject: [PATCH 050/105] feat(project-finance): add finance data export functionality - Implemented a new endpoint in the project finance controller to export financial data as an Excel file, allowing users to download project finance details. - Enhanced the frontend to include an export button that triggers the finance data export, with appropriate loading states and error handling. - Added functionality to group exported data by status, priority, or phases, improving the usability of the exported reports. - Updated the project finance API service to handle the export request and return the generated Excel file as a Blob. --- .../controllers/project-finance-controller.ts | 368 ++++++++++++++++++ .../routes/apis/project-finance-api-router.ts | 1 + .../project-finance.api.service.ts | 14 + .../project-view-finance-header.tsx | 56 ++- 4 files changed, 437 insertions(+), 2 deletions(-) diff --git a/worklenz-backend/src/controllers/project-finance-controller.ts b/worklenz-backend/src/controllers/project-finance-controller.ts index 1092d6aa3..01d729608 100644 --- a/worklenz-backend/src/controllers/project-finance-controller.ts +++ b/worklenz-backend/src/controllers/project-finance-controller.ts @@ -7,6 +7,9 @@ import WorklenzControllerBase from "./worklenz-controller-base"; import HandleExceptions from "../decorators/handle-exceptions"; import { TASK_STATUS_COLOR_ALPHA } from "../shared/constants"; import { getColor } from "../shared/utils"; +import moment from "moment"; + +const Excel = require("exceljs"); // Utility function to format time in hours, minutes, seconds format const formatTimeToHMS = (totalSeconds: number): string => { @@ -637,4 +640,369 @@ export default class ProjectfinanceController extends WorklenzControllerBase { return res.status(200).send(new ServerResponse(true, formattedTasks)); } + + @HandleExceptions() + public static async exportFinanceData( + req: IWorkLenzRequest, + res: IWorkLenzResponse + ): Promise { + const projectId = req.params.project_id; + const groupBy = (req.query.groupBy as string) || 'status'; + + // Get project name for filename + const projectNameQuery = `SELECT name FROM projects WHERE id = $1`; + const projectNameResult = await db.query(projectNameQuery, [projectId]); + const projectName = projectNameResult.rows[0]?.name || 'Unknown Project'; + + // 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 tasks with their financial data - support hierarchical loading + const q = ` + WITH RECURSIVE task_tree AS ( + -- Get the requested tasks (parent tasks or subtasks of a specific parent) + SELECT + t.id, + t.name, + t.parent_task_id, + t.project_id, + t.status_id, + t.priority_id, + (SELECT phase_id FROM task_phase WHERE task_id = t.id) as phase_id, + (SELECT get_task_assignees(t.id)) as assignees, + t.billable, + COALESCE(t.fixed_cost, 0) as fixed_cost, + COALESCE(t.total_minutes * 60, 0) as estimated_seconds, + COALESCE((SELECT SUM(time_spent) FROM task_work_log WHERE task_id = t.id), 0) as total_time_logged_seconds, + (SELECT COUNT(*) FROM tasks WHERE parent_task_id = t.id AND archived = false) as sub_tasks_count, + 0 as level, + t.id as root_id + FROM tasks t + WHERE t.project_id = $1 + AND t.archived = false + AND t.parent_task_id IS NULL -- Only load parent tasks initially + + UNION ALL + + -- Get all descendant tasks for aggregation + SELECT + t.id, + t.name, + t.parent_task_id, + t.project_id, + t.status_id, + t.priority_id, + (SELECT phase_id FROM task_phase WHERE task_id = t.id) as phase_id, + (SELECT get_task_assignees(t.id)) as assignees, + t.billable, + COALESCE(t.fixed_cost, 0) as fixed_cost, + COALESCE(t.total_minutes * 60, 0) as estimated_seconds, + COALESCE((SELECT SUM(time_spent) FROM task_work_log WHERE task_id = t.id), 0) as total_time_logged_seconds, + 0 as sub_tasks_count, + tt.level + 1 as level, + tt.root_id + FROM tasks t + INNER JOIN task_tree tt ON t.parent_task_id = tt.id + WHERE t.archived = false + ), + task_costs AS ( + SELECT + tt.*, + -- Calculate estimated cost based on estimated hours and assignee rates + COALESCE(( + SELECT SUM((tt.estimated_seconds / 3600.0) * COALESCE(fprr.rate, 0)) + FROM json_array_elements(tt.assignees) AS assignee_json + LEFT JOIN project_members pm ON pm.team_member_id = (assignee_json->>'team_member_id')::uuid + AND pm.project_id = tt.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 + 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 = tt.project_id + LEFT JOIN finance_project_rate_card_roles fprr ON fprr.id = pm.project_rate_card_role_id + WHERE twl.task_id = tt.id + ), 0) as actual_cost_from_logs + FROM task_tree tt + ), + aggregated_tasks AS ( + SELECT + tc.id, + tc.name, + tc.parent_task_id, + tc.status_id, + tc.priority_id, + tc.phase_id, + tc.assignees, + tc.billable, + tc.fixed_cost, + tc.sub_tasks_count, + -- For parent tasks, sum values from all descendants including self + CASE + WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN ( + SELECT SUM(sub_tc.estimated_seconds) + FROM task_costs sub_tc + WHERE sub_tc.root_id = tc.id + ) + ELSE tc.estimated_seconds + END as estimated_seconds, + CASE + WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN ( + SELECT SUM(sub_tc.total_time_logged_seconds) + FROM task_costs sub_tc + WHERE sub_tc.root_id = tc.id + ) + ELSE tc.total_time_logged_seconds + END as total_time_logged_seconds, + CASE + WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN ( + SELECT SUM(sub_tc.estimated_cost) + FROM task_costs sub_tc + WHERE sub_tc.root_id = tc.id + ) + ELSE tc.estimated_cost + END as estimated_cost, + CASE + WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN ( + SELECT SUM(sub_tc.actual_cost_from_logs) + FROM task_costs sub_tc + WHERE sub_tc.root_id = tc.id + ) + ELSE tc.actual_cost_from_logs + END as actual_cost_from_logs + FROM task_costs tc + WHERE tc.level = 0 -- Only return the requested level + ) + SELECT + at.*, + (at.estimated_cost + at.fixed_cost) as total_budget, + (at.actual_cost_from_logs + at.fixed_cost) as total_actual, + ((at.actual_cost_from_logs + at.fixed_cost) - (at.estimated_cost + at.fixed_cost)) as variance + FROM aggregated_tasks at; + `; + + const result = await db.query(q, [projectId]); + const tasks = result.rows; + + // Add color_code to each assignee and include their rate information using project_members + for (const task of tasks) { + if (Array.isArray(task.assignees)) { + for (const assignee of task.assignees) { + assignee.color_code = getColor(assignee.name); + + // Get the rate for this assignee using project_members.project_rate_card_role_id + const memberRateQuery = ` + SELECT + pm.project_rate_card_role_id, + fprr.rate, + fprr.job_title_id, + jt.name as job_title_name + FROM project_members pm + LEFT JOIN finance_project_rate_card_roles fprr ON fprr.id = pm.project_rate_card_role_id + LEFT JOIN job_titles jt ON fprr.job_title_id = jt.id + WHERE pm.team_member_id = $1 AND pm.project_id = $2 + `; + + try { + const memberRateResult = await db.query(memberRateQuery, [assignee.team_member_id, projectId]); + if (memberRateResult.rows.length > 0) { + const memberRate = memberRateResult.rows[0]; + assignee.project_rate_card_role_id = memberRate.project_rate_card_role_id; + assignee.rate = memberRate.rate ? Number(memberRate.rate) : 0; + assignee.job_title_id = memberRate.job_title_id; + assignee.job_title_name = memberRate.job_title_name; + } else { + // Member doesn't have a rate card role assigned + assignee.project_rate_card_role_id = null; + assignee.rate = 0; + assignee.job_title_id = null; + assignee.job_title_name = null; + } + } catch (error) { + console.error("Error fetching member rate from project_members:", error); + assignee.project_rate_card_role_id = null; + assignee.rate = 0; + assignee.job_title_id = null; + assignee.job_title_name = null; + } + } + } + } + + // Get groups based on groupBy parameter + let groups: Array<{ id: string; group_name: string; color_code: string; color_code_dark: string }> = []; + + if (groupBy === "status") { + const q = ` + SELECT + ts.id, + ts.name as group_name, + stsc.color_code::text, + stsc.color_code_dark::text + FROM task_statuses ts + INNER JOIN sys_task_status_categories stsc ON ts.category_id = stsc.id + WHERE ts.project_id = $1 + ORDER BY ts.sort_order; + `; + groups = (await db.query(q, [projectId])).rows; + } else if (groupBy === "priority") { + const q = ` + SELECT + id, + name as group_name, + color_code::text, + color_code_dark::text + FROM task_priorities + ORDER BY value; + `; + groups = (await db.query(q)).rows; + } else if (groupBy === "phases") { + const q = ` + SELECT + id, + name as group_name, + color_code::text, + color_code::text as color_code_dark + FROM project_phases + WHERE project_id = $1 + ORDER BY sort_index; + `; + groups = (await db.query(q, [projectId])).rows; + + // Add TASK_STATUS_COLOR_ALPHA to color codes + for (const group of groups) { + group.color_code = group.color_code + TASK_STATUS_COLOR_ALPHA; + group.color_code_dark = group.color_code_dark + TASK_STATUS_COLOR_ALPHA; + } + } + + // Group tasks by the selected criteria + const groupedTasks = groups.map(group => { + const groupTasks = tasks.filter(task => { + if (groupBy === "status") return task.status_id === group.id; + if (groupBy === "priority") return task.priority_id === group.id; + if (groupBy === "phases") return task.phase_id === group.id; + return false; + }); + + return { + group_id: group.id, + group_name: group.group_name, + color_code: group.color_code, + color_code_dark: group.color_code_dark, + tasks: groupTasks.map(task => ({ + id: task.id, + name: task.name, + estimated_seconds: Number(task.estimated_seconds) || 0, + estimated_hours: formatTimeToHMS(Number(task.estimated_seconds) || 0), + total_time_logged_seconds: Number(task.total_time_logged_seconds) || 0, + total_time_logged: formatTimeToHMS(Number(task.total_time_logged_seconds) || 0), + estimated_cost: Number(task.estimated_cost) || 0, + fixed_cost: Number(task.fixed_cost) || 0, + total_budget: Number(task.total_budget) || 0, + total_actual: Number(task.total_actual) || 0, + variance: Number(task.variance) || 0, + members: task.assignees, + billable: task.billable, + sub_tasks_count: Number(task.sub_tasks_count) || 0 + })) + }; + }); + + // Include project rate cards in the response for reference + const responseData = { + groups: groupedTasks, + project_rate_cards: projectRateCards + }; + + // Create Excel workbook and worksheet + const workbook = new Excel.Workbook(); + const worksheet = workbook.addWorksheet("Finance Data"); + + // Add headers to the worksheet + worksheet.columns = [ + { header: "Task Name", key: "task_name", width: 30 }, + { header: "Group", key: "group_name", width: 20 }, + { header: "Estimated Hours", key: "estimated_hours", width: 15 }, + { header: "Total Time Logged", key: "total_time_logged", width: 15 }, + { header: "Estimated Cost", key: "estimated_cost", width: 15 }, + { header: "Fixed Cost", key: "fixed_cost", width: 15 }, + { header: "Total Budget", key: "total_budget", width: 15 }, + { header: "Total Actual", key: "total_actual", width: 15 }, + { header: "Variance", key: "variance", width: 15 }, + { header: "Members", key: "members", width: 30 }, + { header: "Billable", key: "billable", width: 10 }, + { header: "Sub Tasks Count", key: "sub_tasks_count", width: 15 } + ]; + + // Add title row + worksheet.getCell("A1").value = `Finance Data Export - ${projectName} - ${moment().format("MMM DD, YYYY")}`; + worksheet.mergeCells("A1:L1"); + worksheet.getCell("A1").alignment = { horizontal: "center" }; + worksheet.getCell("A1").style.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "D9D9D9" } }; + worksheet.getCell("A1").font = { size: 16, bold: true }; + + // Add headers on row 3 + worksheet.getRow(3).values = [ + "Task Name", "Group", "Estimated Hours", "Total Time Logged", + "Estimated Cost", "Fixed Cost", "Total Budget", "Total Actual", + "Variance", "Members", "Billable", "Sub Tasks Count" + ]; + worksheet.getRow(3).font = { bold: true }; + + // Add data to the worksheet + let currentRow = 4; + for (const group of responseData.groups) { + for (const task of group.tasks) { + worksheet.addRow({ + task_name: task.name, + group_name: group.group_name, + estimated_hours: task.estimated_hours, + total_time_logged: task.total_time_logged, + estimated_cost: task.estimated_cost.toFixed(2), + fixed_cost: task.fixed_cost.toFixed(2), + total_budget: task.total_budget.toFixed(2), + total_actual: task.total_actual.toFixed(2), + variance: task.variance.toFixed(2), + members: task.members.map((m: any) => m.name).join(", "), + billable: task.billable ? "Yes" : "No", + sub_tasks_count: task.sub_tasks_count + }); + currentRow++; + } + } + + // Create a buffer to hold the Excel file + const buffer = await workbook.xlsx.writeBuffer(); + + // Create filename with project name, date and time + const sanitizedProjectName = projectName.replace(/[^a-zA-Z0-9\s]/g, '').replace(/\s+/g, '_'); + const dateTime = moment().format('YYYY-MM-DD_HH-mm-ss'); + const filename = `${sanitizedProjectName}_Finance_Data_${dateTime}.xlsx`; + + // Set the response headers for the Excel file + res.setHeader("Content-Disposition", `attachment; filename=${filename}`); + res.setHeader("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + + // Send the Excel file as a response + res.end(buffer); + } } diff --git a/worklenz-backend/src/routes/apis/project-finance-api-router.ts b/worklenz-backend/src/routes/apis/project-finance-api-router.ts index 9228705a4..aae00f22c 100644 --- a/worklenz-backend/src/routes/apis/project-finance-api-router.ts +++ b/worklenz-backend/src/routes/apis/project-finance-api-router.ts @@ -14,5 +14,6 @@ projectFinanceApiRouter.get( safeControllerFunction(ProjectfinanceController.getTaskBreakdown) ); projectFinanceApiRouter.put("/task/:task_id/fixed-cost", ProjectfinanceController.updateTaskFixedCost); +projectFinanceApiRouter.get("/project/:project_id/export", ProjectfinanceController.exportFinanceData); export default projectFinanceApiRouter; \ No newline at end of file diff --git a/worklenz-frontend/src/api/project-finance-ratecard/project-finance.api.service.ts b/worklenz-frontend/src/api/project-finance-ratecard/project-finance.api.service.ts index 6a6147788..2292ade85 100644 --- a/worklenz-frontend/src/api/project-finance-ratecard/project-finance.api.service.ts +++ b/worklenz-frontend/src/api/project-finance-ratecard/project-finance.api.service.ts @@ -49,4 +49,18 @@ export const projectFinanceApiService = { ); return response.data; }, + + exportFinanceData: async ( + projectId: string, + groupBy: 'status' | 'priority' | 'phases' = 'status' + ): Promise => { + const response = await apiClient.get( + `${rootUrl}/project/${projectId}/export`, + { + params: { groupBy }, + responseType: 'blob' + } + ); + return response.data; + }, } \ No newline at end of file diff --git a/worklenz-frontend/src/pages/projects/projectView/finance/project-view-finance-header/project-view-finance-header.tsx b/worklenz-frontend/src/pages/projects/projectView/finance/project-view-finance-header/project-view-finance-header.tsx index 7c2134816..15f19088b 100644 --- a/worklenz-frontend/src/pages/projects/projectView/finance/project-view-finance-header/project-view-finance-header.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/finance/project-view-finance-header/project-view-finance-header.tsx @@ -1,10 +1,14 @@ -import { Button, ConfigProvider, Flex, Select, Typography } from 'antd'; +import { Button, ConfigProvider, Flex, Select, Typography, message } from 'antd'; import GroupByFilterDropdown from './group-by-filter-dropdown'; import { DownOutlined } from '@ant-design/icons'; import { useAppDispatch } from '../../../../../hooks/useAppDispatch'; +import { useAppSelector } from '../../../../../hooks/useAppSelector'; import { useTranslation } from 'react-i18next'; import { changeCurrency, toggleImportRatecardsDrawer } from '@/features/finance/finance-slice'; +import { projectFinanceApiService } from '@/api/project-finance-ratecard/project-finance.api.service'; +import { useParams } from 'react-router-dom'; +import { useState } from 'react'; type ProjectViewFinanceHeaderProps = { activeTab: 'finance' | 'ratecard'; @@ -21,8 +25,50 @@ const ProjectViewFinanceHeader = ({ }: ProjectViewFinanceHeaderProps) => { // localization const { t } = useTranslation('project-view-finance'); + const { projectId } = useParams<{ projectId: string }>(); + const [exporting, setExporting] = useState(false); const dispatch = useAppDispatch(); + const { project } = useAppSelector(state => state.projectReducer); + + const handleExport = async () => { + if (!projectId) { + message.error('Project ID not found'); + return; + } + + try { + setExporting(true); + const blob = await projectFinanceApiService.exportFinanceData(projectId, activeGroup); + + // Get project name from Redux state + const projectName = project?.name || 'Unknown_Project'; + + // Create filename with project name, date and time + const sanitizedProjectName = projectName.replace(/[^a-zA-Z0-9\s]/g, '').replace(/\s+/g, '_'); + const dateTime = new Date().toISOString().replace(/[:.]/g, '-').split('T'); + const date = dateTime[0]; + const time = dateTime[1].split('.')[0]; + const filename = `${sanitizedProjectName}_Finance_Data_${date}_${time}.xlsx`; + + // Create download link + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(url); + + message.success('Finance data exported successfully'); + } catch (error) { + console.error('Export failed:', error); + message.error('Failed to export finance data'); + } finally { + setExporting(false); + } + }; return ( @@ -52,7 +98,13 @@ const ProjectViewFinanceHeader = ({ {activeTab === 'finance' ? ( - ) : ( From 549728cdafff962e2280be6da90439a975a32840 Mon Sep 17 00:00:00 2001 From: shancds Date: Thu, 29 May 2025 08:24:16 +0530 Subject: [PATCH 051/105] feat(reporting): implement member selection and filtering in time reports --- .../reporting-allocation-controller.ts | 29 +++-- .../reporting.timesheet.api.service.ts | 2 + .../time-reports-overview.slice.ts | 66 ++++++++++ .../members-time-sheet/members-time-sheet.tsx | 7 +- .../timeReports/page-header/members.tsx | 114 ++++++++++++++++++ .../page-header/time-report-page-header.tsx | 4 + 6 files changed, 210 insertions(+), 12 deletions(-) create mode 100644 worklenz-frontend/src/pages/reporting/timeReports/page-header/members.tsx diff --git a/worklenz-backend/src/controllers/reporting/reporting-allocation-controller.ts b/worklenz-backend/src/controllers/reporting/reporting-allocation-controller.ts index 4db8e3d54..32513b1e4 100644 --- a/worklenz-backend/src/controllers/reporting/reporting-allocation-controller.ts +++ b/worklenz-backend/src/controllers/reporting/reporting-allocation-controller.ts @@ -94,7 +94,7 @@ export default class ReportingAllocationController extends ReportingControllerBa SELECT name, (SELECT COALESCE(SUM(time_spent), 0) FROM task_work_log - LEFT JOIN tasks ON task_work_log.task_id = tasks.id + LEFT JOIN tasks ON task_work_log.task_id = tasks.id WHERE user_id = users.id ${billableQuery} AND CASE WHEN ($1 IS TRUE) THEN tasks.project_id IS NOT NULL ELSE tasks.archived = FALSE END AND tasks.project_id = projects.id @@ -473,17 +473,24 @@ export default class ReportingAllocationController extends ReportingControllerBa : `AND p.id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = p.id AND user_id = '${req.user?.id}') `; const billableQuery = this.buildBillableQuery(billable); - + const members = (req.body.members || []) as string[]; + // Prepare members filter + let membersFilter = ""; + if (members.length > 0) { + const memberIds = members.map(id => `'${id}'`).join(","); + membersFilter = `AND tmiv.team_member_id IN (${memberIds})`; + } const q = ` - SELECT tmiv.email, tmiv.name, SUM(time_spent) AS logged_time - FROM team_member_info_view tmiv - LEFT JOIN task_work_log ON task_work_log.user_id = tmiv.user_id - LEFT JOIN tasks ON tasks.id = task_work_log.task_id ${billableQuery} - LEFT JOIN projects p ON p.id = tasks.project_id AND p.team_id = tmiv.team_id - WHERE p.id IN (${projectIds}) - ${durationClause} ${archivedClause} - GROUP BY tmiv.email, tmiv.name - ORDER BY logged_time DESC;`; + SELECT tmiv.team_member_id, tmiv.email, tmiv.name, SUM(time_spent) AS logged_time + FROM team_member_info_view tmiv + LEFT JOIN task_work_log ON task_work_log.user_id = tmiv.user_id + LEFT JOIN tasks ON tasks.id = task_work_log.task_id ${billableQuery} + LEFT JOIN projects p ON p.id = tasks.project_id AND p.team_id = tmiv.team_id + WHERE p.id IN (${projectIds}) + ${durationClause} ${archivedClause} + ${membersFilter} + GROUP BY tmiv.email, tmiv.name, tmiv.team_member_id + ORDER BY logged_time DESC;`; const result = await db.query(q, []); for (const member of result.rows) { diff --git a/worklenz-frontend/src/api/reporting/reporting.timesheet.api.service.ts b/worklenz-frontend/src/api/reporting/reporting.timesheet.api.service.ts index 1529d46b1..0c74203e2 100644 --- a/worklenz-frontend/src/api/reporting/reporting.timesheet.api.service.ts +++ b/worklenz-frontend/src/api/reporting/reporting.timesheet.api.service.ts @@ -27,7 +27,9 @@ export const reportingTimesheetApiService = { getMemberTimeSheets: async (body = {}, archived = false): Promise> => { const q = toQueryString({ archived }); + console.log('getMemberTimeSheets body:', body); const response = await apiClient.post(`${rootUrl}/time-reports/members/${q}`, body); + console.log('getMemberTimeSheets response:', response); return response.data; }, diff --git a/worklenz-frontend/src/features/reporting/time-reports/time-reports-overview.slice.ts b/worklenz-frontend/src/features/reporting/time-reports/time-reports-overview.slice.ts index 9518495bb..9de106e65 100644 --- a/worklenz-frontend/src/features/reporting/time-reports/time-reports-overview.slice.ts +++ b/worklenz-frontend/src/features/reporting/time-reports/time-reports-overview.slice.ts @@ -23,6 +23,9 @@ interface ITimeReportsOverviewState { billable: boolean; nonBillable: boolean; }; + + members: any[]; + loadingMembers: boolean; } const initialState: ITimeReportsOverviewState = { @@ -42,6 +45,12 @@ const initialState: ITimeReportsOverviewState = { billable: true, nonBillable: true, }, + members: [], + loadingMembers: false, +}; + +const selectedMembers = (state: ITimeReportsOverviewState) => { + return state.members.filter(member => member.selected).map(member => member.id) as string[]; }; const selectedTeams = (state: ITimeReportsOverviewState) => { @@ -54,6 +63,26 @@ const selectedCategories = (state: ITimeReportsOverviewState) => { .map(category => category.id) as string[]; }; +export const fetchReportingMembers = createAsyncThunk( + 'timeReportsOverview/fetchReportingMembers', + async (_, { rejectWithValue, getState }) => { + const state = getState() as { timeReportsOverviewReducer: ITimeReportsOverviewState }; + const { timeReportsOverviewReducer } = state; + + try { + const res = await reportingApiService.getMembers(selectedMembers(timeReportsOverviewReducer)); + if (res.done) { + // Extract members from the response + return res.body.members; // Use `body.members` instead of `body` + } else { + return rejectWithValue(res.message || 'Failed to fetch members'); + } + } catch (error) { + return rejectWithValue(error.message || 'An error occurred while fetching members'); + } + } +); + export const fetchReportingTeams = createAsyncThunk( 'timeReportsOverview/fetchReportingTeams', async () => { @@ -123,6 +152,7 @@ const timeReportsOverviewSlice = createSlice({ setSelectOrDeselectProject: (state, action) => { const project = state.projects.find(project => project.id === action.payload.id); if (project) { + console.log('setSelectOrDeselectProject', project, action.payload); project.selected = action.payload.selected; } }, @@ -141,6 +171,17 @@ const timeReportsOverviewSlice = createSlice({ setArchived: (state, action: PayloadAction) => { state.archived = action.payload; }, + setSelectOrDeselectMember: (state, action: PayloadAction<{ id: string; selected: boolean }>) => { + const member = state.members.find(member => member.id === action.payload.id); + if (member) { + member.selected = action.payload.selected; + } + }, + setSelectOrDeselectAllMembers: (state, action: PayloadAction) => { + state.members.forEach(member => { + member.selected = action.payload; + }); + }, }, extraReducers: builder => { builder.addCase(fetchReportingTeams.fulfilled, (state, action) => { @@ -185,6 +226,29 @@ const timeReportsOverviewSlice = createSlice({ builder.addCase(fetchReportingProjects.rejected, state => { state.loadingProjects = false; }); + builder.addCase(fetchReportingMembers.fulfilled, (state, action) => { + console.log('fetchReportingMembers fulfilled', action.payload); + const members = action.payload.map((member: any) => ({ + id: member.id, + name: member.name, + selected: true, // Default to selected + avatar_url: member.avatar_url, // Include avatar URL if needed + email: member.email, // Include email if needed + })); + state.members = members; + state.loadingMembers = false; + }); + + builder.addCase(fetchReportingMembers.pending, state => { + console.log('fetchReportingMembers pending'); + state.loadingMembers = true; + }); + + builder.addCase(fetchReportingMembers.rejected, (state, action) => { + console.log('fetchReportingMembers rejected', action.payload); + state.loadingMembers = false; + console.error('Error fetching members:', action.payload); + }); }, }); @@ -197,6 +261,8 @@ export const { setSelectOrDeselectProject, setSelectOrDeselectAllProjects, setSelectOrDeselectBillable, + setSelectOrDeselectMember, + setSelectOrDeselectAllMembers, setNoCategory, setArchived, } = timeReportsOverviewSlice.actions; diff --git a/worklenz-frontend/src/pages/reporting/time-reports/members-time-sheet/members-time-sheet.tsx b/worklenz-frontend/src/pages/reporting/time-reports/members-time-sheet/members-time-sheet.tsx index 4caa7f49a..310f88807 100644 --- a/worklenz-frontend/src/pages/reporting/time-reports/members-time-sheet/members-time-sheet.tsx +++ b/worklenz-frontend/src/pages/reporting/time-reports/members-time-sheet/members-time-sheet.tsx @@ -35,6 +35,8 @@ const MembersTimeSheet = forwardRef((_, ref) => { loadingCategories, projects: filterProjects, loadingProjects, + members, + loadingMembers, billable, archived, } = useAppSelector(state => state.timeReportsOverviewReducer); @@ -170,11 +172,13 @@ const MembersTimeSheet = forwardRef((_, ref) => { const selectedTeams = teams.filter(team => team.selected); const selectedProjects = filterProjects.filter(project => project.selected); const selectedCategories = categories.filter(category => category.selected); + const selectedMembers = members.filter(member => member.selected); // Use selected members const body = { teams: selectedTeams.map(t => t.id), projects: selectedProjects.map(project => project.id), categories: selectedCategories.map(category => category.id), + members: selectedMembers.map(member => member.id), // Include members in the request duration, date_range: dateRange, billable, @@ -185,6 +189,7 @@ const MembersTimeSheet = forwardRef((_, ref) => { setJsonData(res.body || []); } } catch (error) { + console.error('Error fetching chart data:', error); logger.error('Error fetching chart data:', error); } finally { setLoading(false); @@ -193,7 +198,7 @@ const MembersTimeSheet = forwardRef((_, ref) => { useEffect(() => { fetchChartData(); - }, [dispatch, duration, dateRange, billable, archived, teams, filterProjects, categories]); + }, [dispatch, duration, dateRange, billable, archived, teams, filterProjects, categories, members]); const exportChart = () => { if (chartRef.current) { diff --git a/worklenz-frontend/src/pages/reporting/timeReports/page-header/members.tsx b/worklenz-frontend/src/pages/reporting/timeReports/page-header/members.tsx new file mode 100644 index 000000000..fd9a03e49 --- /dev/null +++ b/worklenz-frontend/src/pages/reporting/timeReports/page-header/members.tsx @@ -0,0 +1,114 @@ +import React, { useState } from 'react'; +import { useAppDispatch } from '@/hooks/useAppDispatch'; +import { useAppSelector } from '@/hooks/useAppSelector'; +import { setSelectOrDeselectAllMembers, setSelectOrDeselectMember } from '@/features/reporting/time-reports/time-reports-overview.slice'; +import { Button, Checkbox, Divider, Dropdown, Input, Avatar, theme } from 'antd'; +import { CheckboxChangeEvent } from 'antd/es/checkbox'; +import { CaretDownFilled } from '@ant-design/icons'; +import { useTranslation } from 'react-i18next'; + +const Members: React.FC = () => { + const dispatch = useAppDispatch(); + const { t } = useTranslation('time-report'); + const { members, loadingMembers } = useAppSelector(state => state.timeReportsOverviewReducer); + const { token } = theme.useToken(); + + const [searchText, setSearchText] = useState(''); + const [selectAll, setSelectAll] = useState(true); + + // Filter members based on search text + const filteredMembers = members.filter(member => + member.name?.toLowerCase().includes(searchText.toLowerCase()) + ); + + // Handle checkbox change for individual members + const handleCheckboxChange = (id: string, checked: boolean) => { + console.log('Select Change:', id); + dispatch(setSelectOrDeselectMember({ id, selected: checked })); + }; + + // Handle "Select All" checkbox change + const handleSelectAllChange = (e: CheckboxChangeEvent) => { + console.log('Select All Change:', e); + const isChecked = e.target.checked; + setSelectAll(isChecked); + dispatch(setSelectOrDeselectAllMembers(isChecked)); + }; + + return ( + ( +
+
+ e.stopPropagation()} + placeholder={t('searchByMember')} + value={searchText} + onChange={e => setSearchText(e.target.value)} + /> +
+
+ e.stopPropagation()} + onChange={handleSelectAllChange} + checked={selectAll} + > + {t('selectAll')} + +
+ +
+ {filteredMembers.map(member => ( +
+ + e.stopPropagation()} + checked={member.selected} + onChange={e => handleCheckboxChange(member.id, e.target.checked)} + > + {member.name} + +
+ ))} +
+
+ )} + > + +
+ ); +}; + +export default Members; \ No newline at end of file diff --git a/worklenz-frontend/src/pages/reporting/timeReports/page-header/time-report-page-header.tsx b/worklenz-frontend/src/pages/reporting/timeReports/page-header/time-report-page-header.tsx index 20c3e152b..5b4a4b7cd 100644 --- a/worklenz-frontend/src/pages/reporting/timeReports/page-header/time-report-page-header.tsx +++ b/worklenz-frontend/src/pages/reporting/timeReports/page-header/time-report-page-header.tsx @@ -8,7 +8,9 @@ import { fetchReportingTeams, fetchReportingProjects, fetchReportingCategories, + fetchReportingMembers, } from '@/features/reporting/time-reports/time-reports-overview.slice'; +import Members from './members'; const TimeReportPageHeader: React.FC = () => { const dispatch = useAppDispatch(); @@ -18,6 +20,7 @@ const TimeReportPageHeader: React.FC = () => { await dispatch(fetchReportingTeams()); await dispatch(fetchReportingCategories()); await dispatch(fetchReportingProjects()); + await dispatch(fetchReportingMembers()); }; fetchData(); @@ -29,6 +32,7 @@ const TimeReportPageHeader: React.FC = () => { + ); }; From 9b48cc7e06db024d5697b2f131e64f3f820b762b Mon Sep 17 00:00:00 2001 From: shancds Date: Thu, 29 May 2025 09:33:15 +0530 Subject: [PATCH 052/105] refactor(reporting): remove console logs from member time sheets and reporting slice --- .../api/reporting/reporting.timesheet.api.service.ts | 2 -- .../time-reports/time-reports-overview.slice.ts | 10 +++------- .../reporting/timeReports/page-header/members.tsx | 2 -- 3 files changed, 3 insertions(+), 11 deletions(-) diff --git a/worklenz-frontend/src/api/reporting/reporting.timesheet.api.service.ts b/worklenz-frontend/src/api/reporting/reporting.timesheet.api.service.ts index 0c74203e2..1529d46b1 100644 --- a/worklenz-frontend/src/api/reporting/reporting.timesheet.api.service.ts +++ b/worklenz-frontend/src/api/reporting/reporting.timesheet.api.service.ts @@ -27,9 +27,7 @@ export const reportingTimesheetApiService = { getMemberTimeSheets: async (body = {}, archived = false): Promise> => { const q = toQueryString({ archived }); - console.log('getMemberTimeSheets body:', body); const response = await apiClient.post(`${rootUrl}/time-reports/members/${q}`, body); - console.log('getMemberTimeSheets response:', response); return response.data; }, diff --git a/worklenz-frontend/src/features/reporting/time-reports/time-reports-overview.slice.ts b/worklenz-frontend/src/features/reporting/time-reports/time-reports-overview.slice.ts index 9de106e65..25c7ede79 100644 --- a/worklenz-frontend/src/features/reporting/time-reports/time-reports-overview.slice.ts +++ b/worklenz-frontend/src/features/reporting/time-reports/time-reports-overview.slice.ts @@ -152,7 +152,6 @@ const timeReportsOverviewSlice = createSlice({ setSelectOrDeselectProject: (state, action) => { const project = state.projects.find(project => project.id === action.payload.id); if (project) { - console.log('setSelectOrDeselectProject', project, action.payload); project.selected = action.payload.selected; } }, @@ -227,25 +226,22 @@ const timeReportsOverviewSlice = createSlice({ state.loadingProjects = false; }); builder.addCase(fetchReportingMembers.fulfilled, (state, action) => { - console.log('fetchReportingMembers fulfilled', action.payload); const members = action.payload.map((member: any) => ({ id: member.id, name: member.name, - selected: true, // Default to selected - avatar_url: member.avatar_url, // Include avatar URL if needed - email: member.email, // Include email if needed + selected: true, + avatar_url: member.avatar_url, + email: member.email, })); state.members = members; state.loadingMembers = false; }); builder.addCase(fetchReportingMembers.pending, state => { - console.log('fetchReportingMembers pending'); state.loadingMembers = true; }); builder.addCase(fetchReportingMembers.rejected, (state, action) => { - console.log('fetchReportingMembers rejected', action.payload); state.loadingMembers = false; console.error('Error fetching members:', action.payload); }); diff --git a/worklenz-frontend/src/pages/reporting/timeReports/page-header/members.tsx b/worklenz-frontend/src/pages/reporting/timeReports/page-header/members.tsx index fd9a03e49..499a8b1ae 100644 --- a/worklenz-frontend/src/pages/reporting/timeReports/page-header/members.tsx +++ b/worklenz-frontend/src/pages/reporting/timeReports/page-header/members.tsx @@ -23,13 +23,11 @@ const Members: React.FC = () => { // Handle checkbox change for individual members const handleCheckboxChange = (id: string, checked: boolean) => { - console.log('Select Change:', id); dispatch(setSelectOrDeselectMember({ id, selected: checked })); }; // Handle "Select All" checkbox change const handleSelectAllChange = (e: CheckboxChangeEvent) => { - console.log('Select All Change:', e); const isChecked = e.target.checked; setSelectAll(isChecked); dispatch(setSelectOrDeselectAllMembers(isChecked)); From 7b1c048dbb028202ef11d1238f373f695e83363e Mon Sep 17 00:00:00 2001 From: shancds Date: Thu, 29 May 2025 09:42:08 +0530 Subject: [PATCH 053/105] feat(time-report): add member search functionality to time report localization --- worklenz-frontend/public/locales/en/time-report.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/worklenz-frontend/public/locales/en/time-report.json b/worklenz-frontend/public/locales/en/time-report.json index b5da8dd25..e2e67ac97 100644 --- a/worklenz-frontend/public/locales/en/time-report.json +++ b/worklenz-frontend/public/locales/en/time-report.json @@ -40,5 +40,7 @@ "noCategory": "No Category", "noProjects": "No projects found", "noTeams": "No teams found", - "noData": "No data found" + "noData": "No data found", + "members": "Members", + "searchByMember": "Search by member" } From f1920c17b4d83736a3de05230350042af135bfdb Mon Sep 17 00:00:00 2001 From: shancds Date: Thu, 29 May 2025 12:09:50 +0530 Subject: [PATCH 054/105] feat(members-time-sheet): enhance utilization display with color indicators --- .../members-time-sheet/members-time-sheet.tsx | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/worklenz-frontend/src/pages/reporting/time-reports/members-time-sheet/members-time-sheet.tsx b/worklenz-frontend/src/pages/reporting/time-reports/members-time-sheet/members-time-sheet.tsx index 310f88807..f44f711cd 100644 --- a/worklenz-frontend/src/pages/reporting/time-reports/members-time-sheet/members-time-sheet.tsx +++ b/worklenz-frontend/src/pages/reporting/time-reports/members-time-sheet/members-time-sheet.tsx @@ -104,29 +104,20 @@ const MembersTimeSheet = forwardRef((_, ref) => { let color = ''; if (percent < 90) { status = 'Under'; + color = '🟧'; } else if (percent <= 110) { status = 'Optimal'; + color = '🟩'; } else { status = 'Over'; + color = '🟥'; } return [ `${context.dataset.label}: ${hours} h`, - `Utilization: ${percent}%`, + `${color} Utilization: ${percent}%`, `${status} Utilized: ${overUnder} h` ]; }, - labelTextColor: function (context: any) { - const idx = context.dataIndex; - const member = jsonData[idx]; - const utilization = parseFloat(member?.utilization_percent || '0'); - if (utilization < 90) { - return '#FFB546'; - } else if (utilization >= 90 && utilization <= 110) { - return '#B2EF9A'; - } else { - return '#FE7173'; - } - } } } }, From b94c56f50ddaffbda8479a26aad42f6676c3614d Mon Sep 17 00:00:00 2001 From: shancds Date: Thu, 29 May 2025 15:38:25 +0530 Subject: [PATCH 055/105] feat(reporting): enhance utilization tracking and filtering in time reports --- .../reporting-allocation-controller.ts | 40 +++++-- .../public/locales/en/time-report.json | 4 +- .../time-reports-overview.slice.ts | 73 +++++++++++- .../members-time-sheet/members-time-sheet.tsx | 35 +++--- .../page-header/time-report-page-header.tsx | 4 + .../timeReports/page-header/utilization.tsx | 104 ++++++++++++++++++ .../src/types/reporting/reporting.types.ts | 1 + 7 files changed, 234 insertions(+), 27 deletions(-) create mode 100644 worklenz-frontend/src/pages/reporting/timeReports/page-header/utilization.tsx diff --git a/worklenz-backend/src/controllers/reporting/reporting-allocation-controller.ts b/worklenz-backend/src/controllers/reporting/reporting-allocation-controller.ts index 32513b1e4..9add9696a 100644 --- a/worklenz-backend/src/controllers/reporting/reporting-allocation-controller.ts +++ b/worklenz-backend/src/controllers/reporting/reporting-allocation-controller.ts @@ -492,19 +492,43 @@ export default class ReportingAllocationController extends ReportingControllerBa GROUP BY tmiv.email, tmiv.name, tmiv.team_member_id ORDER BY logged_time DESC;`; const result = await db.query(q, []); - - for (const member of result.rows) { - member.value = member.logged_time ? parseFloat(moment.duration(member.logged_time, "seconds").asHours().toFixed(2)) : 0; + const utilization = (req.body.utilization || []) as string[]; + + // Precompute totalWorkingHours * 3600 for efficiency + const totalWorkingSeconds = totalWorkingHours * 3600; + const hasUtilizationFilter = utilization.length > 0; + + // calculate utilization state + for (let i = 0, len = result.rows.length; i < len; i++) { + const member = result.rows[i]; + const loggedSeconds = member.logged_time ? parseFloat(member.logged_time) : 0; + const utilizedHours = loggedSeconds / 3600; + const utilizationPercent = totalWorkingSeconds > 0 && loggedSeconds + ? ((loggedSeconds / totalWorkingSeconds) * 100) + : 0; + const overUnder = utilizedHours - totalWorkingHours; + + member.value = utilizedHours ? parseFloat(utilizedHours.toFixed(2)) : 0; member.color_code = getColor(member.name); member.total_working_hours = totalWorkingHours; - member.utilization_percent = (totalWorkingHours > 0 && member.logged_time) ? ((parseFloat(member.logged_time) / (totalWorkingHours * 3600)) * 100).toFixed(2) : '0.00'; - member.utilized_hours = member.logged_time ? (parseFloat(member.logged_time) / 3600).toFixed(2) : '0.00'; - // Over/under utilized hours: utilized_hours - total_working_hours - const overUnder = member.utilized_hours && member.total_working_hours ? (parseFloat(member.utilized_hours) - member.total_working_hours) : 0; + member.utilization_percent = utilizationPercent.toFixed(2); + member.utilized_hours = utilizedHours.toFixed(2); member.over_under_utilized_hours = overUnder.toFixed(2); + + if (utilizationPercent < 90) { + member.utilization_state = 'under'; + } else if (utilizationPercent <= 110) { + member.utilization_state = 'optimal'; + } else { + member.utilization_state = 'over'; + } } - return res.status(200).send(new ServerResponse(true, result.rows)); + const filteredRows = hasUtilizationFilter + ? result.rows.filter(member => utilization.includes(member.utilization_state)) + : result.rows; + + return res.status(200).send(new ServerResponse(true, filteredRows)); } @HandleExceptions() diff --git a/worklenz-frontend/public/locales/en/time-report.json b/worklenz-frontend/public/locales/en/time-report.json index e2e67ac97..dc1f1fe0d 100644 --- a/worklenz-frontend/public/locales/en/time-report.json +++ b/worklenz-frontend/public/locales/en/time-report.json @@ -42,5 +42,7 @@ "noTeams": "No teams found", "noData": "No data found", "members": "Members", - "searchByMember": "Search by member" + "searchByMember": "Search by member", + "utilization": "Utilization" + } diff --git a/worklenz-frontend/src/features/reporting/time-reports/time-reports-overview.slice.ts b/worklenz-frontend/src/features/reporting/time-reports/time-reports-overview.slice.ts index 25c7ede79..af0c89041 100644 --- a/worklenz-frontend/src/features/reporting/time-reports/time-reports-overview.slice.ts +++ b/worklenz-frontend/src/features/reporting/time-reports/time-reports-overview.slice.ts @@ -26,6 +26,9 @@ interface ITimeReportsOverviewState { members: any[]; loadingMembers: boolean; + + utilization: any[]; + loadingUtilization: boolean; } const initialState: ITimeReportsOverviewState = { @@ -47,6 +50,9 @@ const initialState: ITimeReportsOverviewState = { }, members: [], loadingMembers: false, + + utilization: [], + loadingUtilization: false, }; const selectedMembers = (state: ITimeReportsOverviewState) => { @@ -63,6 +69,36 @@ const selectedCategories = (state: ITimeReportsOverviewState) => { .map(category => category.id) as string[]; }; +const selectedUtilization = (state: ITimeReportsOverviewState) => { + return state.utilization + .filter(utilization => utilization.selected) + .map(utilization => utilization.id) as string[]; +}; + +const allUtilization = (state: ITimeReportsOverviewState) => { + return state.utilization; +}; + +export const fetchReportingUtilization = createAsyncThunk( + 'timeReportsOverview/fetchReportingUtilization', + async (_, { rejectWithValue }) => { + try { + const utilization = [ + { id: 'under', name: 'Under-utilized (Under 90%)', selected: true }, + { id: 'optimal', name: 'Optimal-utilized (90%-110%)', selected: true }, + { id: 'over', name: 'Over-utilized (Over 110%)', selected: true }, + ]; + return utilization; + } catch (error) { + let errorMessage = 'An error occurred while fetching utilization'; + if (error instanceof Error) { + errorMessage = error.message; + } + return rejectWithValue(errorMessage); + } + } +); + export const fetchReportingMembers = createAsyncThunk( 'timeReportsOverview/fetchReportingMembers', async (_, { rejectWithValue, getState }) => { @@ -78,7 +114,11 @@ export const fetchReportingMembers = createAsyncThunk( return rejectWithValue(res.message || 'Failed to fetch members'); } } catch (error) { - return rejectWithValue(error.message || 'An error occurred while fetching members'); + let errorMessage = 'An error occurred while fetching members'; + if (error instanceof Error) { + errorMessage = error.message; + } + return rejectWithValue(errorMessage); } } ); @@ -181,6 +221,20 @@ const timeReportsOverviewSlice = createSlice({ member.selected = action.payload; }); }, + setSelectOrDeselectUtilization: ( + state, + action: PayloadAction<{ id: string; selected: boolean }> + ) => { + const utilization = state.utilization.find(u => u.id === action.payload.id); + if (utilization) { + utilization.selected = action.payload.selected; + } + }, + setSelectOrDeselectAllUtilization: (state, action: PayloadAction) => { + state.utilization.forEach(utilization => { + utilization.selected = action.payload; + }); + }, }, extraReducers: builder => { builder.addCase(fetchReportingTeams.fulfilled, (state, action) => { @@ -229,8 +283,8 @@ const timeReportsOverviewSlice = createSlice({ const members = action.payload.map((member: any) => ({ id: member.id, name: member.name, - selected: true, - avatar_url: member.avatar_url, + selected: true, + avatar_url: member.avatar_url, email: member.email, })); state.members = members; @@ -245,6 +299,17 @@ const timeReportsOverviewSlice = createSlice({ state.loadingMembers = false; console.error('Error fetching members:', action.payload); }); + builder.addCase(fetchReportingUtilization.fulfilled, (state, action) => { + state.utilization = action.payload; + state.loadingUtilization = false; + }); + builder.addCase(fetchReportingUtilization.pending, state => { + state.loadingUtilization = true; + }); + builder.addCase(fetchReportingUtilization.rejected, (state, action) => { + state.loadingUtilization = false; + console.error('Error fetching utilization:', action.payload); + }); }, }); @@ -259,6 +324,8 @@ export const { setSelectOrDeselectBillable, setSelectOrDeselectMember, setSelectOrDeselectAllMembers, + setSelectOrDeselectUtilization, + setSelectOrDeselectAllUtilization, setNoCategory, setArchived, } = timeReportsOverviewSlice.actions; diff --git a/worklenz-frontend/src/pages/reporting/time-reports/members-time-sheet/members-time-sheet.tsx b/worklenz-frontend/src/pages/reporting/time-reports/members-time-sheet/members-time-sheet.tsx index f44f711cd..b50961836 100644 --- a/worklenz-frontend/src/pages/reporting/time-reports/members-time-sheet/members-time-sheet.tsx +++ b/worklenz-frontend/src/pages/reporting/time-reports/members-time-sheet/members-time-sheet.tsx @@ -37,6 +37,8 @@ const MembersTimeSheet = forwardRef((_, ref) => { loadingProjects, members, loadingMembers, + utilization, + loadingUtilization, billable, archived, } = useAppSelector(state => state.timeReportsOverviewReducer); @@ -100,22 +102,24 @@ const MembersTimeSheet = forwardRef((_, ref) => { const hours = member?.utilized_hours || '0.00'; const percent = parseFloat(member?.utilization_percent || '0.00'); const overUnder = member?.over_under_utilized_hours || '0.00'; - let status = ''; let color = ''; - if (percent < 90) { - status = 'Under'; - color = '🟧'; - } else if (percent <= 110) { - status = 'Optimal'; - color = '🟩'; - } else { - status = 'Over'; - color = '🟥'; + switch (member.utilization_state) { + case 'under': + color = '🟧'; + break; + case 'optimal': + color = '🟩'; + break; + case 'over': + color = '🟥'; + break; + default: + color = ''; } return [ `${context.dataset.label}: ${hours} h`, `${color} Utilization: ${percent}%`, - `${status} Utilized: ${overUnder} h` + `${member.utilization_state} Utilized: ${overUnder} h` ]; }, } @@ -163,13 +167,14 @@ const MembersTimeSheet = forwardRef((_, ref) => { const selectedTeams = teams.filter(team => team.selected); const selectedProjects = filterProjects.filter(project => project.selected); const selectedCategories = categories.filter(category => category.selected); - const selectedMembers = members.filter(member => member.selected); // Use selected members - + const selectedMembers = members.filter(member => member.selected); + const selectedUtilization = utilization.filter(item => item.selected); const body = { teams: selectedTeams.map(t => t.id), projects: selectedProjects.map(project => project.id), categories: selectedCategories.map(category => category.id), - members: selectedMembers.map(member => member.id), // Include members in the request + members: selectedMembers.map(member => member.id), + utilization: selectedUtilization.map(item => item.id), duration, date_range: dateRange, billable, @@ -189,7 +194,7 @@ const MembersTimeSheet = forwardRef((_, ref) => { useEffect(() => { fetchChartData(); - }, [dispatch, duration, dateRange, billable, archived, teams, filterProjects, categories, members]); + }, [dispatch, duration, dateRange, billable, archived, teams, filterProjects, categories, members, utilization]); const exportChart = () => { if (chartRef.current) { diff --git a/worklenz-frontend/src/pages/reporting/timeReports/page-header/time-report-page-header.tsx b/worklenz-frontend/src/pages/reporting/timeReports/page-header/time-report-page-header.tsx index 5b4a4b7cd..4b7ab36d3 100644 --- a/worklenz-frontend/src/pages/reporting/timeReports/page-header/time-report-page-header.tsx +++ b/worklenz-frontend/src/pages/reporting/timeReports/page-header/time-report-page-header.tsx @@ -9,8 +9,10 @@ import { fetchReportingProjects, fetchReportingCategories, fetchReportingMembers, + fetchReportingUtilization, } from '@/features/reporting/time-reports/time-reports-overview.slice'; import Members from './members'; +import Utilization from './utilization'; const TimeReportPageHeader: React.FC = () => { const dispatch = useAppDispatch(); @@ -21,6 +23,7 @@ const TimeReportPageHeader: React.FC = () => { await dispatch(fetchReportingCategories()); await dispatch(fetchReportingProjects()); await dispatch(fetchReportingMembers()); + await dispatch(fetchReportingUtilization()); }; fetchData(); @@ -33,6 +36,7 @@ const TimeReportPageHeader: React.FC = () => { + ); }; diff --git a/worklenz-frontend/src/pages/reporting/timeReports/page-header/utilization.tsx b/worklenz-frontend/src/pages/reporting/timeReports/page-header/utilization.tsx new file mode 100644 index 000000000..14ed2f2e2 --- /dev/null +++ b/worklenz-frontend/src/pages/reporting/timeReports/page-header/utilization.tsx @@ -0,0 +1,104 @@ +import React, { useState } from 'react'; +import { useAppDispatch } from '@/hooks/useAppDispatch'; +import { useAppSelector } from '@/hooks/useAppSelector'; +import { setSelectOrDeselectAllMembers, setSelectOrDeselectAllUtilization, setSelectOrDeselectMember, setSelectOrDeselectUtilization } from '@/features/reporting/time-reports/time-reports-overview.slice'; +import { Button, Checkbox, Divider, Dropdown, Input, Avatar, theme } from 'antd'; +import { CheckboxChangeEvent } from 'antd/es/checkbox'; +import { CaretDownFilled } from '@ant-design/icons'; +import { useTranslation } from 'react-i18next'; +import { id } from 'date-fns/locale'; + +const Utilization: React.FC = () => { + const dispatch = useAppDispatch(); + const { t } = useTranslation('time-report'); + const { utilization, loadingUtilization } = useAppSelector(state => state.timeReportsOverviewReducer); + const { token } = theme.useToken(); + + const [searchText, setSearchText] = useState(''); + const [selectAll, setSelectAll] = useState(true); + + // Filter members based on search text + const filteredItems = utilization.filter(item => + item.name?.toLowerCase().includes(searchText.toLowerCase()) + ); + // Handle checkbox change for individual members + const handleCheckboxChange = (id: string, selected: boolean) => { + dispatch(setSelectOrDeselectUtilization({ id, selected })); + }; + + const handleSelectAll = (e: CheckboxChangeEvent) => { + const isChecked = e.target.checked; + setSelectAll(isChecked); + dispatch(setSelectOrDeselectAllUtilization(isChecked)); + }; + + return ( + ( +
+
+
+
+ e.stopPropagation()} + onChange={handleSelectAll} + checked={selectAll} + > + {t('selectAll')} + +
+ +
+ {filteredItems.map((ut, index) => ( +
+ e.stopPropagation()} + checked={ut.selected} + onChange={e => handleCheckboxChange(ut.id, e.target.checked)} + > + {ut.name} + +
+ ))} +
+
+ )} + > + +
+ ); +}; + +export default Utilization; \ No newline at end of file diff --git a/worklenz-frontend/src/types/reporting/reporting.types.ts b/worklenz-frontend/src/types/reporting/reporting.types.ts index aa36069c3..6ca743911 100644 --- a/worklenz-frontend/src/types/reporting/reporting.types.ts +++ b/worklenz-frontend/src/types/reporting/reporting.types.ts @@ -409,6 +409,7 @@ export interface IRPTTimeMember { utilized_hours?: string; utilization_percent?: string; over_under_utilized_hours?: string; + utilization_state?: string; } export interface IMemberTaskStatGroupResonse { From b5288a8da2d1f3749f40114819b2834f66e58279 Mon Sep 17 00:00:00 2001 From: shancds Date: Thu, 29 May 2025 15:49:06 +0530 Subject: [PATCH 056/105] fix(reporting): correct member data extraction in fetchReportingMembers --- .../reporting/time-reports/time-reports-overview.slice.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/worklenz-frontend/src/features/reporting/time-reports/time-reports-overview.slice.ts b/worklenz-frontend/src/features/reporting/time-reports/time-reports-overview.slice.ts index af0c89041..81ad17c92 100644 --- a/worklenz-frontend/src/features/reporting/time-reports/time-reports-overview.slice.ts +++ b/worklenz-frontend/src/features/reporting/time-reports/time-reports-overview.slice.ts @@ -108,8 +108,7 @@ export const fetchReportingMembers = createAsyncThunk( try { const res = await reportingApiService.getMembers(selectedMembers(timeReportsOverviewReducer)); if (res.done) { - // Extract members from the response - return res.body.members; // Use `body.members` instead of `body` + return res.body; } else { return rejectWithValue(res.message || 'Failed to fetch members'); } @@ -280,7 +279,7 @@ const timeReportsOverviewSlice = createSlice({ state.loadingProjects = false; }); builder.addCase(fetchReportingMembers.fulfilled, (state, action) => { - const members = action.payload.map((member: any) => ({ + const members = action.payload.members.map((member: any) => ({ id: member.id, name: member.name, selected: true, From a0f36968b3b44ca58239236fe07cbb767d34d89d Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Thu, 29 May 2025 16:13:36 +0530 Subject: [PATCH 057/105] feat(auth): add debug logging for authentication processes - Introduced detailed console logging in the auth controller, deserialize, serialize, and passport strategies to aid in debugging authentication flows. - Enhanced error handling and response messaging during user registration and login processes, providing clearer feedback for success and failure scenarios. - Updated the signup and login functions to include more informative logs, improving traceability of user actions and system responses. --- .../src/controllers/auth-controller.ts | 21 +++++++- worklenz-backend/src/passport/deserialize.ts | 19 +++++++ .../passport-local-login.ts | 4 ++ .../passport-local-signup.ts | 50 +++++++++++++++---- worklenz-backend/src/passport/serialize.ts | 4 ++ 5 files changed, 88 insertions(+), 10 deletions(-) diff --git a/worklenz-backend/src/controllers/auth-controller.ts b/worklenz-backend/src/controllers/auth-controller.ts index 8364d59cd..46370f2af 100644 --- a/worklenz-backend/src/controllers/auth-controller.ts +++ b/worklenz-backend/src/controllers/auth-controller.ts @@ -28,20 +28,39 @@ export default class AuthController extends WorklenzControllerBase { } public static verify(req: IWorkLenzRequest, res: IWorkLenzResponse) { + console.log("=== VERIFY DEBUG ==="); + console.log("req.user:", req.user); + console.log("req.isAuthenticated():", req.isAuthenticated()); + console.log("req.session.passport:", (req.session as any).passport); + console.log("req.query.strategy:", req.query.strategy); + // Flash messages sent from passport-local-signup.ts and passport-local-login.ts const errors = req.flash()["error"] || []; const messages = req.flash()["success"] || []; + + console.log("Flash errors:", errors); + console.log("Flash messages:", messages); + // If there are multiple messages, we will send one at a time. const auth_error = errors.length > 0 ? errors[0] : null; const message = messages.length > 0 ? messages[0] : null; const midTitle = req.query.strategy === "login" ? "Login Failed!" : "Signup Failed!"; const title = req.query.strategy ? midTitle : null; + + console.log("Title:", title); + console.log("Auth error:", auth_error); + console.log("Success message:", message); + console.log("Is authenticated:", req.isAuthenticated()); + console.log("Has user:", !!req.user); if (req.user) req.user.build_v = FileConstants.getRelease(); - return res.status(200).send(new AuthResponse(title, req.isAuthenticated(), req.user || null, auth_error, message)); + const response = new AuthResponse(title, req.isAuthenticated(), req.user || null, auth_error, message); + console.log("Sending response:", response); + + return res.status(200).send(response); } public static logout(req: IWorkLenzRequest, res: IWorkLenzResponse) { diff --git a/worklenz-backend/src/passport/deserialize.ts b/worklenz-backend/src/passport/deserialize.ts index bbbd5352b..fa52fa7ce 100644 --- a/worklenz-backend/src/passport/deserialize.ts +++ b/worklenz-backend/src/passport/deserialize.ts @@ -22,17 +22,30 @@ async function clearEmailInvitations(email: string, teamId: string) { // Check whether the user still exists on the database export async function deserialize(user: { id: string | null }, done: IDeserializeCallback) { try { + console.log("=== DESERIALIZE DEBUG ==="); + console.log("User object:", user); + if (!user || !user.id) { + console.log("No user or user.id, returning null"); return done(null, null); } const {id} = user; + console.log("Deserializing user ID:", id); + const excludedSubscriptionTypes = ["TRIAL", "PADDLE"]; const q = `SELECT deserialize_user($1) AS user;`; const result = await db.query(q, [id]); + + console.log("Database query result rows length:", result.rows.length); + if (result.rows.length) { const [data] = result.rows; + console.log("Database result data:", data); + if (data?.user) { + console.log("User data found:", data.user); + const realExpiredDate = moment(data.user.valid_till_date).add(7, "days"); data.user.is_expired = false; @@ -42,11 +55,17 @@ export async function deserialize(user: { id: string | null }, done: IDeserializ void setLastActive(data.user.id); void clearEmailInvitations(data.user.email, data.user.team_id); + console.log("Returning successful user:", data.user); return done(null, data.user as IPassportSession); } + console.log("No user data in result"); } + console.log("No rows returned from database"); + + console.log("Returning null user"); return done(null, null); } catch (error) { + console.log("Deserialize error:", error); return done(error, null); } } diff --git a/worklenz-backend/src/passport/passport-strategies/passport-local-login.ts b/worklenz-backend/src/passport/passport-strategies/passport-local-login.ts index 7d29fae86..259e31428 100644 --- a/worklenz-backend/src/passport/passport-strategies/passport-local-login.ts +++ b/worklenz-backend/src/passport/passport-strategies/passport-local-login.ts @@ -22,6 +22,7 @@ async function handleLogin(req: Request, email: string, password: string, done: console.log("User query result count:", result.rowCount); const [data] = result.rows; + console.log("data", data); if (!data?.password) { console.log("No account found"); @@ -33,6 +34,9 @@ async function handleLogin(req: Request, email: string, password: string, done: if (passwordMatch && email === data.email) { delete data.password; + console.log("=== LOGIN SUCCESS DEBUG ==="); + console.log("About to call done with user:", data); + console.log("User structure:", JSON.stringify(data, null, 2)); return done(null, data, {message: "User successfully logged in"}); } return done(null, false, { message: "Incorrect email or password" }); diff --git a/worklenz-backend/src/passport/passport-strategies/passport-local-signup.ts b/worklenz-backend/src/passport/passport-strategies/passport-local-signup.ts index 563950667..4e83706cb 100644 --- a/worklenz-backend/src/passport/passport-strategies/passport-local-signup.ts +++ b/worklenz-backend/src/passport/passport-strategies/passport-local-signup.ts @@ -37,8 +37,15 @@ async function registerUser(password: string, team_id: string, name: string, tea team_member_id, }; + console.log("=== REGISTER USER DEBUG ==="); + console.log("Calling register_user with body:", body); + const result = await db.query(q, [JSON.stringify(body)]); const [data] = result.rows; + + console.log("Register user result:", data); + console.log("User object returned:", data.user); + return data.user; } @@ -47,41 +54,66 @@ async function handleSignUp(req: Request, email: string, password: string, done: // team = Invited team_id if req.body.from_invitation is true const {name, team_name, team_member_id, team_id, timezone} = req.body; - if (!team_name) return done(null, null, req.flash(ERROR_KEY, "Team name is required")); + if (!team_name) { + req.flash(ERROR_KEY, "Team name is required"); + return done(null, null, {message: "Team name is required"}); + } const googleAccountFound = await isGoogleAccountFound(email); - if (googleAccountFound) - return done(null, null, req.flash(ERROR_KEY, `${req.body.email} is already linked with a Google account.`)); + if (googleAccountFound) { + req.flash(ERROR_KEY, `${req.body.email} is already linked with a Google account.`); + return done(null, null, {message: `${req.body.email} is already linked with a Google account.`}); + } try { + console.log("=== SIGNUP DEBUG ==="); + console.log("About to register user with data:", {name, team_name, email, timezone, team_member_id, team_id}); + const user = await registerUser(password, team_id, name, team_name, email, timezone, team_member_id); + + console.log("User registration successful, user object:", user); + sendWelcomeEmail(email, name); - return done(null, user, req.flash(SUCCESS_KEY, "Registration successful. Please check your email for verification.")); + + console.log("About to call done with user:", user); + req.flash(SUCCESS_KEY, "Registration successful. Please check your email for verification."); + return done(null, user, {message: "Registration successful. Please check your email for verification."}); } catch (error: any) { + console.log("=== SIGNUP ERROR ==="); + console.log("Error during signup:", error); + const message = (error?.message) || ""; if (message === "ERROR_INVALID_JOINING_EMAIL") { - return done(null, null, req.flash(ERROR_KEY, `No invitations found for email ${req.body.email}.`)); + req.flash(ERROR_KEY, `No invitations found for email ${req.body.email}.`); + return done(null, null, {message: `No invitations found for email ${req.body.email}.`}); } // if error.message is "email already exists" then it should have the email address in the error message after ":". if (message.includes("EMAIL_EXISTS_ERROR") || error.constraint === "users_google_id_uindex") { const [, value] = error.message.split(":"); - return done(null, null, req.flash(ERROR_KEY, `Worklenz account already exists for email ${value}.`)); + const errorMsg = `Worklenz account already exists for email ${value}.`; + req.flash(ERROR_KEY, errorMsg); + return done(null, null, {message: errorMsg}); } if (message.includes("TEAM_NAME_EXISTS_ERROR")) { const [, value] = error.message.split(":"); - return done(null, null, req.flash(ERROR_KEY, `Team name "${value}" already exists. Please choose a different team name.`)); + const errorMsg = `Team name "${value}" already exists. Please choose a different team name.`; + req.flash(ERROR_KEY, errorMsg); + return done(null, null, {message: errorMsg}); } // The Team name is already taken. if (error.constraint === "teams_url_uindex" || error.constraint === "teams_name_uindex") { - return done(null, null, req.flash(ERROR_KEY, `Team name "${team_name}" is already taken. Please choose a different team name.`)); + const errorMsg = `Team name "${team_name}" is already taken. Please choose a different team name.`; + req.flash(ERROR_KEY, errorMsg); + return done(null, null, {message: errorMsg}); } log_error(error, req.body); - return done(null, null, req.flash(ERROR_KEY, DEFAULT_ERROR_MESSAGE)); + req.flash(ERROR_KEY, DEFAULT_ERROR_MESSAGE); + return done(null, null, {message: DEFAULT_ERROR_MESSAGE}); } } diff --git a/worklenz-backend/src/passport/serialize.ts b/worklenz-backend/src/passport/serialize.ts index b3c603f67..8b33136e4 100644 --- a/worklenz-backend/src/passport/serialize.ts +++ b/worklenz-backend/src/passport/serialize.ts @@ -3,5 +3,9 @@ import {IPassportSession} from "../interfaces/passport-session"; // Parse the user id to deserialize function export function serialize($user: IPassportSession, done: ISerializeCallback) { + console.log("=== SERIALIZE DEBUG ==="); + console.log("Serializing user:", $user); + console.log("User ID:", $user?.id); + done(null, { id: $user?.id ?? null }); } From 6a2e9afff8c2463d85be14cee6b604443dff8176 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Thu, 29 May 2025 16:44:40 +0530 Subject: [PATCH 058/105] feat(auth): enhance session and user deserialization logging - Added detailed logging for session checks in the auth controller, including session ID and full session object. - Implemented user existence verification in the deserialize function, with improved logging for user checks and database query results. - Enhanced the serialize function to log the serialized user object and completion of the serialization process, improving traceability in authentication workflows. --- .../src/controllers/auth-controller.ts | 19 +++++++++++++++++++ worklenz-backend/src/passport/deserialize.ts | 14 +++++++++++++- worklenz-backend/src/passport/serialize.ts | 7 ++++++- 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/worklenz-backend/src/controllers/auth-controller.ts b/worklenz-backend/src/controllers/auth-controller.ts index 46370f2af..9bcd66f36 100644 --- a/worklenz-backend/src/controllers/auth-controller.ts +++ b/worklenz-backend/src/controllers/auth-controller.ts @@ -32,8 +32,27 @@ export default class AuthController extends WorklenzControllerBase { console.log("req.user:", req.user); console.log("req.isAuthenticated():", req.isAuthenticated()); console.log("req.session.passport:", (req.session as any).passport); + console.log("req.session.id:", req.sessionID); + console.log("Full session object:", JSON.stringify(req.session, null, 2)); console.log("req.query.strategy:", req.query.strategy); + // Check if session exists in database + if (req.sessionID) { + db.query("SELECT sid, sess FROM pg_sessions WHERE sid = $1", [req.sessionID]) + .then(result => { + if (result.rows.length > 0) { + console.log("Session found in database:"); + console.log("Session ID:", result.rows[0].sid); + console.log("Session data:", JSON.stringify(result.rows[0].sess, null, 2)); + } else { + console.log("Session NOT FOUND in database for ID:", req.sessionID); + } + }) + .catch(err => { + console.log("Error checking session in database:", err); + }); + } + // Flash messages sent from passport-local-signup.ts and passport-local-login.ts const errors = req.flash()["error"] || []; const messages = req.flash()["success"] || []; diff --git a/worklenz-backend/src/passport/deserialize.ts b/worklenz-backend/src/passport/deserialize.ts index fa52fa7ce..450174ce8 100644 --- a/worklenz-backend/src/passport/deserialize.ts +++ b/worklenz-backend/src/passport/deserialize.ts @@ -33,11 +33,23 @@ export async function deserialize(user: { id: string | null }, done: IDeserializ const {id} = user; console.log("Deserializing user ID:", id); + // First check if user exists in users table + const userCheck = await db.query("SELECT id, active_team FROM users WHERE id = $1", [id]); + console.log("User exists check:", userCheck.rowCount, userCheck.rows[0]); + + if (!userCheck.rowCount) { + console.log("User not found in users table"); + return done(null, null); + } + const excludedSubscriptionTypes = ["TRIAL", "PADDLE"]; const q = `SELECT deserialize_user($1) AS user;`; + console.log("Calling deserialize_user with ID:", id); + const result = await db.query(q, [id]); console.log("Database query result rows length:", result.rows.length); + console.log("Raw database result:", result.rows); if (result.rows.length) { const [data] = result.rows; @@ -58,7 +70,7 @@ export async function deserialize(user: { id: string | null }, done: IDeserializ console.log("Returning successful user:", data.user); return done(null, data.user as IPassportSession); } - console.log("No user data in result"); + console.log("No user data in result - deserialize_user returned null"); } console.log("No rows returned from database"); diff --git a/worklenz-backend/src/passport/serialize.ts b/worklenz-backend/src/passport/serialize.ts index 8b33136e4..942c89a82 100644 --- a/worklenz-backend/src/passport/serialize.ts +++ b/worklenz-backend/src/passport/serialize.ts @@ -7,5 +7,10 @@ export function serialize($user: IPassportSession, done: ISerializeCallback) { console.log("Serializing user:", $user); console.log("User ID:", $user?.id); - done(null, { id: $user?.id ?? null }); + const serializedUser = { id: $user?.id ?? null }; + console.log("Serialized user object:", serializedUser); + + done(null, serializedUser); + + console.log("Serialize done callback completed"); } From a3d5e63635ab7bb22a5e64df63d1c1fff15e8be8 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Thu, 29 May 2025 16:48:25 +0530 Subject: [PATCH 059/105] fix(session): update session middleware configuration - Changed session middleware settings to resave sessions when uninitialized and prevent saving uninitialized sessions. - Updated cookie settings to enable httpOnly and set secure to false, enhancing security measures for session management. --- worklenz-backend/src/middlewares/session-middleware.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/worklenz-backend/src/middlewares/session-middleware.ts b/worklenz-backend/src/middlewares/session-middleware.ts index cb6cd624a..263fd8574 100644 --- a/worklenz-backend/src/middlewares/session-middleware.ts +++ b/worklenz-backend/src/middlewares/session-middleware.ts @@ -9,8 +9,8 @@ export default session({ name: process.env.SESSION_NAME, secret: process.env.SESSION_SECRET || "development-secret-key", proxy: false, - resave: false, - saveUninitialized: true, + resave: true, + saveUninitialized: false, rolling: true, store: new pgSession({ pool: db.pool, @@ -18,8 +18,8 @@ export default session({ }), cookie: { path: "/", - // secure: isProduction(), - // httpOnly: isProduction(), + httpOnly: true, + secure: false, // sameSite: "none", // domain: isProduction() ? ".worklenz.com" : undefined, maxAge: 30 * 24 * 60 * 60 * 1000 // 30 days From 2f0fb92e3e2352b3c4b260ec7a366ab8ddee8378 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Thu, 29 May 2025 16:54:27 +0530 Subject: [PATCH 060/105] feat(session): add session store debugging and pg_sessions table management - Implemented a test function for the session store to verify database connection and check the existence of the pg_sessions table. - Added logic to create the pg_sessions table if it does not exist, including defining its structure and constraints. - Enhanced session store methods with detailed logging for session set and get operations, improving traceability and debugging capabilities. --- .../src/middlewares/session-middleware.ts | 123 +++++++++++++++++- 1 file changed, 119 insertions(+), 4 deletions(-) diff --git a/worklenz-backend/src/middlewares/session-middleware.ts b/worklenz-backend/src/middlewares/session-middleware.ts index 263fd8574..2c3eddc75 100644 --- a/worklenz-backend/src/middlewares/session-middleware.ts +++ b/worklenz-backend/src/middlewares/session-middleware.ts @@ -5,6 +5,124 @@ import { isProduction } from "../shared/utils"; // eslint-disable-next-line @typescript-eslint/no-var-requires const pgSession = require("connect-pg-simple")(session); +// Test database connection and pg_sessions table +async function testSessionStore() { + try { + console.log("=== SESSION STORE DEBUG ==="); + + // Test basic database connection + const testQuery = await db.query("SELECT NOW() as current_time"); + console.log("Database connection test:", testQuery.rows[0]); + + // Check if pg_sessions table exists + const tableCheck = await db.query(` + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_name = 'pg_sessions' + ) as table_exists + `); + console.log("pg_sessions table exists:", tableCheck.rows[0].table_exists); + + if (tableCheck.rows[0].table_exists) { + // Check table structure + const structureQuery = await db.query(` + SELECT column_name, data_type + FROM information_schema.columns + WHERE table_name = 'pg_sessions' + ORDER BY ordinal_position + `); + console.log("pg_sessions table structure:", structureQuery.rows); + + // Check current sessions count + const countQuery = await db.query("SELECT COUNT(*) as session_count FROM pg_sessions"); + console.log("Current sessions in database:", countQuery.rows[0].session_count); + + // Check recent sessions + const recentQuery = await db.query(` + SELECT sid, expire, created_at + FROM pg_sessions + ORDER BY created_at DESC + LIMIT 3 + `); + console.log("Recent sessions:", recentQuery.rows); + } else { + console.log("ERROR: pg_sessions table does not exist!"); + + // Try to create the table + console.log("Attempting to create pg_sessions table..."); + await db.query(` + CREATE TABLE IF NOT EXISTS pg_sessions ( + sid VARCHAR NOT NULL COLLATE "default", + sess JSON NOT NULL, + expire TIMESTAMP(6) NOT NULL, + created_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP + ) + WITH (OIDS=FALSE); + + ALTER TABLE pg_sessions ADD CONSTRAINT session_pkey PRIMARY KEY (sid) NOT DEFERRABLE INITIALLY IMMEDIATE; + CREATE INDEX IF NOT EXISTS IDX_session_expire ON pg_sessions (expire); + `); + console.log("pg_sessions table created successfully"); + } + + console.log("=== END SESSION STORE DEBUG ==="); + } catch (error) { + console.log("Session store test error:", error); + } +} + +// Run the test +testSessionStore(); + +const store = new pgSession({ + pool: db.pool, + tableName: "pg_sessions" +}); + +// Add store event listeners +store.on("connect", () => { + console.log("Session store connected to database"); +}); + +store.on("disconnect", () => { + console.log("Session store disconnected from database"); +}); + +// Override store methods to add debugging +const originalSet = store.set.bind(store); +const originalGet = store.get.bind(store); + +store.set = function(sid: string, session: any, callback: any) { + console.log(`=== SESSION SET ===`); + console.log(`Session ID: ${sid}`); + console.log(`Session data:`, JSON.stringify(session, null, 2)); + + return originalSet(sid, session, (err: any) => { + if (err) { + console.log(`Session SET ERROR for ${sid}:`, err); + } else { + console.log(`Session SET SUCCESS for ${sid}`); + } + callback && callback(err); + }); +}; + +store.get = function(sid: string, callback: any) { + console.log(`=== SESSION GET ===`); + console.log(`Requesting session ID: ${sid}`); + + return originalGet(sid, (err: any, session: any) => { + if (err) { + console.log(`Session GET ERROR for ${sid}:`, err); + } else if (session) { + console.log(`Session GET SUCCESS for ${sid}:`, JSON.stringify(session, null, 2)); + } else { + console.log(`Session GET: No session found for ${sid}`); + } + callback(err, session); + }); +}; + export default session({ name: process.env.SESSION_NAME, secret: process.env.SESSION_SECRET || "development-secret-key", @@ -12,10 +130,7 @@ export default session({ resave: true, saveUninitialized: false, rolling: true, - store: new pgSession({ - pool: db.pool, - tableName: "pg_sessions" - }), + store, cookie: { path: "/", httpOnly: true, From 935165d7510784b7297c411eb68feb31fc046d76 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Thu, 29 May 2025 17:04:08 +0530 Subject: [PATCH 061/105] refactor(session): simplify pg_sessions table structure and query logic - Removed the created_at column from the pg_sessions table definition to streamline session management. - Updated the recent sessions query to order by expire instead of created_at, enhancing the relevance of retrieved session data. --- worklenz-backend/src/middlewares/session-middleware.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/worklenz-backend/src/middlewares/session-middleware.ts b/worklenz-backend/src/middlewares/session-middleware.ts index 2c3eddc75..ef48d20be 100644 --- a/worklenz-backend/src/middlewares/session-middleware.ts +++ b/worklenz-backend/src/middlewares/session-middleware.ts @@ -39,9 +39,9 @@ async function testSessionStore() { // Check recent sessions const recentQuery = await db.query(` - SELECT sid, expire, created_at + SELECT sid, expire FROM pg_sessions - ORDER BY created_at DESC + ORDER BY expire DESC LIMIT 3 `); console.log("Recent sessions:", recentQuery.rows); @@ -54,8 +54,7 @@ async function testSessionStore() { CREATE TABLE IF NOT EXISTS pg_sessions ( sid VARCHAR NOT NULL COLLATE "default", sess JSON NOT NULL, - expire TIMESTAMP(6) NOT NULL, - created_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP + expire TIMESTAMP(6) NOT NULL ) WITH (OIDS=FALSE); From d1fe23b4319822847f86208dd4d274aa7bc62847 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Thu, 29 May 2025 17:06:19 +0530 Subject: [PATCH 062/105] feat(database): add progress tracking and finance module tables - Introduced a new ENUM type for progress modes in tasks to enhance progress tracking capabilities. - Updated the projects and tasks tables to include new columns for manual and weighted progress tracking. - Added new finance-related tables for rate cards and project rate card roles to support financial management within projects. - Enhanced project members table to link with finance project rate card roles, improving data integrity and relationships. --- worklenz-backend/database/sql/1_tables.sql | 51 +++++++++++++++++++++- 1 file changed, 49 insertions(+), 2 deletions(-) diff --git a/worklenz-backend/database/sql/1_tables.sql b/worklenz-backend/database/sql/1_tables.sql index af6cdc0ef..670d12faf 100644 --- a/worklenz-backend/database/sql/1_tables.sql +++ b/worklenz-backend/database/sql/1_tables.sql @@ -14,6 +14,9 @@ CREATE TYPE SCHEDULE_TYPE AS ENUM ('daily', 'weekly', 'yearly', 'monthly', 'ever CREATE TYPE LANGUAGE_TYPE AS ENUM ('en', 'es', 'pt'); +-- Add progress mode type for tasks progress tracking +CREATE TYPE PROGRESS_MODE_TYPE AS ENUM ('manual', 'weighted', 'time', 'default'); + -- START: Users CREATE SEQUENCE IF NOT EXISTS users_user_no_seq START 1; @@ -777,7 +780,10 @@ CREATE TABLE IF NOT EXISTS projects ( estimated_man_days INTEGER DEFAULT 0, hours_per_day INTEGER DEFAULT 8, health_id UUID, - estimated_working_days INTEGER DEFAULT 0 + estimated_working_days INTEGER DEFAULT 0, + use_manual_progress BOOLEAN DEFAULT FALSE, + use_weighted_progress BOOLEAN DEFAULT FALSE, + use_time_progress BOOLEAN DEFAULT FALSE ); ALTER TABLE projects @@ -1411,9 +1417,16 @@ CREATE TABLE IF NOT EXISTS tasks ( sort_order INTEGER DEFAULT 0 NOT NULL, roadmap_sort_order INTEGER DEFAULT 0 NOT NULL, billable BOOLEAN DEFAULT TRUE, - schedule_id UUID + schedule_id UUID, + manual_progress BOOLEAN DEFAULT FALSE, + progress_value INTEGER DEFAULT NULL, + progress_mode PROGRESS_MODE_TYPE DEFAULT 'default', + weight INTEGER DEFAULT NULL, + fixed_cost DECIMAL(10, 2) DEFAULT 0 CHECK (fixed_cost >= 0) ); +COMMENT ON COLUMN tasks.fixed_cost IS 'Fixed cost for the task in addition to hourly rate calculations'; + ALTER TABLE tasks ADD CONSTRAINT tasks_pk PRIMARY KEY (id); @@ -2279,3 +2292,37 @@ ALTER TABLE organization_working_days ALTER TABLE organization_working_days ADD CONSTRAINT org_organization_id_fk FOREIGN KEY (organization_id) REFERENCES organizations; + +-- Finance module tables +CREATE TABLE IF NOT EXISTS finance_rate_cards ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + team_id UUID NOT NULL REFERENCES teams (id) ON DELETE CASCADE, + name VARCHAR NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + currency TEXT NOT NULL DEFAULT 'USD' +); + +CREATE TABLE IF NOT EXISTS finance_project_rate_card_roles ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + project_id UUID NOT NULL REFERENCES projects (id) ON DELETE CASCADE, + job_title_id UUID NOT NULL REFERENCES job_titles (id) ON DELETE CASCADE, + rate DECIMAL(10, 2) NOT NULL CHECK (rate >= 0), + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT unique_project_role UNIQUE (project_id, job_title_id) +); + +CREATE TABLE IF NOT EXISTS finance_rate_card_roles ( + rate_card_id UUID NOT NULL REFERENCES finance_rate_cards (id) ON DELETE CASCADE, + job_title_id UUID REFERENCES job_titles(id) ON DELETE SET NULL, + rate DECIMAL(10, 2) NOT NULL CHECK (rate >= 0), + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +ALTER TABLE project_members + ADD COLUMN IF NOT EXISTS project_rate_card_role_id UUID REFERENCES finance_project_rate_card_roles(id) ON DELETE SET NULL; + +ALTER TABLE projects + ADD COLUMN IF NOT EXISTS rate_card UUID REFERENCES finance_rate_cards(id) ON DELETE SET NULL; From 1f6bbce0ae7d9ead30cce99eb08098d70e245617 Mon Sep 17 00:00:00 2001 From: shancds Date: Thu, 29 May 2025 17:50:11 +0530 Subject: [PATCH 063/105] feat(reporting): add total time utilization component and update member time sheets to include totals --- .../reporting-allocation-controller.ts | 26 ++++++++++---- .../reporting.timesheet.api.service.ts | 4 +-- .../members-time-sheet/members-time-sheet.tsx | 16 +++++++-- .../timeReports/members-time-reports.tsx | 18 +++++++--- .../total-time-utilization.tsx | 34 +++++++++++++++++++ .../src/types/reporting/reporting.types.ts | 9 +++++ 6 files changed, 93 insertions(+), 14 deletions(-) create mode 100644 worklenz-frontend/src/pages/reporting/timeReports/total-time-utilization/total-time-utilization.tsx diff --git a/worklenz-backend/src/controllers/reporting/reporting-allocation-controller.ts b/worklenz-backend/src/controllers/reporting/reporting-allocation-controller.ts index 9add9696a..98feafd9c 100644 --- a/worklenz-backend/src/controllers/reporting/reporting-allocation-controller.ts +++ b/worklenz-backend/src/controllers/reporting/reporting-allocation-controller.ts @@ -504,8 +504,8 @@ export default class ReportingAllocationController extends ReportingControllerBa const loggedSeconds = member.logged_time ? parseFloat(member.logged_time) : 0; const utilizedHours = loggedSeconds / 3600; const utilizationPercent = totalWorkingSeconds > 0 && loggedSeconds - ? ((loggedSeconds / totalWorkingSeconds) * 100) - : 0; + ? ((loggedSeconds / totalWorkingSeconds) * 100) + : 0; const overUnder = utilizedHours - totalWorkingHours; member.value = utilizedHours ? parseFloat(utilizedHours.toFixed(2)) : 0; @@ -516,11 +516,11 @@ export default class ReportingAllocationController extends ReportingControllerBa member.over_under_utilized_hours = overUnder.toFixed(2); if (utilizationPercent < 90) { - member.utilization_state = 'under'; + member.utilization_state = 'under'; } else if (utilizationPercent <= 110) { - member.utilization_state = 'optimal'; + member.utilization_state = 'optimal'; } else { - member.utilization_state = 'over'; + member.utilization_state = 'over'; } } @@ -528,7 +528,21 @@ export default class ReportingAllocationController extends ReportingControllerBa ? result.rows.filter(member => utilization.includes(member.utilization_state)) : result.rows; - return res.status(200).send(new ServerResponse(true, filteredRows)); + // Calculate totals + const total_time_logs = filteredRows.reduce((sum, member) => sum + parseFloat(member.logged_time || '0'), 0); + const total_estimated_hours = totalWorkingHours; + const total_utilization = total_time_logs > 0 && totalWorkingSeconds > 0 + ? ((total_time_logs / totalWorkingSeconds) * 100).toFixed(2) + : '0.00'; + + return res.status(200).send(new ServerResponse(true, { + filteredRows, + totals: { + total_time_logs: ((total_time_logs / 3600).toFixed(2)).toString(), + total_estimated_hours: total_estimated_hours.toString(), + total_utilization: total_utilization.toString(), + }, + })); } @HandleExceptions() diff --git a/worklenz-frontend/src/api/reporting/reporting.timesheet.api.service.ts b/worklenz-frontend/src/api/reporting/reporting.timesheet.api.service.ts index 1529d46b1..488a06195 100644 --- a/worklenz-frontend/src/api/reporting/reporting.timesheet.api.service.ts +++ b/worklenz-frontend/src/api/reporting/reporting.timesheet.api.service.ts @@ -3,7 +3,7 @@ import { toQueryString } from '@/utils/toQueryString'; import apiClient from '../api-client'; import { IServerResponse } from '@/types/common.types'; import { IAllocationViewModel } from '@/types/reporting/reporting-allocation.types'; -import { IProjectLogsBreakdown, IRPTTimeMember, IRPTTimeProject, ITimeLogBreakdownReq } from '@/types/reporting/reporting.types'; +import { IProjectLogsBreakdown, IRPTTimeMember, IRPTTimeMemberViewModel, IRPTTimeProject, ITimeLogBreakdownReq } from '@/types/reporting/reporting.types'; const rootUrl = `${API_BASE_URL}/reporting`; @@ -25,7 +25,7 @@ export const reportingTimesheetApiService = { return response.data; }, - getMemberTimeSheets: async (body = {}, archived = false): Promise> => { + getMemberTimeSheets: async (body = {}, archived = false): Promise> => { const q = toQueryString({ archived }); const response = await apiClient.post(`${rootUrl}/time-reports/members/${q}`, body); return response.data; diff --git a/worklenz-frontend/src/pages/reporting/time-reports/members-time-sheet/members-time-sheet.tsx b/worklenz-frontend/src/pages/reporting/time-reports/members-time-sheet/members-time-sheet.tsx index b50961836..4c5179255 100644 --- a/worklenz-frontend/src/pages/reporting/time-reports/members-time-sheet/members-time-sheet.tsx +++ b/worklenz-frontend/src/pages/reporting/time-reports/members-time-sheet/members-time-sheet.tsx @@ -19,11 +19,14 @@ import { useAppDispatch } from '@/hooks/useAppDispatch'; ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend, ChartDataLabels); +interface MembersTimeSheetProps { + onTotalsUpdate: (totals: { total_time_logs: string; total_estimated_hours: string; total_utilization: string }) => void; +} export interface MembersTimeSheetRef { exportChart: () => void; } -const MembersTimeSheet = forwardRef((_, ref) => { +const MembersTimeSheet = forwardRef(({ onTotalsUpdate }, ref) => { const { t } = useTranslation('time-report'); const dispatch = useAppDispatch(); const chartRef = React.useRef>(null); @@ -181,8 +184,17 @@ const MembersTimeSheet = forwardRef((_, ref) => { }; const res = await reportingTimesheetApiService.getMemberTimeSheets(body, archived); + console.log('Members Time Sheet Data:', res.body.totals); if (res.done) { - setJsonData(res.body || []); + setJsonData(res.body.filteredRows || []); + + const totalsRaw = res.body.totals || {}; + const totals = { + total_time_logs: totalsRaw.total_time_logs ?? "0", + total_estimated_hours: totalsRaw.total_estimated_hours ?? "0", + total_utilization: totalsRaw.total_utilization ?? "0", + }; + onTotalsUpdate(totals); } } catch (error) { console.error('Error fetching chart data:', error); diff --git a/worklenz-frontend/src/pages/reporting/timeReports/members-time-reports.tsx b/worklenz-frontend/src/pages/reporting/timeReports/members-time-reports.tsx index cdbcebd17..e777eed68 100644 --- a/worklenz-frontend/src/pages/reporting/timeReports/members-time-reports.tsx +++ b/worklenz-frontend/src/pages/reporting/timeReports/members-time-reports.tsx @@ -4,12 +4,18 @@ import MembersTimeSheet, { MembersTimeSheetRef } from '@/pages/reporting/time-re import TimeReportingRightHeader from './timeReportingRightHeader/TimeReportingRightHeader'; import { useTranslation } from 'react-i18next'; import { useDocumentTitle } from '@/hooks/useDoumentTItle'; -import { useRef } from 'react'; +import { useRef, useState } from 'react'; +import TotalTimeUtilization from './total-time-utilization/total-time-utilization'; +import { IRPTTimeTotals } from '@/types/reporting/reporting.types'; const MembersTimeReports = () => { const { t } = useTranslation('time-report'); const chartRef = useRef(null); - + const [totals, setTotals] = useState({ + total_time_logs: "0", + total_estimated_hours: "0", + total_utilization: "0", + }); useDocumentTitle('Reporting - Allocation'); const handleExport = (type: string) => { @@ -18,6 +24,10 @@ const MembersTimeReports = () => { } }; + const handleTotalsUpdate = (newTotals: IRPTTimeTotals) => { + setTotals(newTotals); + }; + return ( { exportType={[{ key: 'png', label: 'PNG' }]} export={handleExport} /> - + { }, }} > - + ); diff --git a/worklenz-frontend/src/pages/reporting/timeReports/total-time-utilization/total-time-utilization.tsx b/worklenz-frontend/src/pages/reporting/timeReports/total-time-utilization/total-time-utilization.tsx new file mode 100644 index 000000000..ce760d4c9 --- /dev/null +++ b/worklenz-frontend/src/pages/reporting/timeReports/total-time-utilization/total-time-utilization.tsx @@ -0,0 +1,34 @@ +import { Card, Flex } from 'antd'; +import React, { useEffect } from 'react'; +import { useAppDispatch } from '@/hooks/useAppDispatch'; +import { IRPTTimeTotals } from '@/types/reporting/reporting.types'; + +interface TotalTimeUtilizationProps { + totals: IRPTTimeTotals; +} +const TotalTimeUtilization: React.FC = ({ totals }) => { + return ( + + +
+
Total Time Logs
+
{totals.total_time_logs}h
+
+
+ +
+
Estimated Hours
+
{totals.total_estimated_hours}h
+
+
+ +
+
Utilization (%)
+
{totals.total_utilization}%
+
+
+
+ ); +}; + +export default TotalTimeUtilization; \ No newline at end of file diff --git a/worklenz-frontend/src/types/reporting/reporting.types.ts b/worklenz-frontend/src/types/reporting/reporting.types.ts index 6ca743911..a0ff7bf7c 100644 --- a/worklenz-frontend/src/types/reporting/reporting.types.ts +++ b/worklenz-frontend/src/types/reporting/reporting.types.ts @@ -411,6 +411,15 @@ export interface IRPTTimeMember { over_under_utilized_hours?: string; utilization_state?: string; } +export interface IRPTTimeTotals { + total_estimated_hours?: string; + total_time_logs?: string; + total_utilization?: string; +} +export interface IRPTTimeMemberViewModel { + filteredRows?: IRPTTimeMember[]; + totals?: IRPTTimeTotals; +} export interface IMemberTaskStatGroupResonse { team_member_name: string; From 8d6c43c59ca85ab71d743da326836e720ff5b9ac Mon Sep 17 00:00:00 2001 From: shancds Date: Thu, 29 May 2025 17:57:16 +0530 Subject: [PATCH 064/105] fix(reporting): update total utilization calculation precision and remove debug log from member time sheets --- .../controllers/reporting/reporting-allocation-controller.ts | 4 ++-- .../time-reports/members-time-sheet/members-time-sheet.tsx | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/worklenz-backend/src/controllers/reporting/reporting-allocation-controller.ts b/worklenz-backend/src/controllers/reporting/reporting-allocation-controller.ts index 98feafd9c..962530f93 100644 --- a/worklenz-backend/src/controllers/reporting/reporting-allocation-controller.ts +++ b/worklenz-backend/src/controllers/reporting/reporting-allocation-controller.ts @@ -532,8 +532,8 @@ export default class ReportingAllocationController extends ReportingControllerBa const total_time_logs = filteredRows.reduce((sum, member) => sum + parseFloat(member.logged_time || '0'), 0); const total_estimated_hours = totalWorkingHours; const total_utilization = total_time_logs > 0 && totalWorkingSeconds > 0 - ? ((total_time_logs / totalWorkingSeconds) * 100).toFixed(2) - : '0.00'; + ? ((total_time_logs / totalWorkingSeconds) * 100).toFixed(1) + : '0'; return res.status(200).send(new ServerResponse(true, { filteredRows, diff --git a/worklenz-frontend/src/pages/reporting/time-reports/members-time-sheet/members-time-sheet.tsx b/worklenz-frontend/src/pages/reporting/time-reports/members-time-sheet/members-time-sheet.tsx index 4c5179255..e70218938 100644 --- a/worklenz-frontend/src/pages/reporting/time-reports/members-time-sheet/members-time-sheet.tsx +++ b/worklenz-frontend/src/pages/reporting/time-reports/members-time-sheet/members-time-sheet.tsx @@ -184,7 +184,6 @@ const MembersTimeSheet = forwardRef( }; const res = await reportingTimesheetApiService.getMemberTimeSheets(body, archived); - console.log('Members Time Sheet Data:', res.body.totals); if (res.done) { setJsonData(res.body.filteredRows || []); From b0fb0a275906092b19ed2a96ff91d35ce2a08f10 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Fri, 30 May 2025 09:48:47 +0530 Subject: [PATCH 065/105] refactor(auth): remove debug logging from authentication processes - Eliminated extensive console logging in the auth controller, deserialize, serialize, and passport strategies to streamline code and improve performance. - Simplified response handling in the auth controller by directly returning the AuthResponse object. - Updated session middleware to enhance clarity and maintainability by removing unnecessary debug functions and logs. --- .../src/controllers/auth-controller.ts | 39 +----- .../src/middlewares/session-middleware.ts | 124 +----------------- worklenz-backend/src/passport/deserialize.ts | 30 ----- .../passport-local-login.ts | 11 -- .../passport-local-signup.ts | 18 --- worklenz-backend/src/passport/serialize.ts | 11 +- worklenz-frontend/src/layouts/MainLayout.tsx | 17 +-- .../projects/projectView/project-view.tsx | 2 +- 8 files changed, 8 insertions(+), 244 deletions(-) diff --git a/worklenz-backend/src/controllers/auth-controller.ts b/worklenz-backend/src/controllers/auth-controller.ts index 9bcd66f36..7bca4d3f5 100644 --- a/worklenz-backend/src/controllers/auth-controller.ts +++ b/worklenz-backend/src/controllers/auth-controller.ts @@ -28,58 +28,21 @@ export default class AuthController extends WorklenzControllerBase { } public static verify(req: IWorkLenzRequest, res: IWorkLenzResponse) { - console.log("=== VERIFY DEBUG ==="); - console.log("req.user:", req.user); - console.log("req.isAuthenticated():", req.isAuthenticated()); - console.log("req.session.passport:", (req.session as any).passport); - console.log("req.session.id:", req.sessionID); - console.log("Full session object:", JSON.stringify(req.session, null, 2)); - console.log("req.query.strategy:", req.query.strategy); - - // Check if session exists in database - if (req.sessionID) { - db.query("SELECT sid, sess FROM pg_sessions WHERE sid = $1", [req.sessionID]) - .then(result => { - if (result.rows.length > 0) { - console.log("Session found in database:"); - console.log("Session ID:", result.rows[0].sid); - console.log("Session data:", JSON.stringify(result.rows[0].sess, null, 2)); - } else { - console.log("Session NOT FOUND in database for ID:", req.sessionID); - } - }) - .catch(err => { - console.log("Error checking session in database:", err); - }); - } - // Flash messages sent from passport-local-signup.ts and passport-local-login.ts const errors = req.flash()["error"] || []; const messages = req.flash()["success"] || []; - console.log("Flash errors:", errors); - console.log("Flash messages:", messages); - // If there are multiple messages, we will send one at a time. const auth_error = errors.length > 0 ? errors[0] : null; const message = messages.length > 0 ? messages[0] : null; const midTitle = req.query.strategy === "login" ? "Login Failed!" : "Signup Failed!"; const title = req.query.strategy ? midTitle : null; - - console.log("Title:", title); - console.log("Auth error:", auth_error); - console.log("Success message:", message); - console.log("Is authenticated:", req.isAuthenticated()); - console.log("Has user:", !!req.user); if (req.user) req.user.build_v = FileConstants.getRelease(); - const response = new AuthResponse(title, req.isAuthenticated(), req.user || null, auth_error, message); - console.log("Sending response:", response); - - return res.status(200).send(response); + return res.status(200).send(new AuthResponse(title, req.isAuthenticated(), req.user || null, auth_error, message)); } public static logout(req: IWorkLenzRequest, res: IWorkLenzResponse) { diff --git a/worklenz-backend/src/middlewares/session-middleware.ts b/worklenz-backend/src/middlewares/session-middleware.ts index ef48d20be..671d01eca 100644 --- a/worklenz-backend/src/middlewares/session-middleware.ts +++ b/worklenz-backend/src/middlewares/session-middleware.ts @@ -5,123 +5,6 @@ import { isProduction } from "../shared/utils"; // eslint-disable-next-line @typescript-eslint/no-var-requires const pgSession = require("connect-pg-simple")(session); -// Test database connection and pg_sessions table -async function testSessionStore() { - try { - console.log("=== SESSION STORE DEBUG ==="); - - // Test basic database connection - const testQuery = await db.query("SELECT NOW() as current_time"); - console.log("Database connection test:", testQuery.rows[0]); - - // Check if pg_sessions table exists - const tableCheck = await db.query(` - SELECT EXISTS ( - SELECT FROM information_schema.tables - WHERE table_name = 'pg_sessions' - ) as table_exists - `); - console.log("pg_sessions table exists:", tableCheck.rows[0].table_exists); - - if (tableCheck.rows[0].table_exists) { - // Check table structure - const structureQuery = await db.query(` - SELECT column_name, data_type - FROM information_schema.columns - WHERE table_name = 'pg_sessions' - ORDER BY ordinal_position - `); - console.log("pg_sessions table structure:", structureQuery.rows); - - // Check current sessions count - const countQuery = await db.query("SELECT COUNT(*) as session_count FROM pg_sessions"); - console.log("Current sessions in database:", countQuery.rows[0].session_count); - - // Check recent sessions - const recentQuery = await db.query(` - SELECT sid, expire - FROM pg_sessions - ORDER BY expire DESC - LIMIT 3 - `); - console.log("Recent sessions:", recentQuery.rows); - } else { - console.log("ERROR: pg_sessions table does not exist!"); - - // Try to create the table - console.log("Attempting to create pg_sessions table..."); - await db.query(` - CREATE TABLE IF NOT EXISTS pg_sessions ( - sid VARCHAR NOT NULL COLLATE "default", - sess JSON NOT NULL, - expire TIMESTAMP(6) NOT NULL - ) - WITH (OIDS=FALSE); - - ALTER TABLE pg_sessions ADD CONSTRAINT session_pkey PRIMARY KEY (sid) NOT DEFERRABLE INITIALLY IMMEDIATE; - CREATE INDEX IF NOT EXISTS IDX_session_expire ON pg_sessions (expire); - `); - console.log("pg_sessions table created successfully"); - } - - console.log("=== END SESSION STORE DEBUG ==="); - } catch (error) { - console.log("Session store test error:", error); - } -} - -// Run the test -testSessionStore(); - -const store = new pgSession({ - pool: db.pool, - tableName: "pg_sessions" -}); - -// Add store event listeners -store.on("connect", () => { - console.log("Session store connected to database"); -}); - -store.on("disconnect", () => { - console.log("Session store disconnected from database"); -}); - -// Override store methods to add debugging -const originalSet = store.set.bind(store); -const originalGet = store.get.bind(store); - -store.set = function(sid: string, session: any, callback: any) { - console.log(`=== SESSION SET ===`); - console.log(`Session ID: ${sid}`); - console.log(`Session data:`, JSON.stringify(session, null, 2)); - - return originalSet(sid, session, (err: any) => { - if (err) { - console.log(`Session SET ERROR for ${sid}:`, err); - } else { - console.log(`Session SET SUCCESS for ${sid}`); - } - callback && callback(err); - }); -}; - -store.get = function(sid: string, callback: any) { - console.log(`=== SESSION GET ===`); - console.log(`Requesting session ID: ${sid}`); - - return originalGet(sid, (err: any, session: any) => { - if (err) { - console.log(`Session GET ERROR for ${sid}:`, err); - } else if (session) { - console.log(`Session GET SUCCESS for ${sid}:`, JSON.stringify(session, null, 2)); - } else { - console.log(`Session GET: No session found for ${sid}`); - } - callback(err, session); - }); -}; - export default session({ name: process.env.SESSION_NAME, secret: process.env.SESSION_SECRET || "development-secret-key", @@ -129,13 +12,14 @@ export default session({ resave: true, saveUninitialized: false, rolling: true, - store, + store: new pgSession({ + pool: db.pool, + tableName: "pg_sessions" + }), cookie: { path: "/", httpOnly: true, secure: false, - // sameSite: "none", - // domain: isProduction() ? ".worklenz.com" : undefined, maxAge: 30 * 24 * 60 * 60 * 1000 // 30 days } }); \ No newline at end of file diff --git a/worklenz-backend/src/passport/deserialize.ts b/worklenz-backend/src/passport/deserialize.ts index 450174ce8..56f396215 100644 --- a/worklenz-backend/src/passport/deserialize.ts +++ b/worklenz-backend/src/passport/deserialize.ts @@ -22,42 +22,18 @@ async function clearEmailInvitations(email: string, teamId: string) { // Check whether the user still exists on the database export async function deserialize(user: { id: string | null }, done: IDeserializeCallback) { try { - console.log("=== DESERIALIZE DEBUG ==="); - console.log("User object:", user); - if (!user || !user.id) { - console.log("No user or user.id, returning null"); return done(null, null); } const {id} = user; - console.log("Deserializing user ID:", id); - - // First check if user exists in users table - const userCheck = await db.query("SELECT id, active_team FROM users WHERE id = $1", [id]); - console.log("User exists check:", userCheck.rowCount, userCheck.rows[0]); - - if (!userCheck.rowCount) { - console.log("User not found in users table"); - return done(null, null); - } - const excludedSubscriptionTypes = ["TRIAL", "PADDLE"]; const q = `SELECT deserialize_user($1) AS user;`; - console.log("Calling deserialize_user with ID:", id); - const result = await db.query(q, [id]); - console.log("Database query result rows length:", result.rows.length); - console.log("Raw database result:", result.rows); - if (result.rows.length) { const [data] = result.rows; - console.log("Database result data:", data); - if (data?.user) { - console.log("User data found:", data.user); - const realExpiredDate = moment(data.user.valid_till_date).add(7, "days"); data.user.is_expired = false; @@ -67,17 +43,11 @@ export async function deserialize(user: { id: string | null }, done: IDeserializ void setLastActive(data.user.id); void clearEmailInvitations(data.user.email, data.user.team_id); - console.log("Returning successful user:", data.user); return done(null, data.user as IPassportSession); } - console.log("No user data in result - deserialize_user returned null"); } - console.log("No rows returned from database"); - - console.log("Returning null user"); return done(null, null); } catch (error) { - console.log("Deserialize error:", error); return done(error, null); } } diff --git a/worklenz-backend/src/passport/passport-strategies/passport-local-login.ts b/worklenz-backend/src/passport/passport-strategies/passport-local-login.ts index 259e31428..2f906e992 100644 --- a/worklenz-backend/src/passport/passport-strategies/passport-local-login.ts +++ b/worklenz-backend/src/passport/passport-strategies/passport-local-login.ts @@ -5,10 +5,7 @@ import db from "../../config/db"; import { Request } from "express"; async function handleLogin(req: Request, email: string, password: string, done: any) { - console.log("Login attempt for:", email); - if (!email || !password) { - console.log("Missing credentials"); return done(null, false, { message: "Please enter both email and password" }); } @@ -19,29 +16,21 @@ async function handleLogin(req: Request, email: string, password: string, done: AND google_id IS NULL AND is_deleted IS FALSE;`; const result = await db.query(q, [email]); - console.log("User query result count:", result.rowCount); const [data] = result.rows; - console.log("data", data); if (!data?.password) { - console.log("No account found"); return done(null, false, { message: "No account found with this email" }); } const passwordMatch = bcrypt.compareSync(password, data.password); - console.log("Password match:", passwordMatch); if (passwordMatch && email === data.email) { delete data.password; - console.log("=== LOGIN SUCCESS DEBUG ==="); - console.log("About to call done with user:", data); - console.log("User structure:", JSON.stringify(data, null, 2)); return done(null, data, {message: "User successfully logged in"}); } return done(null, false, { message: "Incorrect email or password" }); } catch (error) { - console.error("Login error:", error); log_error(error, req.body); return done(error); } diff --git a/worklenz-backend/src/passport/passport-strategies/passport-local-signup.ts b/worklenz-backend/src/passport/passport-strategies/passport-local-signup.ts index 4e83706cb..0c5cf39af 100644 --- a/worklenz-backend/src/passport/passport-strategies/passport-local-signup.ts +++ b/worklenz-backend/src/passport/passport-strategies/passport-local-signup.ts @@ -37,15 +37,8 @@ async function registerUser(password: string, team_id: string, name: string, tea team_member_id, }; - console.log("=== REGISTER USER DEBUG ==="); - console.log("Calling register_user with body:", body); - const result = await db.query(q, [JSON.stringify(body)]); const [data] = result.rows; - - console.log("Register user result:", data); - console.log("User object returned:", data.user); - return data.user; } @@ -66,22 +59,11 @@ async function handleSignUp(req: Request, email: string, password: string, done: } try { - console.log("=== SIGNUP DEBUG ==="); - console.log("About to register user with data:", {name, team_name, email, timezone, team_member_id, team_id}); - const user = await registerUser(password, team_id, name, team_name, email, timezone, team_member_id); - - console.log("User registration successful, user object:", user); - sendWelcomeEmail(email, name); - - console.log("About to call done with user:", user); req.flash(SUCCESS_KEY, "Registration successful. Please check your email for verification."); return done(null, user, {message: "Registration successful. Please check your email for verification."}); } catch (error: any) { - console.log("=== SIGNUP ERROR ==="); - console.log("Error during signup:", error); - const message = (error?.message) || ""; if (message === "ERROR_INVALID_JOINING_EMAIL") { diff --git a/worklenz-backend/src/passport/serialize.ts b/worklenz-backend/src/passport/serialize.ts index 942c89a82..b3c603f67 100644 --- a/worklenz-backend/src/passport/serialize.ts +++ b/worklenz-backend/src/passport/serialize.ts @@ -3,14 +3,5 @@ import {IPassportSession} from "../interfaces/passport-session"; // Parse the user id to deserialize function export function serialize($user: IPassportSession, done: ISerializeCallback) { - console.log("=== SERIALIZE DEBUG ==="); - console.log("Serializing user:", $user); - console.log("User ID:", $user?.id); - - const serializedUser = { id: $user?.id ?? null }; - console.log("Serialized user object:", serializedUser); - - done(null, serializedUser); - - console.log("Serialize done callback completed"); + done(null, { id: $user?.id ?? null }); } diff --git a/worklenz-frontend/src/layouts/MainLayout.tsx b/worklenz-frontend/src/layouts/MainLayout.tsx index 5dbc5c3e8..514e6c69e 100644 --- a/worklenz-frontend/src/layouts/MainLayout.tsx +++ b/worklenz-frontend/src/layouts/MainLayout.tsx @@ -7,10 +7,7 @@ import { colors } from '../styles/colors'; import { verifyAuthentication } from '@/features/auth/authSlice'; import { useEffect, useState } from 'react'; import { useAppDispatch } from '@/hooks/useAppDispatch'; -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 themeMode = useAppSelector(state => state.themeReducer.mode); @@ -33,18 +30,6 @@ const MainLayout = () => { void verifyAuthStatus(); }, [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 = { @@ -59,7 +44,7 @@ const MainLayout = () => { } as const; const contentStyles = { - paddingInline: isDesktop ? 64 : 24, + paddingInline: isDesktop ? 0 : 24, overflowX: 'hidden', marginTop: alertHeight + 64, // Adjust top margin based on alert height + navbar height } as const; diff --git a/worklenz-frontend/src/pages/projects/projectView/project-view.tsx b/worklenz-frontend/src/pages/projects/projectView/project-view.tsx index d1ff8b9d6..d5d873472 100644 --- a/worklenz-frontend/src/pages/projects/projectView/project-view.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/project-view.tsx @@ -169,7 +169,7 @@ const ProjectView = () => { ), []); return ( -
+
Date: Fri, 30 May 2025 12:19:02 +0530 Subject: [PATCH 066/105] chore(dependencies): update rimraf version and clean up package-lock.json - Removed deprecated rimraf entries from package-lock.json to streamline dependencies. - Added an override for rimraf in package.json to ensure compatibility with version 6.0.1. - Refactored useTaskDragAndDrop and useTaskSocketHandlers hooks to use separate selectors for improved performance. - Made minor style adjustments in project-view-header and project-view components for consistency. --- worklenz-backend/package-lock.json | 77 ------------------- worklenz-backend/package.json | 3 + .../src/hooks/useTaskDragAndDrop.ts | 6 +- .../src/hooks/useTaskSocketHandlers.ts | 5 +- .../projectView/project-view-header.tsx | 2 +- .../projects/projectView/project-view.tsx | 9 +-- .../taskList/project-view-task-list.tsx | 43 ++++------- 7 files changed, 25 insertions(+), 120 deletions(-) diff --git a/worklenz-backend/package-lock.json b/worklenz-backend/package-lock.json index 138d01ff9..09dbb1c00 100644 --- a/worklenz-backend/package-lock.json +++ b/worklenz-backend/package-lock.json @@ -3934,23 +3934,6 @@ "node": ">=8" } }, - "node_modules/@jest/core/node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", - "dev": true, - "license": "ISC", - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/@jest/core/node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -4485,22 +4468,6 @@ "node": ">=10" } }, - "node_modules/@mapbox/node-pre-gyp/node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", - "license": "ISC", - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/@mapbox/node-pre-gyp/node_modules/semver": { "version": "7.5.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", @@ -9102,23 +9069,6 @@ "node": "^10.12.0 || >=12.0.0" } }, - "node_modules/flat-cache/node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", - "dev": true, - "license": "ISC", - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/flatted": { "version": "3.2.7", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.7.tgz", @@ -9300,17 +9250,6 @@ "mkdirp": "bin/cmd.js" } }, - "node_modules/fstream/node_modules/rimraf": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", - "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - } - }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -14983,22 +14922,6 @@ "node": ">=8.17.0" } }, - "node_modules/tmp/node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", - "license": "ISC", - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", diff --git a/worklenz-backend/package.json b/worklenz-backend/package.json index cffa800b1..8c689b39e 100644 --- a/worklenz-backend/package.json +++ b/worklenz-backend/package.json @@ -42,6 +42,9 @@ "reportFile": "test-reporter.xml", "indent": 4 }, + "overrides": { + "rimraf": "^6.0.1" + }, "dependencies": { "@aws-sdk/client-s3": "^3.378.0", "@aws-sdk/client-ses": "^3.378.0", diff --git a/worklenz-frontend/src/hooks/useTaskDragAndDrop.ts b/worklenz-frontend/src/hooks/useTaskDragAndDrop.ts index cab0a3614..56e410575 100644 --- a/worklenz-frontend/src/hooks/useTaskDragAndDrop.ts +++ b/worklenz-frontend/src/hooks/useTaskDragAndDrop.ts @@ -19,10 +19,8 @@ import { IProjectTask } from '@/types/project/projectTasksViewModel.types'; export const useTaskDragAndDrop = () => { const dispatch = useAppDispatch(); - const { taskGroups, groupBy } = useAppSelector(state => ({ - taskGroups: state.taskReducer.taskGroups, - groupBy: state.taskReducer.groupBy, - })); + const taskGroups = useAppSelector(state => state.taskReducer.taskGroups); + const groupBy = useAppSelector(state => state.taskReducer.groupBy); // Memoize sensors configuration for better performance const sensors = useSensors( diff --git a/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts b/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts index 7c85ead69..6cc17eed3 100644 --- a/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts +++ b/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts @@ -49,8 +49,9 @@ export const useTaskSocketHandlers = () => { const { socket } = useSocket(); const currentSession = useAuthService().getCurrentSession(); - const { loadingAssignees, taskGroups } = useAppSelector((state: any) => state.taskReducer); - const { projectId } = useAppSelector((state: any) => state.projectReducer); + const loadingAssignees = useAppSelector((state: any) => state.taskReducer.loadingAssignees); + const taskGroups = useAppSelector((state: any) => state.taskReducer.taskGroups); + const projectId = useAppSelector((state: any) => state.projectReducer.projectId); // Memoize socket event handlers const handleAssigneesUpdate = useCallback( diff --git a/worklenz-frontend/src/pages/projects/projectView/project-view-header.tsx b/worklenz-frontend/src/pages/projects/projectView/project-view-header.tsx index 385b50f16..bc82466d0 100644 --- a/worklenz-frontend/src/pages/projects/projectView/project-view-header.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/project-view-header.tsx @@ -306,7 +306,7 @@ const ProjectViewHeader = () => { {renderProjectAttributes()} } - style={{ paddingInline: 0, marginBlockEnd: 12 }} + style={{ paddingInline: 0}} extra={renderHeaderActions()} /> {createPortal( {}} />, document.body, 'project-drawer')} diff --git a/worklenz-frontend/src/pages/projects/projectView/project-view.tsx b/worklenz-frontend/src/pages/projects/projectView/project-view.tsx index d5d873472..5bc25a73f 100644 --- a/worklenz-frontend/src/pages/projects/projectView/project-view.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/project-view.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState, useMemo, useCallback } from 'react'; -import { PushpinFilled, PushpinOutlined, QuestionCircleOutlined } from '@ant-design/icons'; -import { Badge, Button, ConfigProvider, Flex, Tabs, TabsProps, Tooltip } from 'antd'; +import { PushpinFilled, PushpinOutlined } from '@ant-design/icons'; +import { Button, ConfigProvider, Flex, Tabs } from 'antd'; import { useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { createPortal } from 'react-dom'; @@ -117,7 +117,7 @@ const ProjectView = () => { const tabMenuItems = useMemo(() => tabItems.map(item => ({ key: item.key, label: ( - + {item.label} {item.key === 'tasks-list' || item.key === 'board' ? ( @@ -140,9 +140,6 @@ const ProjectView = () => { ) : ( ) } diff --git a/worklenz-frontend/src/pages/projects/projectView/taskList/project-view-task-list.tsx b/worklenz-frontend/src/pages/projects/projectView/taskList/project-view-task-list.tsx index 299147718..80a999d0b 100644 --- a/worklenz-frontend/src/pages/projects/projectView/taskList/project-view-task-list.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/taskList/project-view-task-list.tsx @@ -19,36 +19,19 @@ const ProjectViewTaskList = () => { const [searchParams, setSearchParams] = useSearchParams(); const [initialLoadComplete, setInitialLoadComplete] = useState(false); - // Combine related selectors to reduce subscriptions - const { - projectId, - taskGroups, - loadingGroups, - groupBy, - archived, - fields, - search, - } = useAppSelector(state => ({ - projectId: state.projectReducer.projectId, - taskGroups: state.taskReducer.taskGroups, - loadingGroups: state.taskReducer.loadingGroups, - groupBy: state.taskReducer.groupBy, - archived: state.taskReducer.archived, - fields: state.taskReducer.fields, - search: state.taskReducer.search, - })); - - const { - statusCategories, - loading: loadingStatusCategories, - } = useAppSelector(state => ({ - statusCategories: state.taskStatusReducer.statusCategories, - loading: state.taskStatusReducer.loading, - })); - - const { loadingPhases } = useAppSelector(state => ({ - loadingPhases: state.phaseReducer.loadingPhases, - })); + // Use separate selectors to avoid creating new objects + const projectId = useAppSelector(state => state.projectReducer.projectId); + const taskGroups = useAppSelector(state => state.taskReducer.taskGroups); + const loadingGroups = useAppSelector(state => state.taskReducer.loadingGroups); + const groupBy = useAppSelector(state => state.taskReducer.groupBy); + const archived = useAppSelector(state => state.taskReducer.archived); + const fields = useAppSelector(state => state.taskReducer.fields); + const search = useAppSelector(state => state.taskReducer.search); + + const statusCategories = useAppSelector(state => state.taskStatusReducer.statusCategories); + const loadingStatusCategories = useAppSelector(state => state.taskStatusReducer.loading); + + const loadingPhases = useAppSelector(state => state.phaseReducer.loadingPhases); // Single source of truth for loading state - EXCLUDE labels loading from skeleton // Labels loading should not block the main task list display From 43c6701d3aa635064249fc507289a26cd80ac1ed Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Fri, 30 May 2025 12:42:43 +0530 Subject: [PATCH 067/105] feat(finance): enhance project finance view with export functionality and UI improvements - Implemented export functionality for finance data, allowing users to download project finance reports in Excel format. - Refactored project finance header to streamline UI components and improve user experience. - Removed deprecated FinanceTab and GroupByFilterDropdown components, consolidating functionality into the main finance view. - Updated the project finance view to utilize new components for better organization and interaction. - Enhanced group selection for finance data, allowing users to filter by status, priority, or phases. --- .../controllers/project-finance-controller.ts | 285 ++++++++++++------ .../finance/finance-tab/finance-tab.tsx | 23 -- .../group-by-filter-dropdown.tsx | 56 ---- .../project-view-finance-header.tsx | 137 --------- .../finance/project-view-finance.tsx | 156 ++++++++-- .../finance/ratecard-tab/ratecard-tab.tsx | 38 --- 6 files changed, 326 insertions(+), 369 deletions(-) delete mode 100644 worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-tab.tsx delete mode 100644 worklenz-frontend/src/pages/projects/projectView/finance/project-view-finance-header/group-by-filter-dropdown.tsx delete mode 100644 worklenz-frontend/src/pages/projects/projectView/finance/project-view-finance-header/project-view-finance-header.tsx delete mode 100644 worklenz-frontend/src/pages/projects/projectView/finance/ratecard-tab/ratecard-tab.tsx diff --git a/worklenz-backend/src/controllers/project-finance-controller.ts b/worklenz-backend/src/controllers/project-finance-controller.ts index 01d729608..cb82c4323 100644 --- a/worklenz-backend/src/controllers/project-finance-controller.ts +++ b/worklenz-backend/src/controllers/project-finance-controller.ts @@ -8,38 +8,37 @@ import HandleExceptions from "../decorators/handle-exceptions"; import { TASK_STATUS_COLOR_ALPHA } from "../shared/constants"; import { getColor } from "../shared/utils"; import moment from "moment"; - -const Excel = require("exceljs"); +import Excel from "exceljs"; // Utility function to format time in hours, minutes, seconds format const formatTimeToHMS = (totalSeconds: number): string => { if (!totalSeconds || totalSeconds === 0) return "0s"; - + const hours = Math.floor(totalSeconds / 3600); const minutes = Math.floor((totalSeconds % 3600) / 60); const seconds = totalSeconds % 60; - + const parts = []; if (hours > 0) parts.push(`${hours}h`); if (minutes > 0) parts.push(`${minutes}m`); if (seconds > 0 || parts.length === 0) parts.push(`${seconds}s`); - - return parts.join(' '); + + return parts.join(" "); }; // Utility function to parse time string back to seconds for calculations const parseTimeToSeconds = (timeString: string): number => { if (!timeString || timeString === "0s") return 0; - + let totalSeconds = 0; const hourMatch = timeString.match(/(\d+)h/); const minuteMatch = timeString.match(/(\d+)m/); const secondMatch = timeString.match(/(\d+)s/); - + if (hourMatch) totalSeconds += parseInt(hourMatch[1]) * 3600; if (minuteMatch) totalSeconds += parseInt(minuteMatch[1]) * 60; if (secondMatch) totalSeconds += parseInt(secondMatch[1]); - + return totalSeconds; }; @@ -50,7 +49,7 @@ export default class ProjectfinanceController extends WorklenzControllerBase { res: IWorkLenzResponse ): Promise { const projectId = req.params.project_id; - const groupBy = req.query.group || "status"; + const groupBy = req.query.group_by || "status"; // First, get the project rate cards for this project const rateCardQuery = ` @@ -65,7 +64,7 @@ export default class ProjectfinanceController extends WorklenzControllerBase { WHERE fprr.project_id = $1 ORDER BY jt.name; `; - + const rateCardResult = await db.query(rateCardQuery, [projectId]); const projectRateCards = rateCardResult.rows; @@ -205,7 +204,7 @@ export default class ProjectfinanceController extends WorklenzControllerBase { if (Array.isArray(task.assignees)) { for (const assignee of task.assignees) { assignee.color_code = getColor(assignee.name); - + // Get the rate for this assignee using project_members.project_rate_card_role_id const memberRateQuery = ` SELECT @@ -218,12 +217,16 @@ export default class ProjectfinanceController extends WorklenzControllerBase { 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]); + 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.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; @@ -235,7 +238,10 @@ export default class ProjectfinanceController extends WorklenzControllerBase { assignee.job_title_name = null; } } catch (error) { - console.error("Error fetching member rate from project_members:", 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; @@ -246,8 +252,13 @@ export default class ProjectfinanceController extends WorklenzControllerBase { } // Get groups based on groupBy parameter - let groups: Array<{ id: string; group_name: string; color_code: string; color_code_dark: string }> = []; - + let groups: Array<{ + id: string; + group_name: string; + color_code: string; + color_code_dark: string; + }> = []; + if (groupBy === "status") { const q = ` SELECT @@ -284,7 +295,7 @@ export default class ProjectfinanceController extends WorklenzControllerBase { ORDER BY sort_index; `; groups = (await db.query(q, [projectId])).rows; - + // Add TASK_STATUS_COLOR_ALPHA to color codes for (const group of groups) { group.color_code = group.color_code + TASK_STATUS_COLOR_ALPHA; @@ -293,8 +304,8 @@ export default class ProjectfinanceController extends WorklenzControllerBase { } // Group tasks by the selected criteria - const groupedTasks = groups.map(group => { - const groupTasks = tasks.filter(task => { + const groupedTasks = groups.map((group) => { + const groupTasks = tasks.filter((task) => { if (groupBy === "status") return task.status_id === group.id; if (groupBy === "priority") return task.priority_id === group.id; if (groupBy === "phases") return task.phase_id === group.id; @@ -306,13 +317,16 @@ export default class ProjectfinanceController extends WorklenzControllerBase { group_name: group.group_name, color_code: group.color_code, color_code_dark: group.color_code_dark, - tasks: groupTasks.map(task => ({ + tasks: groupTasks.map((task) => ({ id: task.id, name: task.name, estimated_seconds: Number(task.estimated_seconds) || 0, estimated_hours: formatTimeToHMS(Number(task.estimated_seconds) || 0), - total_time_logged_seconds: Number(task.total_time_logged_seconds) || 0, - total_time_logged: formatTimeToHMS(Number(task.total_time_logged_seconds) || 0), + total_time_logged_seconds: + Number(task.total_time_logged_seconds) || 0, + total_time_logged: formatTimeToHMS( + Number(task.total_time_logged_seconds) || 0 + ), estimated_cost: Number(task.estimated_cost) || 0, fixed_cost: Number(task.fixed_cost) || 0, total_budget: Number(task.total_budget) || 0, @@ -320,15 +334,15 @@ export default class ProjectfinanceController extends WorklenzControllerBase { variance: Number(task.variance) || 0, members: task.assignees, billable: task.billable, - sub_tasks_count: Number(task.sub_tasks_count) || 0 - })) + sub_tasks_count: Number(task.sub_tasks_count) || 0, + })), }; }); // Include project rate cards in the response for reference const responseData = { groups: groupedTasks, - project_rate_cards: projectRateCards + project_rate_cards: projectRateCards, }; return res.status(200).send(new ServerResponse(true, responseData)); @@ -343,7 +357,9 @@ export default class ProjectfinanceController extends WorklenzControllerBase { 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")); + return res + .status(400) + .send(new ServerResponse(false, null, "Invalid fixed cost value")); } const q = ` @@ -354,9 +370,11 @@ export default class ProjectfinanceController extends WorklenzControllerBase { `; 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(404) + .send(new ServerResponse(false, null, "Task not found")); } return res.status(200).send(new ServerResponse(true, result.rows[0])); @@ -385,9 +403,11 @@ export default class ProjectfinanceController extends WorklenzControllerBase { `; const taskResult = await db.query(taskQuery, [taskId]); - + if (taskResult.rows.length === 0) { - return res.status(404).send(new ServerResponse(false, null, "Task not found")); + return res + .status(404) + .send(new ServerResponse(false, null, "Task not found")); } const [task] = taskResult.rows; @@ -412,12 +432,15 @@ export default class ProjectfinanceController extends WorklenzControllerBase { 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]); + 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 @@ -426,20 +449,31 @@ export default class ProjectfinanceController extends WorklenzControllerBase { 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({ + + 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, + 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) + 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) { @@ -450,17 +484,17 @@ export default class ProjectfinanceController extends WorklenzControllerBase { // Group members by job title and calculate totals const groupedMembers = membersWithRates.reduce((acc: any, member: any) => { - const jobRole = member.job_title_name || "Unassigned"; - + 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] = { + jobRole, + estimated_hours: 0, + logged_hours: 0, + estimated_cost: 0, + actual_cost: 0, + members: [], + }; } acc[jobRole].estimated_hours += member.estimated_hours; @@ -475,7 +509,7 @@ export default class ProjectfinanceController extends WorklenzControllerBase { estimated_hours: member.estimated_hours, logged_hours: member.logged_hours, estimated_cost: member.estimated_cost, - actual_cost: member.actual_cost + actual_cost: member.actual_cost, }); return acc; @@ -485,11 +519,23 @@ export default class ProjectfinanceController extends WorklenzControllerBase { 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), + 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) + 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 = { @@ -498,10 +544,10 @@ export default class ProjectfinanceController extends WorklenzControllerBase { name: task.name, project_id: task.project_id, billable: task.billable, - ...taskTotals + ...taskTotals, }, grouped_members: Object.values(groupedMembers), - members: membersWithRates + members: membersWithRates, }; return res.status(200).send(new ServerResponse(true, responseData)); @@ -516,7 +562,9 @@ export default class ProjectfinanceController extends WorklenzControllerBase { const parentTaskId = req.params.parent_task_id; if (!parentTaskId) { - return res.status(400).send(new ServerResponse(false, null, "Parent task ID is required")); + return res + .status(400) + .send(new ServerResponse(false, null, "Parent task ID is required")); } // Get subtasks with their financial data @@ -581,7 +629,7 @@ export default class ProjectfinanceController extends WorklenzControllerBase { if (Array.isArray(task.assignees)) { for (const assignee of task.assignees) { assignee.color_code = getColor(assignee.name); - + // Get the rate for this assignee const memberRateQuery = ` SELECT @@ -594,12 +642,16 @@ export default class ProjectfinanceController extends WorklenzControllerBase { 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]); + 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.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; @@ -621,13 +673,15 @@ export default class ProjectfinanceController extends WorklenzControllerBase { } // Format the response to match the expected structure - const formattedTasks = tasks.map(task => ({ + const formattedTasks = tasks.map((task) => ({ id: task.id, name: task.name, estimated_seconds: Number(task.estimated_seconds) || 0, estimated_hours: formatTimeToHMS(Number(task.estimated_seconds) || 0), total_time_logged_seconds: Number(task.total_time_logged_seconds) || 0, - total_time_logged: formatTimeToHMS(Number(task.total_time_logged_seconds) || 0), + total_time_logged: formatTimeToHMS( + Number(task.total_time_logged_seconds) || 0 + ), estimated_cost: Number(task.estimated_cost) || 0, fixed_cost: Number(task.fixed_cost) || 0, total_budget: Number(task.total_budget) || 0, @@ -635,7 +689,7 @@ export default class ProjectfinanceController extends WorklenzControllerBase { variance: Number(task.variance) || 0, members: task.assignees, billable: task.billable, - sub_tasks_count: Number(task.sub_tasks_count) || 0 + sub_tasks_count: Number(task.sub_tasks_count) || 0, })); return res.status(200).send(new ServerResponse(true, formattedTasks)); @@ -647,12 +701,12 @@ export default class ProjectfinanceController extends WorklenzControllerBase { res: IWorkLenzResponse ): Promise { const projectId = req.params.project_id; - const groupBy = (req.query.groupBy as string) || 'status'; + const groupBy = (req.query.groupBy as string) || "status"; // Get project name for filename const projectNameQuery = `SELECT name FROM projects WHERE id = $1`; const projectNameResult = await db.query(projectNameQuery, [projectId]); - const projectName = projectNameResult.rows[0]?.name || 'Unknown Project'; + const projectName = projectNameResult.rows[0]?.name || "Unknown Project"; // First, get the project rate cards for this project const rateCardQuery = ` @@ -667,7 +721,7 @@ export default class ProjectfinanceController extends WorklenzControllerBase { WHERE fprr.project_id = $1 ORDER BY jt.name; `; - + const rateCardResult = await db.query(rateCardQuery, [projectId]); const projectRateCards = rateCardResult.rows; @@ -807,7 +861,7 @@ export default class ProjectfinanceController extends WorklenzControllerBase { if (Array.isArray(task.assignees)) { for (const assignee of task.assignees) { assignee.color_code = getColor(assignee.name); - + // Get the rate for this assignee using project_members.project_rate_card_role_id const memberRateQuery = ` SELECT @@ -820,12 +874,16 @@ export default class ProjectfinanceController extends WorklenzControllerBase { 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]); + 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.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; @@ -837,7 +895,10 @@ export default class ProjectfinanceController extends WorklenzControllerBase { assignee.job_title_name = null; } } catch (error) { - console.error("Error fetching member rate from project_members:", 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; @@ -848,8 +909,13 @@ export default class ProjectfinanceController extends WorklenzControllerBase { } // Get groups based on groupBy parameter - let groups: Array<{ id: string; group_name: string; color_code: string; color_code_dark: string }> = []; - + let groups: Array<{ + id: string; + group_name: string; + color_code: string; + color_code_dark: string; + }> = []; + if (groupBy === "status") { const q = ` SELECT @@ -886,7 +952,7 @@ export default class ProjectfinanceController extends WorklenzControllerBase { ORDER BY sort_index; `; groups = (await db.query(q, [projectId])).rows; - + // Add TASK_STATUS_COLOR_ALPHA to color codes for (const group of groups) { group.color_code = group.color_code + TASK_STATUS_COLOR_ALPHA; @@ -895,8 +961,8 @@ export default class ProjectfinanceController extends WorklenzControllerBase { } // Group tasks by the selected criteria - const groupedTasks = groups.map(group => { - const groupTasks = tasks.filter(task => { + const groupedTasks = groups.map((group) => { + const groupTasks = tasks.filter((task) => { if (groupBy === "status") return task.status_id === group.id; if (groupBy === "priority") return task.priority_id === group.id; if (groupBy === "phases") return task.phase_id === group.id; @@ -908,13 +974,16 @@ export default class ProjectfinanceController extends WorklenzControllerBase { group_name: group.group_name, color_code: group.color_code, color_code_dark: group.color_code_dark, - tasks: groupTasks.map(task => ({ + tasks: groupTasks.map((task) => ({ id: task.id, name: task.name, estimated_seconds: Number(task.estimated_seconds) || 0, estimated_hours: formatTimeToHMS(Number(task.estimated_seconds) || 0), - total_time_logged_seconds: Number(task.total_time_logged_seconds) || 0, - total_time_logged: formatTimeToHMS(Number(task.total_time_logged_seconds) || 0), + total_time_logged_seconds: + Number(task.total_time_logged_seconds) || 0, + total_time_logged: formatTimeToHMS( + Number(task.total_time_logged_seconds) || 0 + ), estimated_cost: Number(task.estimated_cost) || 0, fixed_cost: Number(task.fixed_cost) || 0, total_budget: Number(task.total_budget) || 0, @@ -922,15 +991,15 @@ export default class ProjectfinanceController extends WorklenzControllerBase { variance: Number(task.variance) || 0, members: task.assignees, billable: task.billable, - sub_tasks_count: Number(task.sub_tasks_count) || 0 - })) + sub_tasks_count: Number(task.sub_tasks_count) || 0, + })), }; }); // Include project rate cards in the response for reference const responseData = { groups: groupedTasks, - project_rate_cards: projectRateCards + project_rate_cards: projectRateCards, }; // Create Excel workbook and worksheet @@ -950,21 +1019,38 @@ export default class ProjectfinanceController extends WorklenzControllerBase { { header: "Variance", key: "variance", width: 15 }, { header: "Members", key: "members", width: 30 }, { header: "Billable", key: "billable", width: 10 }, - { header: "Sub Tasks Count", key: "sub_tasks_count", width: 15 } + { header: "Sub Tasks Count", key: "sub_tasks_count", width: 15 }, ]; // Add title row - worksheet.getCell("A1").value = `Finance Data Export - ${projectName} - ${moment().format("MMM DD, YYYY")}`; + worksheet.getCell( + "A1" + ).value = `Finance Data Export - ${projectName} - ${moment().format( + "MMM DD, YYYY" + )}`; worksheet.mergeCells("A1:L1"); worksheet.getCell("A1").alignment = { horizontal: "center" }; - worksheet.getCell("A1").style.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "D9D9D9" } }; + worksheet.getCell("A1").style.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "D9D9D9" }, + }; worksheet.getCell("A1").font = { size: 16, bold: true }; // Add headers on row 3 worksheet.getRow(3).values = [ - "Task Name", "Group", "Estimated Hours", "Total Time Logged", - "Estimated Cost", "Fixed Cost", "Total Budget", "Total Actual", - "Variance", "Members", "Billable", "Sub Tasks Count" + "Task Name", + "Group", + "Estimated Hours", + "Total Time Logged", + "Estimated Cost", + "Fixed Cost", + "Total Budget", + "Total Actual", + "Variance", + "Members", + "Billable", + "Sub Tasks Count", ]; worksheet.getRow(3).font = { bold: true }; @@ -984,7 +1070,7 @@ export default class ProjectfinanceController extends WorklenzControllerBase { variance: task.variance.toFixed(2), members: task.members.map((m: any) => m.name).join(", "), billable: task.billable ? "Yes" : "No", - sub_tasks_count: task.sub_tasks_count + sub_tasks_count: task.sub_tasks_count, }); currentRow++; } @@ -994,13 +1080,18 @@ export default class ProjectfinanceController extends WorklenzControllerBase { const buffer = await workbook.xlsx.writeBuffer(); // Create filename with project name, date and time - const sanitizedProjectName = projectName.replace(/[^a-zA-Z0-9\s]/g, '').replace(/\s+/g, '_'); - const dateTime = moment().format('YYYY-MM-DD_HH-mm-ss'); + const sanitizedProjectName = projectName + .replace(/[^a-zA-Z0-9\s]/g, "") + .replace(/\s+/g, "_"); + const dateTime = moment().format("YYYY-MM-DD_HH-mm-ss"); const filename = `${sanitizedProjectName}_Finance_Data_${dateTime}.xlsx`; // Set the response headers for the Excel file res.setHeader("Content-Disposition", `attachment; filename=${filename}`); - res.setHeader("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + res.setHeader( + "Content-Type", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + ); // Send the Excel file as a response res.end(buffer); 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 deleted file mode 100644 index d57a50aa6..000000000 --- a/worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-tab.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import FinanceTableWrapper from './finance-table/finance-table-wrapper'; -import { IProjectFinanceGroup } from '@/types/project/project-finance.types'; - -interface FinanceTabProps { - groupType: 'status' | 'priority' | 'phases'; - taskGroups: IProjectFinanceGroup[]; - loading: boolean; -} - -const FinanceTab = ({ - groupType, - taskGroups = [], - loading -}: FinanceTabProps) => { - - return ( -
- -
- ); -}; - -export default FinanceTab; 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 deleted file mode 100644 index fad9365d6..000000000 --- a/worklenz-frontend/src/pages/projects/projectView/finance/project-view-finance-header/group-by-filter-dropdown.tsx +++ /dev/null @@ -1,56 +0,0 @@ -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 index 5d2fb08f5..ad296f7d2 100644 --- a/worklenz-frontend/src/pages/projects/projectView/finance/project-view-finance.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/finance/project-view-finance.tsx @@ -1,20 +1,27 @@ -import { Flex } from 'antd'; -import { useEffect } from 'react'; +import { Button, ConfigProvider, Flex, Select, Typography, message } from 'antd'; +import { useEffect, useState } from 'react'; import { useParams } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { CaretDownFilled, DownOutlined } from '@ant-design/icons'; import { useAppDispatch } from '@/hooks/useAppDispatch'; import { useAppSelector } from '@/hooks/useAppSelector'; -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 { fetchProjectFinances, setActiveTab, setActiveGroup } from '@/features/projects/finance/project-finance.slice'; +import { changeCurrency, toggleImportRatecardsDrawer } from '@/features/finance/finance-slice'; +import { projectFinanceApiService } from '@/api/project-finance-ratecard/project-finance.api.service'; import { RootState } from '@/app/store'; +import FinanceTableWrapper from './finance-tab/finance-table/finance-table-wrapper'; +import RatecardTable from './ratecard-tab/reatecard-table/ratecard-table'; +import ImportRatecardsDrawer from '@/features/finance/ratecard-drawer/import-ratecards-drawer'; const ProjectViewFinance = () => { const { projectId } = useParams<{ projectId: string }>(); const dispatch = useAppDispatch(); + const { t } = useTranslation('project-view-finance'); + const [exporting, setExporting] = useState(false); const { activeTab, activeGroup, loading, taskGroups } = useAppSelector((state: RootState) => state.projectFinances); - const { refreshTimestamp } = useAppSelector((state: RootState) => state.projectReducer); + const { refreshTimestamp, project } = useAppSelector((state: RootState) => state.projectReducer); + const phaseList = useAppSelector((state) => state.phaseReducer.phaseList); useEffect(() => { if (projectId) { @@ -22,23 +29,136 @@ const ProjectViewFinance = () => { } }, [projectId, activeGroup, dispatch, refreshTimestamp]); + const handleExport = async () => { + if (!projectId) { + message.error('Project ID not found'); + return; + } + + try { + setExporting(true); + const blob = await projectFinanceApiService.exportFinanceData(projectId, activeGroup); + + const projectName = project?.name || 'Unknown_Project'; + const sanitizedProjectName = projectName.replace(/[^a-zA-Z0-9\s]/g, '').replace(/\s+/g, '_'); + const dateTime = new Date().toISOString().replace(/[:.]/g, '-').split('T'); + const date = dateTime[0]; + const time = dateTime[1].split('.')[0]; + const filename = `${sanitizedProjectName}_Finance_Data_${date}_${time}.xlsx`; + + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(url); + + message.success('Finance data exported successfully'); + } catch (error) { + console.error('Export failed:', error); + message.error('Failed to export finance data'); + } finally { + setExporting(false); + } + }; + + const groupDropdownMenuItems = [ + { key: 'status', value: 'status', label: t('statusText') }, + { key: 'priority', value: 'priority', label: t('priorityText') }, + { + key: 'phases', + value: 'phases', + label: phaseList.length > 0 ? project?.phase_label || t('phaseText') : t('phaseText'), + }, + ]; + return ( - dispatch(setActiveTab(tab))} - activeGroup={activeGroup} - setActiveGroup={(group) => dispatch(setActiveGroup(group))} - /> + {/* Finance Header */} + + + + + + + + + {activeTab === 'finance' && ( + + {t('groupByText')}: + dispatch(changeCurrency(value))} + /> + + + + )} + + + {/* Tab Content */} {activeTab === 'finance' ? ( - +
+ +
) : ( - + + + + {t('ratecardImportantNotice')} + + + )}
); 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 deleted file mode 100644 index 4ec0ee074..000000000 --- a/worklenz-frontend/src/pages/projects/projectView/finance/ratecard-tab/ratecard-tab.tsx +++ /dev/null @@ -1,38 +0,0 @@ -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; From fef50bdfb130656ceea8d2938b08225bfd6562ca Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Fri, 30 May 2025 13:28:47 +0530 Subject: [PATCH 068/105] feat(task-logging): enhance time log functionality with subtask handling and UI improvements - Implemented recursive task hierarchy in SQL query to support subtasks in time logging. - Updated time log export to include task names for better clarity. - Added tooltips to inform users when time logging and timer functionalities are disabled due to subtasks. - Enhanced UI components in the task drawer to reflect new time log features and improve user experience. - Introduced responsive design adjustments for better accessibility on mobile devices. --- .../controllers/task-work-log-controller.ts | 78 +++++---- .../locales/en/task-drawer/task-drawer.json | 16 +- .../locales/es/task-drawer/task-drawer.json | 16 +- .../locales/pt/task-drawer/task-drawer.json | 16 +- .../shared/time-log/task-drawer-time-log.tsx | 13 +- .../shared/time-log/time-log-form.tsx | 65 ++++--- .../shared/time-log/time-log-item.css | 35 ++++ .../shared/time-log/time-log-item.tsx | 158 ++++++++++++++---- .../components/task-drawer/task-drawer.tsx | 42 ++++- .../taskListCommon/task-timer/task-timer.tsx | 158 ++++++++++++++---- .../task-list-time-tracker-cell.tsx | 13 ++ .../src/types/tasks/task-log-view.types.ts | 2 + 12 files changed, 489 insertions(+), 123 deletions(-) diff --git a/worklenz-backend/src/controllers/task-work-log-controller.ts b/worklenz-backend/src/controllers/task-work-log-controller.ts index 13f69737e..9465f04d2 100644 --- a/worklenz-backend/src/controllers/task-work-log-controller.ts +++ b/worklenz-backend/src/controllers/task-work-log-controller.ts @@ -28,32 +28,50 @@ export default class TaskWorklogController extends WorklenzControllerBase { if (!id) return []; const q = ` - WITH time_logs AS ( - -- - SELECT id, - description, - time_spent, - created_at, - user_id, - logged_by_timer, - (SELECT name FROM users WHERE users.id = task_work_log.user_id) AS user_name, - (SELECT email FROM users WHERE users.id = task_work_log.user_id) AS user_email, - (SELECT avatar_url FROM users WHERE users.id = task_work_log.user_id) AS avatar_url - FROM task_work_log - WHERE task_id = $1 - -- + WITH RECURSIVE task_hierarchy AS ( + -- Base case: Start with the given task + SELECT id, name, 0 as level + FROM tasks + WHERE id = $1 + + UNION ALL + + -- Recursive case: Get all subtasks + SELECT t.id, t.name, th.level + 1 + FROM tasks t + INNER JOIN task_hierarchy th ON t.parent_task_id = th.id + WHERE t.archived IS FALSE + ), + time_logs AS ( + SELECT + twl.id, + twl.description, + twl.time_spent, + twl.created_at, + twl.user_id, + twl.logged_by_timer, + twl.task_id, + th.name AS task_name, + (SELECT name FROM users WHERE users.id = twl.user_id) AS user_name, + (SELECT email FROM users WHERE users.id = twl.user_id) AS user_email, + (SELECT avatar_url FROM users WHERE users.id = twl.user_id) AS avatar_url + FROM task_work_log twl + INNER JOIN task_hierarchy th ON twl.task_id = th.id ) - SELECT id, - time_spent, - description, - created_at, - user_id, - logged_by_timer, - created_at AS start_time, - (created_at + INTERVAL '1 second' * time_spent) AS end_time, - user_name, - user_email, - avatar_url + SELECT + id, + time_spent, + description, + created_at, + user_id, + logged_by_timer, + task_id, + task_name, + created_at AS start_time, + (created_at + INTERVAL '1 second' * time_spent) AS end_time, + user_name, + user_email, + avatar_url FROM time_logs ORDER BY created_at DESC; `; @@ -143,6 +161,7 @@ export default class TaskWorklogController extends WorklenzControllerBase { }; sheet.columns = [ + {header: "Task Name", key: "task_name", width: 30}, {header: "Reporter Name", key: "user_name", width: 25}, {header: "Reporter Email", key: "user_email", width: 25}, {header: "Start Time", key: "start_time", width: 25}, @@ -153,14 +172,15 @@ export default class TaskWorklogController extends WorklenzControllerBase { ]; sheet.getCell("A1").value = metadata.project_name; - sheet.mergeCells("A1:G1"); + sheet.mergeCells("A1:H1"); sheet.getCell("A1").alignment = {horizontal: "center"}; sheet.getCell("A2").value = `${metadata.name} (${exportDate})`; - sheet.mergeCells("A2:G2"); + sheet.mergeCells("A2:H2"); sheet.getCell("A2").alignment = {horizontal: "center"}; sheet.getRow(4).values = [ + "Task Name", "Reporter Name", "Reporter Email", "Start Time", @@ -176,6 +196,7 @@ export default class TaskWorklogController extends WorklenzControllerBase { for (const item of results) { totalLogged += parseFloat((item.time_spent || 0).toString()); const data = { + task_name: item.task_name, user_name: item.user_name, user_email: item.user_email, start_time: moment(item.start_time).add(timezone.hours || 0, "hours").add(timezone.minutes || 0, "minutes").format(timeFormat), @@ -210,6 +231,7 @@ export default class TaskWorklogController extends WorklenzControllerBase { }; sheet.addRow({ + task_name: "", user_name: "", user_email: "", start_time: "Total", @@ -219,7 +241,7 @@ export default class TaskWorklogController extends WorklenzControllerBase { time_spent: formatDuration(moment.duration(totalLogged, "seconds")), }); - sheet.mergeCells(`A${sheet.rowCount}:F${sheet.rowCount}`); + sheet.mergeCells(`A${sheet.rowCount}:G${sheet.rowCount}`); sheet.getCell(`A${sheet.rowCount}`).value = "Total"; sheet.getCell(`A${sheet.rowCount}`).alignment = { diff --git a/worklenz-frontend/public/locales/en/task-drawer/task-drawer.json b/worklenz-frontend/public/locales/en/task-drawer/task-drawer.json index 771eaad22..12a69bb10 100644 --- a/worklenz-frontend/public/locales/en/task-drawer/task-drawer.json +++ b/worklenz-frontend/public/locales/en/task-drawer/task-drawer.json @@ -80,7 +80,21 @@ "addTimeLog": "Add new time log", "totalLogged": "Total Logged", "exportToExcel": "Export to Excel", - "noTimeLogsFound": "No time logs found" + "noTimeLogsFound": "No time logs found", + "timerDisabledTooltip": "Timer is disabled because this task has {{count}} subtasks. Time should be logged on individual subtasks.", + "timeLogDisabledTooltip": "Time logging is disabled because this task has {{count}} subtasks. Time should be logged on individual subtasks.", + "date": "Date", + "startTime": "Start Time", + "endTime": "End Time", + "workDescription": "Work Description", + "requiredFields": "Please fill in all required fields", + "dateRequired": "Please select a date", + "startTimeRequired": "Please select start time", + "endTimeRequired": "Please select end time", + "workDescriptionPlaceholder": "Add a description", + "cancel": "Cancel", + "logTime": "Log time", + "updateTime": "Update time" }, "taskActivityLogTab": { "title": "Activity Log" diff --git a/worklenz-frontend/public/locales/es/task-drawer/task-drawer.json b/worklenz-frontend/public/locales/es/task-drawer/task-drawer.json index 96ea63626..c4c830cee 100644 --- a/worklenz-frontend/public/locales/es/task-drawer/task-drawer.json +++ b/worklenz-frontend/public/locales/es/task-drawer/task-drawer.json @@ -80,7 +80,21 @@ "addTimeLog": "Añadir nuevo registro de tiempo", "totalLogged": "Total registrado", "exportToExcel": "Exportar a Excel", - "noTimeLogsFound": "No se encontraron registros de tiempo" + "noTimeLogsFound": "No se encontraron registros de tiempo", + "timerDisabledTooltip": "El temporizador está deshabilitado porque esta tarea tiene {{count}} subtareas. El tiempo debe registrarse en las subtareas individuales.", + "timeLogDisabledTooltip": "El registro de tiempo está deshabilitado porque esta tarea tiene {{count}} subtareas. El tiempo debe registrarse en las subtareas individuales.", + "date": "Fecha", + "startTime": "Hora de inicio", + "endTime": "Hora de finalización", + "workDescription": "Descripción del trabajo", + "requiredFields": "Por favor, complete todos los campos requeridos", + "dateRequired": "Por favor, seleccione una fecha", + "startTimeRequired": "Por favor, seleccione la hora de inicio", + "endTimeRequired": "Por favor, seleccione la hora de finalización", + "workDescriptionPlaceholder": "Añadir una descripción", + "cancel": "Cancelar", + "logTime": "Registrar tiempo", + "updateTime": "Actualizar tiempo" }, "taskActivityLogTab": { "title": "Registro de actividad" diff --git a/worklenz-frontend/public/locales/pt/task-drawer/task-drawer.json b/worklenz-frontend/public/locales/pt/task-drawer/task-drawer.json index 94862d03a..651ee0416 100644 --- a/worklenz-frontend/public/locales/pt/task-drawer/task-drawer.json +++ b/worklenz-frontend/public/locales/pt/task-drawer/task-drawer.json @@ -80,7 +80,21 @@ "addTimeLog": "Adicionar novo registro de tempo", "totalLogged": "Total registrado", "exportToExcel": "Exportar para Excel", - "noTimeLogsFound": "Nenhum registro de tempo encontrado" + "noTimeLogsFound": "Nenhum registro de tempo encontrado", + "timerDisabledTooltip": "O cronômetro está desabilitado porque esta tarefa tem {{count}} subtarefas. O tempo deve ser registrado nas subtarefas individuais.", + "timeLogDisabledTooltip": "O registro de tempo está desabilitado porque esta tarefa tem {{count}} subtarefas. O tempo deve ser registrado nas subtarefas individuais.", + "date": "Data", + "startTime": "Hora de início", + "endTime": "Hora de término", + "workDescription": "Descrição do trabalho", + "requiredFields": "Por favor, preencha todos os campos obrigatórios", + "dateRequired": "Por favor, selecione uma data", + "startTimeRequired": "Por favor, selecione a hora de início", + "endTimeRequired": "Por favor, selecione a hora de término", + "workDescriptionPlaceholder": "Adicionar uma descrição", + "cancel": "Cancelar", + "logTime": "Registrar tempo", + "updateTime": "Atualizar tempo" }, "taskActivityLogTab": { "title": "Registro de atividade" diff --git a/worklenz-frontend/src/components/task-drawer/shared/time-log/task-drawer-time-log.tsx b/worklenz-frontend/src/components/task-drawer/shared/time-log/task-drawer-time-log.tsx index 2191dbc71..f1d9dc9ff 100644 --- a/worklenz-frontend/src/components/task-drawer/shared/time-log/task-drawer-time-log.tsx +++ b/worklenz-frontend/src/components/task-drawer/shared/time-log/task-drawer-time-log.tsx @@ -25,8 +25,6 @@ const TaskDrawerTimeLog = ({ t, refreshTrigger = 0 }: TaskDrawerTimeLogProps) => const [totalTimeText, setTotalTimeText] = useState('0m 0s'); const [loading, setLoading] = useState(false); - const dispatch = useAppDispatch(); - const themeMode = useAppSelector(state => state.themeReducer.mode); const { selectedTaskId, taskFormViewModel, timeLogEditing } = useAppSelector( state => state.taskDrawerReducer ); @@ -36,6 +34,15 @@ const TaskDrawerTimeLog = ({ t, refreshTrigger = 0 }: TaskDrawerTimeLogProps) => taskFormViewModel?.task?.timer_start_time || null ); + // Check if task has subtasks + const hasSubTasks = (taskFormViewModel?.task?.sub_tasks_count || 0) > 0; + const timerDisabledTooltip = hasSubTasks + ? t('taskTimeLogTab.timerDisabledTooltip', { + count: taskFormViewModel?.task?.sub_tasks_count || 0, + defaultValue: `Timer is disabled because this task has ${taskFormViewModel?.task?.sub_tasks_count || 0} subtasks. Time should be logged on individual subtasks.` + }) + : ''; + const formatTimeComponents = (hours: number, minutes: number, seconds: number): string => { const parts = []; if (hours > 0) parts.push(`${hours}h`); @@ -131,6 +138,8 @@ const TaskDrawerTimeLog = ({ t, refreshTrigger = 0 }: TaskDrawerTimeLogProps) => handleStartTimer={handleStartTimer} handleStopTimer={handleTimerStop} timeString={timeString} + disabled={hasSubTasks} + disabledTooltip={timerDisabledTooltip} /> - + + + + diff --git a/worklenz-frontend/src/components/task-drawer/shared/time-log/time-log-item.css b/worklenz-frontend/src/components/task-drawer/shared/time-log/time-log-item.css index e69de29bb..a4d6b96f7 100644 --- a/worklenz-frontend/src/components/task-drawer/shared/time-log/time-log-item.css +++ b/worklenz-frontend/src/components/task-drawer/shared/time-log/time-log-item.css @@ -0,0 +1,35 @@ +.time-log-item .ant-card { + transition: all 0.2s ease; +} + +.time-log-item .ant-card:hover { + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + border-color: #d9d9d9; +} + +/* Dark mode hover effects */ +[data-theme='dark'] .time-log-item .ant-card:hover { + box-shadow: 0 2px 8px rgba(255, 255, 255, 0.15); + border-color: #434343; +} + +.time-log-item .ant-card .ant-card-body { + padding: 12px 16px; +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .time-log-item .ant-card { + margin-bottom: 6px; + } + + .time-log-item .ant-divider-vertical { + display: none; + } + + /* Stack time info vertically on mobile */ + .time-log-item .time-tracking-info { + flex-direction: column; + gap: 8px; + } +} diff --git a/worklenz-frontend/src/components/task-drawer/shared/time-log/time-log-item.tsx b/worklenz-frontend/src/components/task-drawer/shared/time-log/time-log-item.tsx index ff4964f35..d1bcfdafe 100644 --- a/worklenz-frontend/src/components/task-drawer/shared/time-log/time-log-item.tsx +++ b/worklenz-frontend/src/components/task-drawer/shared/time-log/time-log-item.tsx @@ -1,5 +1,6 @@ import React from 'react'; -import { Button, Divider, Flex, Popconfirm, Typography, Space } from 'antd'; +import { Button, Divider, Flex, Popconfirm, Typography, Space, Tag, Card } from 'antd'; +import { ClockCircleOutlined } from '@ant-design/icons'; import { colors } from '@/styles/colors'; import { ITaskLogViewModel } from '@/types/tasks/task-log-view.types'; import SingleAvatar from '@/components/common/single-avatar/single-avatar'; @@ -19,20 +20,18 @@ type TimeLogItemProps = { }; const TimeLogItem = ({ log, onDelete }: TimeLogItemProps) => { - const { user_name, avatar_url, time_spent_text, logged_by_timer, created_at, user_id, description } = log; - const { selectedTaskId } = useAppSelector(state => state.taskDrawerReducer); + const { user_name, avatar_url, time_spent_text, logged_by_timer, created_at, user_id, description, task_name, task_id, start_time, end_time } = log; + const { selectedTaskId, taskFormViewModel } = useAppSelector(state => state.taskDrawerReducer); + const themeMode = useAppSelector(state => state.themeReducer.mode); const dispatch = useAppDispatch(); const currentSession = useAuthService().getCurrentSession(); const renderLoggedByTimer = () => { if (!logged_by_timer) return null; return ( - <> - via Timer about{' '} - - {logged_by_timer} - - + } color="green" style={{ fontSize: '11px', margin: 0 }}> + Timer + ); }; @@ -60,14 +59,14 @@ const TimeLogItem = ({ log, onDelete }: TimeLogItemProps) => { return ( - handleDeleteTimeLog(log.id)} > - @@ -75,33 +74,136 @@ const TimeLogItem = ({ log, onDelete }: TimeLogItemProps) => { ); }; + // Check if this time log is from a subtask + const isFromSubtask = task_id && task_id !== selectedTaskId; + + const formatTime = (timeString: string | undefined) => { + if (!timeString) return ''; + try { + return new Date(timeString).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + hour12: true + }); + } catch { + return timeString; + } + }; + + const formatDate = (timeString: string | undefined) => { + if (!timeString) return ''; + try { + return new Date(timeString).toLocaleDateString([], { + month: 'short', + day: 'numeric', + year: 'numeric' + }); + } catch { + return timeString; + } + }; + + const isDarkMode = themeMode === 'dark'; + return (
- - - - - - - - {user_name} logged {time_spent_text} {renderLoggedByTimer()} {calculateTimeGap(created_at || '')} + + + {/* Header with user info and task name */} + + + + + + + {user_name} + + {task_name && ( + + {task_name} + + )} + {renderLoggedByTimer()} + + + {calculateTimeGap(created_at || '')} + + + + {renderActionButtons()} + + + {/* Time tracking details */} + + + + + Start Time + + + {formatTime(start_time)} - - {formatDateTimeWithLocale(created_at || '')} + + + + + + + End Time + + + {formatTime(end_time)} - {renderActionButtons()} + + + + + + + + Duration + + + {time_spent_text} + + + - {description && ( - + + {formatDate(created_at)} + + + + {/* Description */} + {description && ( + + + Description: + + {description} - )} - + + )} - - +
); }; diff --git a/worklenz-frontend/src/components/task-drawer/task-drawer.tsx b/worklenz-frontend/src/components/task-drawer/task-drawer.tsx index bbec5479b..a65ba0c50 100644 --- a/worklenz-frontend/src/components/task-drawer/task-drawer.tsx +++ b/worklenz-frontend/src/components/task-drawer/task-drawer.tsx @@ -1,4 +1,4 @@ -import { TabsProps, Tabs, Button } from 'antd'; +import { TabsProps, Tabs, Button, Tooltip } from 'antd'; import Drawer from 'antd/es/drawer'; import { InputRef } from 'antd/es/input'; import { useTranslation } from 'react-i18next'; @@ -146,16 +146,40 @@ const TaskDrawer = () => { /> ); } else { + // Check if task has subtasks + const hasSubTasks = (taskFormViewModel?.task?.sub_tasks_count || 0) > 0; + const addTimeLogTooltip = hasSubTasks + ? t('taskTimeLogTab.timeLogDisabledTooltip', { + count: taskFormViewModel?.task?.sub_tasks_count || 0, + defaultValue: `Time logging is disabled because this task has ${taskFormViewModel?.task?.sub_tasks_count || 0} subtasks. Time should be logged on individual subtasks.` + }) + : ''; + + const addButton = ( + + ); + return ( - + {hasSubTasks ? ( + + {addButton} + + ) : ( + addButton + )} ); } diff --git a/worklenz-frontend/src/components/taskListCommon/task-timer/task-timer.tsx b/worklenz-frontend/src/components/taskListCommon/task-timer/task-timer.tsx index 3427ac3db..6e4bc98c3 100644 --- a/worklenz-frontend/src/components/taskListCommon/task-timer/task-timer.tsx +++ b/worklenz-frontend/src/components/taskListCommon/task-timer/task-timer.tsx @@ -7,7 +7,7 @@ import logger from '@/utils/errorLogger'; import { formatDateTimeWithLocale } from '@/utils/format-date-time-with-locale'; import { formatDate } from '@/utils/timeUtils'; import { PlayCircleFilled } from '@ant-design/icons'; -import { Flex, Button, Popover, Typography, Divider, Skeleton } from 'antd/es'; +import { Flex, Button, Popover, Typography, Divider, Skeleton, Tooltip, Tag } from 'antd/es'; import React from 'react'; import { useState } from 'react'; @@ -17,6 +17,8 @@ interface TaskTimerProps { handleStopTimer: () => void; timeString: string; taskId: string; + disabled?: boolean; + disabledTooltip?: string; } const TaskTimer = ({ @@ -25,6 +27,8 @@ const TaskTimer = ({ handleStopTimer, timeString, taskId, + disabled = false, + disabledTooltip, }: TaskTimerProps) => { const [timeLogs, setTimeLogs] = useState([]); const [loading, setLoading] = useState(false); @@ -69,32 +73,90 @@ const TaskTimer = ({ }; const timeTrackingLogCard = ( - + - {timeLogs.map(log => ( - - - - - - - {log.user_name}  - - logged  - - {formatTimeSpent(log.time_spent || 0)} - {' '} - {renderLoggedByTimer(log)} - {calculateTimeGap(log.created_at || '')} - - - {formatDateTimeWithLocale(log.created_at || '')} - + {timeLogs.map(log => { + // Check if this time log is from a subtask + const isFromSubtask = log.task_id && log.task_id !== taskId; + + const formatTime = (timeString: string | undefined) => { + if (!timeString) return ''; + try { + return new Date(timeString).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + hour12: true + }); + } catch { + return timeString; + } + }; + + return ( + + + + + + + + {log.user_name} + + {log.task_name && ( + + {log.task_name} + + )} + {log.logged_by_timer && ( + + Timer + + )} + + + {calculateTimeGap(log.created_at || '')} + + + + + + + + Start + + + {formatTime(log.start_time)} + + + + + + End + + + {formatTime(log.end_time)} + + + + + + Duration + + + {formatTimeSpent(log.time_spent || 0)} + + + - - - - ))} + + + ); + })} ); @@ -121,17 +183,45 @@ const TaskTimer = ({ } }; + const renderTimerButton = () => { + const button = started ? ( +
), }, @@ -282,14 +297,16 @@ const RatecardTable: React.FC = () => { key: 'actions', align: 'center', render: (_: any, record: JobRoleType, index: number) => ( - handleDelete(record, index)} - okText={t('yes')} - cancelText={t('no')} - > - + {hasEditPermission && ( + + )} {/*
); diff --git a/worklenz-frontend/src/components/task-list-common/task-row/task-row-time-tracking/task-row-time-tracking.tsx b/worklenz-frontend/src/components/task-list-common/task-row/task-row-time-tracking/task-row-time-tracking.tsx index 2d4f73953..40173be50 100644 --- a/worklenz-frontend/src/components/task-list-common/task-row/task-row-time-tracking/task-row-time-tracking.tsx +++ b/worklenz-frontend/src/components/task-list-common/task-row/task-row-time-tracking/task-row-time-tracking.tsx @@ -3,7 +3,7 @@ import { Divider, Empty, Flex, Popover, Typography } from 'antd'; import { PlayCircleFilled } from '@ant-design/icons'; import { colors } from '@/styles/colors'; import CustomAvatar from '@components/CustomAvatar'; -import { mockTimeLogs } from '@/pages/projects/project-view-1/taskList/taskListTable/taskListTableCells/mockTimeLogs'; +import { mockTimeLogs } from '@/shared/mockTimeLogs'; type TaskListTimeTrackerCellProps = { taskId: string | null; diff --git a/worklenz-frontend/src/lib/project/project-view-constants.ts b/worklenz-frontend/src/lib/project/project-view-constants.ts index 43571cc59..fccbddd86 100644 --- a/worklenz-frontend/src/lib/project/project-view-constants.ts +++ b/worklenz-frontend/src/lib/project/project-view-constants.ts @@ -2,7 +2,7 @@ import React, { ReactNode } from 'react'; import ProjectViewInsights from '@/pages/projects/projectView/insights/project-view-insights'; import ProjectViewFiles from '@/pages/projects/projectView/files/project-view-files'; import ProjectViewMembers from '@/pages/projects/projectView/members/project-view-members'; -import ProjectViewUpdates from '@/pages/projects/project-view-1/updates/project-view-updates'; +import ProjectViewUpdates from '@/pages/projects/projectView/updates/ProjectViewUpdates'; 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'; @@ -32,18 +32,6 @@ export const tabItems: TabItems[] = [ isPinned: true, element: React.createElement(ProjectViewBoard), }, - // { - // index: 2, - // key: 'workload', - // label: 'Workload', - // element: React.createElement(ProjectViewWorkload), - // }, - // { - // index: 3, - // key: 'roadmap', - // label: 'Roadmap', - // element: React.createElement(ProjectViewRoadmap), - // }, { index: 4, key: 'project-insights-member-overview', diff --git a/worklenz-frontend/src/pages/projects/project-view-1/board/card.tsx b/worklenz-frontend/src/pages/projects/project-view-1/board/card.tsx deleted file mode 100644 index b8019bb42..000000000 --- a/worklenz-frontend/src/pages/projects/project-view-1/board/card.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { FC } from 'react'; -import { CSS } from '@dnd-kit/utilities'; -import { useSortable } from '@dnd-kit/sortable'; -export type CardType = { - id: string; - title: string; -}; - -const Card: FC = ({ id, title }) => { - // useSortableに指定するidは一意になるよう設定する必要があります。s - const { attributes, listeners, setNodeRef, transform } = useSortable({ - id: id, - }); - - const style = { - margin: '10px', - opacity: 1, - color: '#333', - background: 'white', - padding: '10px', - transform: CSS.Transform.toString(transform), - }; - - return ( - // attributes、listenersはDOMイベントを検知するために利用します。 - // listenersを任意の領域に付与することで、ドラッグするためのハンドルを作ることもできます。 -
-
-

{title}

-
-
- ); -}; - -export default Card; diff --git a/worklenz-frontend/src/pages/projects/project-view-1/board/column.tsx b/worklenz-frontend/src/pages/projects/project-view-1/board/column.tsx deleted file mode 100644 index bacf2d2a8..000000000 --- a/worklenz-frontend/src/pages/projects/project-view-1/board/column.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { FC } from 'react'; -import { SortableContext, rectSortingStrategy } from '@dnd-kit/sortable'; -import { useDroppable } from '@dnd-kit/core'; -import Card, { CardType } from './card'; -import { IProjectTask } from '@/types/project/projectTasksViewModel.types'; - -export type ColumnType = { - id: string; - title: string; - cards: IProjectTask[]; -}; - -const Column: FC = ({ id, title, cards }) => { - const { setNodeRef } = useDroppable({ id: id }); - return ( - // ソートを行うためのContextです。 - // strategyは4つほど存在しますが、今回は縦・横移動可能なリストを作るためrectSortingStrategyを採用 - -
-

- {title} -

- {cards.map(card => ( - - ))} -
-
- ); -}; - -export default Column; diff --git a/worklenz-frontend/src/pages/projects/project-view-1/board/project-view-board.tsx b/worklenz-frontend/src/pages/projects/project-view-1/board/project-view-board.tsx deleted file mode 100644 index b9d407e48..000000000 --- a/worklenz-frontend/src/pages/projects/project-view-1/board/project-view-board.tsx +++ /dev/null @@ -1,141 +0,0 @@ -import React, { useEffect } from 'react'; -import { - DndContext, - DragEndEvent, - DragOverEvent, - PointerSensor, - useSensor, - useSensors, -} from '@dnd-kit/core'; - -import { useAppSelector } from '@/hooks/useAppSelector'; -import TaskListFilters from '../taskList/taskListFilters/TaskListFilters'; -import { Button, Skeleton } from 'antd'; -import { PlusOutlined } from '@ant-design/icons'; -import { useDispatch } from 'react-redux'; -import { toggleDrawer } from '@/features/projects/status/StatusSlice'; -import KanbanGroup from '@/components/board/kanban-group/kanban-group'; - -const ProjectViewBoard: React.FC = () => { - const dispatch = useDispatch(); - - const { taskGroups, loadingGroups } = useAppSelector(state => state.taskReducer); - const { statusCategories } = useAppSelector(state => state.taskStatusReducer); - const groupBy = useAppSelector(state => state.groupByFilterDropdownReducer.groupBy); - const projectId = useAppSelector(state => state.projectReducer.projectId); - - useEffect(() => { - console.log('projectId', projectId); - // if (projectId) { - // const config: ITaskListConfigV2 = { - // id: projectId, - // field: 'id', - // order: 'desc', - // search: '', - // statuses: '', - // members: '', - // projects: '', - // isSubtasksInclude: false, - // }; - // dispatch(fetchTaskGroups(config) as any); - // } - // if (!statusCategories.length) { - // dispatch(fetchStatusesCategories() as any); - // } - }, [dispatch, projectId, groupBy]); - - const sensors = useSensors( - useSensor(PointerSensor, { - activationConstraint: { - distance: 8, - }, - }) - ); - - const handleDragOver = (event: DragOverEvent) => { - const { active, over } = event; - if (!over) return; - - const activeTask = active.data.current?.task; - const overId = over.id; - - // Find which group the task is being dragged over - const targetGroup = taskGroups.find( - group => group.id === overId || group.tasks.some(task => task.id === overId) - ); - - if (targetGroup && activeTask) { - // Here you would dispatch an action to update the task's status - // For example: - // dispatch(updateTaskStatus({ taskId: activeTask.id, newStatus: targetGroup.id })); - console.log('Moving task', activeTask.id, 'to group', targetGroup.id); - } - }; - - const handleDragEnd = (event: DragEndEvent) => { - const { active, over } = event; - if (!over) return; - - const activeTask = active.data.current?.task; - const overId = over.id; - - // Similar to handleDragOver, but this is where you'd make the final update - const targetGroup = taskGroups.find( - group => group.id === overId || group.tasks.some(task => task.id === overId) - ); - - if (targetGroup && activeTask) { - // Make the final update to your backend/state - console.log('Final move of task', activeTask.id, 'to group', targetGroup.id); - } - }; - - return ( -
- - - -
- -
- {taskGroups.map(group => ( - - ))} - -
-
-
-
-
- ); -}; - -export default ProjectViewBoard; diff --git a/worklenz-frontend/src/pages/projects/project-view-1/roadmap/project-view-roadmap.css b/worklenz-frontend/src/pages/projects/project-view-1/roadmap/project-view-roadmap.css deleted file mode 100644 index 6db276089..000000000 --- a/worklenz-frontend/src/pages/projects/project-view-1/roadmap/project-view-roadmap.css +++ /dev/null @@ -1,116 +0,0 @@ -:root { - --odd-row-color: #fff; - --even-row-color: #4e4e4e10; - --text-color: #181818; - --border: 1px solid #e0e0e0; - --stroke: #e0e0e0; - - --calender-header-bg: #fafafa; -} - -.dark-theme { - --odd-row-color: #141414; - --even-row-color: #4e4e4e10; - --text-color: #fff; - --border: 1px solid #505050; - --stroke: #505050; - - --calender-header-bg: #1d1d1d; -} - -/* scroll bar size override */ -._2k9Ys { - scrollbar-width: unset; -} - -/* ----------------------------------------------------------------------- */ -/* task details side even rows */ -._34SS0:nth-of-type(even) { - background-color: var(--even-row-color); -} - -/* task details side header and body */ -._3_ygE { - border-top: var(--border); - border-left: var(--border); - position: relative; -} -._2B2zv { - border-bottom: var(--border); - border-left: var(--border); - position: relative; -} - -._3ZbQT { - border: none; -} - -._3_ygE::after, -._2B2zv::after { - content: ""; - position: absolute; - top: 0; - right: -25px; - width: 30px; - height: 100%; - box-shadow: inset 10px 0 8px -8px #00000026; -} - -/* ._3lLk3:nth-child(1), -._WuQ0f:nth-child(1) { - min-width: 300px !important; - max-width: 300px !important; -} - -._2eZzQ, -._WuQ0f:nth-child(3), -._WuQ0f:last-child, -._3lLk3:nth-child(2), -._3lLk3:nth-child(3) { - display: none; -} */ - -/* ----------------------------------------------------------------------- */ -/* calender side header */ -._35nLX { - fill: var(--calender-header-bg); - stroke: var(--stroke); - stroke-width: 1px; -} - -/* calender side header texts */ -._9w8d5, -._2q1Kt { - fill: var(--text-color); -} - -/* calender side odd rows */ -._2dZTy:nth-child(odd) { - fill: var(--odd-row-color); -} -/* calender side even rows */ -._2dZTy:nth-child(even) { - fill: var(--even-row-color); -} - -/* calender side body row lines */ -._3rUKi { - stroke: var(--stroke); - stroke-width: 0.3px; -} - -/* calender side body ticks */ -._RuwuK { - stroke: var(--stroke); - stroke-width: 0.3px; -} - -/* calender side header ticks */ -._1rLuZ { - stroke: var(--stroke); - stroke-width: 1px; -} - -.roadmap-table .ant-table-thead .ant-table-cell { - height: 50px; -} diff --git a/worklenz-frontend/src/pages/projects/project-view-1/roadmap/project-view-roadmap.tsx b/worklenz-frontend/src/pages/projects/project-view-1/roadmap/project-view-roadmap.tsx deleted file mode 100644 index a5e7d828a..000000000 --- a/worklenz-frontend/src/pages/projects/project-view-1/roadmap/project-view-roadmap.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { useState } from 'react'; -import { ViewMode } from 'gantt-task-react'; -import 'gantt-task-react/dist/index.css'; -import './project-view-roadmap.css'; -import { Flex } from 'antd'; -import { useAppSelector } from '../../../../hooks/useAppSelector'; -import { TimeFilter } from './time-filter'; -import RoadmapTable from './roadmap-table/roadmap-table'; -import RoadmapGrantChart from './roadmap-grant-chart'; - -const ProjectViewRoadmap = () => { - const [view, setView] = useState(ViewMode.Day); - - // get theme details - const themeMode = useAppSelector(state => state.themeReducer.mode); - - return ( - - {/* time filter */} - setView(viewMode)} /> - - - {/* table */} -
- -
- - {/* gantt Chart */} - -
-
- ); -}; - -export default ProjectViewRoadmap; diff --git a/worklenz-frontend/src/pages/projects/project-view-1/roadmap/roadmap-grant-chart.tsx b/worklenz-frontend/src/pages/projects/project-view-1/roadmap/roadmap-grant-chart.tsx deleted file mode 100644 index 067d07231..000000000 --- a/worklenz-frontend/src/pages/projects/project-view-1/roadmap/roadmap-grant-chart.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import { Gantt, Task, ViewMode } from 'gantt-task-react'; -import React from 'react'; -import { colors } from '../../../../styles/colors'; -import { - NewTaskType, - updateTaskDate, - updateTaskProgress, -} from '../../../../features/roadmap/roadmap-slice'; -import { useAppSelector } from '../../../../hooks/useAppSelector'; -import { useAppDispatch } from '../../../../hooks/useAppDispatch'; -import { toggleTaskDrawer } from '../../../../features/tasks/tasks.slice'; - -type RoadmapGrantChartProps = { - view: ViewMode; -}; - -const RoadmapGrantChart = ({ view }: RoadmapGrantChartProps) => { - // get task list from roadmap slice - const tasks = useAppSelector(state => state.roadmapReducer.tasksList); - - const dispatch = useAppDispatch(); - - // column widths for each view mods - let columnWidth = 60; - if (view === ViewMode.Year) { - columnWidth = 350; - } else if (view === ViewMode.Month) { - columnWidth = 300; - } else if (view === ViewMode.Week) { - columnWidth = 250; - } - - // function to handle double click - const handleDoubleClick = () => { - dispatch(toggleTaskDrawer()); - }; - - // function to handle date change - const handleTaskDateChange = (task: Task) => { - dispatch(updateTaskDate({ taskId: task.id, start: task.start, end: task.end })); - }; - - // function to handle progress change - const handleTaskProgressChange = (task: Task) => { - dispatch(updateTaskProgress({ taskId: task.id, progress: task.progress })); - }; - - // function to convert the tasklist comming form roadmap slice which has NewTaskType converted to Task type which is the default type of the tasks list in the grant chart - const flattenTasks = (tasks: NewTaskType[]): Task[] => { - const flattened: Task[] = []; - - const addTaskAndSubTasks = (task: NewTaskType, parentExpanded: boolean) => { - // add the task to the flattened list if its parent is expanded or it is a top-level task - if (parentExpanded) { - const { subTasks, isExpanded, ...rest } = task; // destructure to exclude properties not in Task type - flattened.push(rest as Task); - - // recursively add subtasks if this task is expanded - if (subTasks && isExpanded) { - subTasks.forEach(subTask => addTaskAndSubTasks(subTask as NewTaskType, true)); - } - } - }; - - // top-level tasks are always visible, start with parentExpanded = true - tasks.forEach(task => addTaskAndSubTasks(task, true)); - - return flattened; - }; - - const flattenedTasksList = flattenTasks(tasks); - - return ( -
- -
- ); -}; - -export default RoadmapGrantChart; diff --git a/worklenz-frontend/src/pages/projects/project-view-1/roadmap/roadmap-table/roadmap-table.tsx b/worklenz-frontend/src/pages/projects/project-view-1/roadmap/roadmap-table/roadmap-table.tsx deleted file mode 100644 index ca3c6e774..000000000 --- a/worklenz-frontend/src/pages/projects/project-view-1/roadmap/roadmap-table/roadmap-table.tsx +++ /dev/null @@ -1,189 +0,0 @@ -import React from 'react'; -import { DatePicker, Typography } from 'antd'; -import dayjs, { Dayjs } from 'dayjs'; -import { useAppSelector } from '@/hooks/useAppSelector'; -import { useAppDispatch } from '@/hooks/useAppDispatch'; -import { NewTaskType, updateTaskDate } from '@features/roadmap/roadmap-slice'; -import { colors } from '@/styles/colors'; -import RoadmapTaskCell from './roadmap-task-cell'; - -const RoadmapTable = () => { - // Get task list and expanded tasks from roadmap slice - const tasks = useAppSelector(state => state.roadmapReducer.tasksList); - - // Get theme data from theme slice - const themeMode = useAppSelector(state => state.themeReducer.mode); - - const dispatch = useAppDispatch(); - - // function to handle date changes - const handleDateChange = (taskId: string, dateType: 'start' | 'end', date: Dayjs) => { - const updatedDate = date.toDate(); - - dispatch( - updateTaskDate({ - taskId, - start: dateType === 'start' ? updatedDate : new Date(), - end: dateType === 'end' ? updatedDate : new Date(), - }) - ); - }; - - // Adjusted column type with a string or ReactNode for the title - const columns: { key: string; title: React.ReactNode; width: number }[] = [ - { - key: 'name', - title: 'Task Name', - width: 240, - }, - { - key: 'start', - title: 'Start Date', - width: 130, - }, - { - key: 'end', - title: 'End Date', - width: 130, - }, - ]; - - // Function to render the column content based on column key - const renderColumnContent = ( - columnKey: string, - task: NewTaskType, - isSubtask: boolean = false - ) => { - switch (columnKey) { - case 'name': - return ; - case 'start': - const startDayjs = task.start ? dayjs(task.start) : null; - return ( - handleDateChange(task.id, 'end', date)} - style={{ - backgroundColor: colors.transparent, - border: 'none', - boxShadow: 'none', - }} - /> - ); - case 'end': - const endDayjs = task.end ? dayjs(task.end) : null; - return ( - handleDateChange(task.id, 'end', date)} - style={{ - backgroundColor: colors.transparent, - border: 'none', - boxShadow: 'none', - }} - /> - ); - - default: - return null; - } - }; - - const dataSource = tasks.map(task => ({ - id: task.id, - name: task.name, - start: task.start, - end: task.end, - type: task.type, - progress: task.progress, - subTasks: task.subTasks, - isExpanded: task.isExpanded, - })); - - // Layout styles for table and columns - const customHeaderColumnStyles = `border px-2 h-[50px] text-left z-10 after:content after:absolute after:top-0 after:-right-1 after:-z-10 after:h-[42px] 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 customBodyColumnStyles = `border px-2 h-[50px] z-10 after:content after:absolute after:top-0 after:-right-1 after:-z-10 after:min-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-transparent border-[#303030]' : 'bg-transparent'}`; - - const rowBackgroundStyles = - themeMode === 'dark' ? 'even:bg-[#1b1b1b] odd:bg-[#141414]' : 'even:bg-[#f4f4f4] odd:bg-white'; - - return ( -
-
- - - {/* table header */} - {columns.map(column => ( - - ))} - - - - {dataSource.length === 0 ? ( - - - - ) : ( - dataSource.map(task => ( - - - {columns.map(column => ( - - ))} - - - {/* subtasks */} - {task.isExpanded && - task?.subTasks?.map(subtask => ( - - {columns.map(column => ( - - ))} - - ))} - - )) - )} - -
- {column.title} -
- No tasks available -
- {renderColumnContent(column.key, task)} -
- {renderColumnContent(column.key, subtask, true)} -
- - ); -}; - -export default RoadmapTable; diff --git a/worklenz-frontend/src/pages/projects/project-view-1/roadmap/roadmap-table/roadmap-task-cell.tsx b/worklenz-frontend/src/pages/projects/project-view-1/roadmap/roadmap-table/roadmap-task-cell.tsx deleted file mode 100644 index 246026b86..000000000 --- a/worklenz-frontend/src/pages/projects/project-view-1/roadmap/roadmap-table/roadmap-task-cell.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import { Flex, Typography, Button, Tooltip } from 'antd'; -import { - DoubleRightOutlined, - DownOutlined, - RightOutlined, - ExpandAltOutlined, -} from '@ant-design/icons'; -import { NewTaskType, toggleTaskExpansion } from '@features/roadmap/roadmap-slice'; -import { useAppDispatch } from '@/hooks/useAppDispatch'; -import { toggleTaskDrawer } from '@features/tasks/taskSlice'; -import { colors } from '@/styles/colors'; - -type RoadmapTaskCellProps = { - task: NewTaskType; - isSubtask?: boolean; -}; - -const RoadmapTaskCell = ({ task, isSubtask = false }: RoadmapTaskCellProps) => { - const dispatch = useAppDispatch(); - - // render the toggle arrow icon for tasks with subtasks - const renderToggleButtonForHasSubTasks = (id: string, hasSubtasks: boolean) => { - if (!hasSubtasks) return null; - return ( - - ); - }; - - // show expand button on hover for tasks without subtasks - const renderToggleButtonForNonSubtasks = (id: string, isSubtask: boolean) => { - return !isSubtask ? ( - - ) : ( -
- ); - }; - - // render the double arrow icon and count label for tasks with subtasks - const renderSubtasksCountLabel = (id: string, isSubtask: boolean, subTasksCount: number) => { - return ( - !isSubtask && ( - - ) - ); - }; - - return ( - - - {!!task?.subTasks?.length ? ( - renderToggleButtonForHasSubTasks(task.id, !!task?.subTasks?.length) - ) : ( -
- {renderToggleButtonForNonSubtasks(task.id, isSubtask)} -
- )} - - {isSubtask && } - - - - {task.name} - - - - {renderSubtasksCountLabel(task.id, isSubtask, task?.subTasks?.length || 0)} -
- - -
- ); -}; - -export default RoadmapTaskCell; diff --git a/worklenz-frontend/src/pages/projects/project-view-1/roadmap/time-filter.tsx b/worklenz-frontend/src/pages/projects/project-view-1/roadmap/time-filter.tsx deleted file mode 100644 index ff1461f10..000000000 --- a/worklenz-frontend/src/pages/projects/project-view-1/roadmap/time-filter.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import React from 'react'; -import 'gantt-task-react/dist/index.css'; -import { ViewMode } from 'gantt-task-react'; -import { Flex, Select } from 'antd'; -type TimeFilterProps = { - onViewModeChange: (viewMode: ViewMode) => void; -}; -export const TimeFilter = ({ onViewModeChange }: TimeFilterProps) => { - // function to handle time change - const handleChange = (value: string) => { - switch (value) { - case 'hour': - return onViewModeChange(ViewMode.Hour); - case 'quaterDay': - return onViewModeChange(ViewMode.QuarterDay); - case 'halfDay': - return onViewModeChange(ViewMode.HalfDay); - case 'day': - return onViewModeChange(ViewMode.Day); - case 'week': - return onViewModeChange(ViewMode.Week); - case 'month': - return onViewModeChange(ViewMode.Month); - case 'year': - return onViewModeChange(ViewMode.Year); - default: - return onViewModeChange(ViewMode.Day); - } - }; - - const timeFilterItems = [ - { - value: 'hour', - label: 'Hour', - }, - { - value: 'quaterDay', - label: 'Quater Day', - }, - { - value: 'halfDay', - label: 'Half Day', - }, - { - value: 'day', - label: 'Day', - }, - { - value: 'week', - label: 'Week', - }, - { - value: 'month', - label: 'Month', - }, - { - value: 'year', - label: 'Year', - }, - ]; - - return ( - - - - ), - }), - - // columnHelper.accessor('time_tracking', { - // header: 'Time Tracking', - // size: 120, - // enablePinning: false, - // cell: ({ row }) => ( - // - // ) - // }) - ]; -}; diff --git a/worklenz-frontend/src/pages/projects/project-view-1/task-list/task-list-custom.css b/worklenz-frontend/src/pages/projects/project-view-1/task-list/task-list-custom.css deleted file mode 100644 index a47805009..000000000 --- a/worklenz-frontend/src/pages/projects/project-view-1/task-list/task-list-custom.css +++ /dev/null @@ -1,44 +0,0 @@ -.table-header { - border-bottom: 1px solid #d9d9d9; - /* Border below header */ -} - -.table-body { - background-color: #ffffff; - /* White background for body */ -} - -.table-row { - display: flex; - /* Use flexbox for row layout */ - align-items: center; - /* Center items vertically */ - transition: background-color 0.2s; - /* Smooth background transition */ -} - -.table-row:hover { - background-color: #f5f5f5; - /* Light gray background on hover */ -} - -/* Optional: Add styles for sticky headers */ -.table-header > div { - position: sticky; - /* Make header cells sticky */ - top: 0; - /* Stick to the top */ - z-index: 1; - /* Ensure it stays above other content */ -} - -/* Optional: Add styles for cell borders */ -.table-row > div { - border-right: 1px solid #d9d9d9; - /* Right border for cells */ -} - -.table-row > div:last-child { - border-right: none; - /* Remove right border for last cell */ -} diff --git a/worklenz-frontend/src/pages/projects/project-view-1/task-list/task-list-custom.tsx b/worklenz-frontend/src/pages/projects/project-view-1/task-list/task-list-custom.tsx deleted file mode 100644 index e2ca07f29..000000000 --- a/worklenz-frontend/src/pages/projects/project-view-1/task-list/task-list-custom.tsx +++ /dev/null @@ -1,272 +0,0 @@ -import { useCallback, useMemo, useRef, useState } from 'react'; -import { Checkbox, theme } from 'antd'; -import { - useReactTable, - getCoreRowModel, - getFilteredRowModel, - getPaginationRowModel, - flexRender, - VisibilityState, - Row, - Column, -} from '@tanstack/react-table'; -import { useVirtualizer } from '@tanstack/react-virtual'; -import { IProjectTask } from '@/types/project/projectTasksViewModel.types'; -import { useAppSelector } from '@/hooks/useAppSelector'; -import React from 'react'; -import './task-list-custom.css'; -import TaskListInstantTaskInput from './task-list-instant-task-input/task-list-instant-task-input'; -import { useAuthService } from '@/hooks/useAuth'; -import { createColumns } from './task-list-columns/task-list-columns'; - -interface TaskListCustomProps { - tasks: IProjectTask[]; - color: string; - groupId?: string | null; - onTaskSelect?: (taskId: string) => void; -} - -const TaskListCustom: React.FC = ({ tasks, color, groupId, onTaskSelect }) => { - const [rowSelection, setRowSelection] = useState({}); - const [columnVisibility, setColumnVisibility] = useState({}); - const [expandedRows, setExpandedRows] = useState>({}); - - const statuses = useAppSelector(state => state.taskStatusReducer.status); - const tableContainerRef = useRef(null); - const { token } = theme.useToken(); - const { getCurrentSession } = useAuthService(); - - const handleExpandClick = useCallback((rowId: string) => { - setExpandedRows(prev => ({ - ...prev, - [rowId]: !prev[rowId], - })); - }, []); - - const handleTaskSelect = useCallback( - (taskId: string) => { - onTaskSelect?.(taskId); - }, - [onTaskSelect] - ); - - const columns = useMemo( - () => - createColumns({ - expandedRows, - statuses, - handleTaskSelect, - getCurrentSession, - }), - [expandedRows, statuses, handleTaskSelect, getCurrentSession] - ); - - const table = useReactTable({ - data: tasks, - columns, - state: { - rowSelection, - columnVisibility, - }, - enableRowSelection: true, - onRowSelectionChange: setRowSelection, - onColumnVisibilityChange: setColumnVisibility, - getCoreRowModel: getCoreRowModel(), - getFilteredRowModel: getFilteredRowModel(), - getPaginationRowModel: getPaginationRowModel(), - }); - - const { rows } = table.getRowModel(); - - const rowVirtualizer = useVirtualizer({ - count: rows.length, - getScrollElement: () => tableContainerRef.current, - estimateSize: () => 50, - overscan: 20, - }); - - const virtualRows = rowVirtualizer.getVirtualItems(); - const totalSize = rowVirtualizer.getTotalSize(); - const paddingTop = virtualRows.length > 0 ? virtualRows?.[0]?.start || 0 : 0; - const paddingBottom = - virtualRows.length > 0 ? totalSize - (virtualRows?.[virtualRows.length - 1]?.end || 0) : 0; - - const columnToggleItems = columns.map(column => ({ - key: column.id as string, - label: ( - - - {typeof column.header === 'string' ? column.header : column.id} - - - ), - onClick: () => { - const columnData = table.getColumn(column.id as string); - if (columnData) { - columnData.toggleVisibility(); - } - }, - })); - - return ( -
-
-
-
- {table.getHeaderGroups().map(headerGroup => ( -
- {headerGroup.headers.map((header, index) => ( -
- {header.isPlaceholder - ? null - : flexRender(header.column.columnDef.header, header.getContext())} -
- ))} -
- ))} -
-
- {paddingTop > 0 &&
} - {virtualRows.map(virtualRow => { - const row = rows[virtualRow.index]; - return ( - -
- {row.getVisibleCells().map((cell, index) => ( -
- {flexRender(cell.column.columnDef.cell, cell.getContext())} -
- ))} -
- {expandedRows[row.id] && - row.original.sub_tasks?.map(subTask => ( -
- {columns.map((col, index) => ( -
- {flexRender(col.cell, { - getValue: () => subTask[col.id as keyof typeof subTask] ?? null, - row: { original: subTask } as Row, - column: col as Column, - table, - })} -
- ))} -
- ))} -
- ); - })} - {paddingBottom > 0 &&
} -
-
-
- - {/* {selectedCount > 0 && ( - - {selectedCount} tasks selected - - - - - - )} */} -
- ); -}; - -export default TaskListCustom; diff --git a/worklenz-frontend/src/pages/projects/project-view-1/task-list/task-list-header/task-list-header.tsx b/worklenz-frontend/src/pages/projects/project-view-1/task-list/task-list-header/task-list-header.tsx deleted file mode 100644 index 120438056..000000000 --- a/worklenz-frontend/src/pages/projects/project-view-1/task-list/task-list-header/task-list-header.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import React, { useState } from 'react'; -import { Button, Dropdown, Input, Menu, Badge, Tooltip } from 'antd'; -import { - RightOutlined, - LoadingOutlined, - EllipsisOutlined, - EditOutlined, - RetweetOutlined, -} from '@ant-design/icons'; -import { ITaskListGroup } from '@/types/tasks/taskList.types'; -import { ITaskStatusCategory } from '@/types/status.types'; -import { useAppSelector } from '@/hooks/useAppSelector'; -// import WorklenzTaskListPhaseDuration from "./WorklenzTaskListPhaseDuration"; -// import WorklenzTasksProgressBar from "./WorklenzTasksProgressBar"; - -interface Props { - group: ITaskListGroup; - projectId: string | null; - categories: ITaskStatusCategory[]; -} - -const TaskListGroupSettings: React.FC = ({ group, projectId, categories }) => { - const [edit, setEdit] = useState(false); - const [showMenu, setShowMenu] = useState(false); - const [isEditColProgress, setIsEditColProgress] = useState(false); - const [isGroupByPhases, setIsGroupByPhases] = useState(false); - const [isGroupByStatus, setIsGroupByStatus] = useState(false); - const [isAdmin, setIsAdmin] = useState(false); - - const menu = ( - - - - Rename - - {isGroupByStatus && ( - - - Change category - - } - > - {categories.map(item => ( - - - - - - ))} - - )} - - ); - - const onBlurEditColumn = (group: ITaskListGroup) => { - setEdit(false); - }; - - const onToggleClick = () => { - console.log('onToggleClick'); - }; - - const canDisplayActions = () => { - return true; - }; - - return ( -
-
- - - {canDisplayActions() && ( - setShowMenu(visible)} - > - - - )} -
- - {/* {isGroupByPhases && group.name !== "Unmapped" && ( -
- -
- )} - - {isProgressBarAvailable() && ( - - )} */} -
- ); -}; - -export default TaskListGroupSettings; diff --git a/worklenz-frontend/src/pages/projects/project-view-1/task-list/task-list-instant-task-input/task-list-instant-task-input.tsx b/worklenz-frontend/src/pages/projects/project-view-1/task-list/task-list-instant-task-input/task-list-instant-task-input.tsx deleted file mode 100644 index 2312800a0..000000000 --- a/worklenz-frontend/src/pages/projects/project-view-1/task-list/task-list-instant-task-input/task-list-instant-task-input.tsx +++ /dev/null @@ -1,160 +0,0 @@ -import { Input, InputRef, theme } from 'antd'; -import React, { useState, useMemo, useRef } from 'react'; -import { useAppSelector } from '@/hooks/useAppSelector'; -import { colors } from '@/styles/colors'; -import { useTranslation } from 'react-i18next'; -import { ILocalSession } from '@/types/auth/local-session.types'; -import { ITaskCreateRequest } from '@/types/tasks/task-create-request.types'; -import { - addTask, - getCurrentGroup, - GROUP_BY_PHASE_VALUE, - GROUP_BY_PRIORITY_VALUE, - GROUP_BY_STATUS_VALUE, -} from '@/features/tasks/tasks.slice'; -import { useSocket } from '@/socket/socketContext'; -import { IProjectTask } from '@/types/project/projectTasksViewModel.types'; -import { SocketEvents } from '@/shared/socket-events'; -import { DRAWER_ANIMATION_INTERVAL } from '@/shared/constants'; -import { useAppDispatch } from '@/hooks/useAppDispatch'; - -interface ITaskListInstantTaskInputProps { - session: ILocalSession | null; - groupId?: string | null; - parentTask?: string | null; -} -interface IAddNewTask extends IProjectTask { - groupId: string; -} - -const TaskListInstantTaskInput = ({ - session, - groupId = null, - parentTask = null, -}: ITaskListInstantTaskInputProps) => { - const [isEdit, setIsEdit] = useState(false); - const [taskName, setTaskName] = useState(''); - const [creatingTask, setCreatingTask] = useState(false); - const taskInputRef = useRef(null); - const dispatch = useAppDispatch(); - - const { socket } = useSocket(); - const { token } = theme.useToken(); - - const { t } = useTranslation('task-list-table'); - - const themeMode = useAppSelector(state => state.themeReducer.mode); - const customBorderColor = useMemo(() => themeMode === 'dark' && ' border-[#303030]', [themeMode]); - const projectId = useAppSelector(state => state.projectReducer.projectId); - - const createRequestBody = (): ITaskCreateRequest | null => { - if (!projectId || !session) return null; - const body: ITaskCreateRequest = { - project_id: projectId, - name: taskName, - reporter_id: session.id, - team_id: session.team_id, - }; - - const groupBy = getCurrentGroup(); - if (groupBy.value === GROUP_BY_STATUS_VALUE) { - body.status_id = groupId || undefined; - } else if (groupBy.value === GROUP_BY_PRIORITY_VALUE) { - body.priority_id = groupId || undefined; - } else if (groupBy.value === GROUP_BY_PHASE_VALUE) { - body.phase_id = groupId || undefined; - } - - if (parentTask) { - body.parent_task_id = parentTask; - } - console.log('createRequestBody', body); - - return body; - }; - - const reset = (scroll = true) => { - setIsEdit(false); - - setCreatingTask(false); - - setTaskName(''); - setIsEdit(true); - - setTimeout(() => { - taskInputRef.current?.focus(); - if (scroll) window.scrollTo(0, document.body.scrollHeight); - }, DRAWER_ANIMATION_INTERVAL); // wait for the animation end - }; - - const onNewTaskReceived = (task: IAddNewTask) => { - if (!groupId) return; - console.log('onNewTaskReceived', task); - task.groupId = groupId; - if (groupId && task.id) { - dispatch(addTask(task)); - reset(false); - // if (this.map.has(task.id)) return; - - // this.service.addTask(task, this.groupId); - // this.reset(false); - } - }; - - const addInstantTask = () => { - if (creatingTask) return; - console.log('addInstantTask', projectId, taskName.trim()); - if (!projectId || !session || taskName.trim() === '') return; - - try { - setCreatingTask(true); - const body = createRequestBody(); - if (!body) return; - socket?.emit(SocketEvents.QUICK_TASK.toString(), JSON.stringify(body)); - socket?.once(SocketEvents.QUICK_TASK.toString(), (task: IProjectTask) => { - setCreatingTask(false); - if (task.parent_task_id) { - } - onNewTaskReceived(task as IAddNewTask); - }); - } catch (error) { - console.error(error); - } finally { - setCreatingTask(false); - } - }; - - const handleAddTask = () => { - setIsEdit(false); - addInstantTask(); - }; - - return ( -
- {isEdit ? ( - setTaskName(e.target.value)} - onBlur={handleAddTask} - onPressEnter={handleAddTask} - ref={taskInputRef} - /> - ) : ( - setIsEdit(true)} - className="w-[300px] border-none" - style={{ height: '34px' }} - value={t('addTaskText')} - ref={taskInputRef} - /> - )} -
- ); -}; - -export default TaskListInstantTaskInput; diff --git a/worklenz-frontend/src/pages/projects/project-view-1/task-list/task-list-table-old/task-list-table-old.tsx b/worklenz-frontend/src/pages/projects/project-view-1/task-list/task-list-table-old/task-list-table-old.tsx deleted file mode 100644 index 23ba76237..000000000 --- a/worklenz-frontend/src/pages/projects/project-view-1/task-list/task-list-table-old/task-list-table-old.tsx +++ /dev/null @@ -1,471 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { Avatar, Checkbox, DatePicker, Flex, Tag, Tooltip, Typography } from 'antd'; - -import { useAppSelector } from '@/hooks/useAppSelector'; -import { columnList } from '@/pages/projects/project-view-1/taskList/taskListTable/columns/columnList'; -import AddTaskListRow from '@/pages/projects/project-view-1/taskList/taskListTable/taskListTableRows/AddTaskListRow'; - -import CustomAvatar from '@/components/CustomAvatar'; -import LabelsSelector from '@components/task-list-common/labelsSelector/labels-selector'; -import { useSelectedProject } from '@/hooks/useSelectedProject'; -import StatusDropdown from '@/components/task-list-common/status-dropdown/status-dropdown'; -import PriorityDropdown from '@/components/task-list-common/priorityDropdown/priority-dropdown'; -import { simpleDateFormat } from '@/utils/simpleDateFormat'; -import { durationDateFormat } from '@/utils/durationDateFormat'; -import CustomColorLabel from '@components/task-list-common/labelsSelector/custom-color-label'; -import CustomNumberLabel from '@components/task-list-common/labelsSelector/custom-number-label'; -import PhaseDropdown from '@components/task-list-common/phaseDropdown/PhaseDropdown'; -import AssigneeSelector from '@components/task-list-common/assigneeSelector/AssigneeSelector'; -import TaskCell from '@/pages/projects/project-view-1/taskList/taskListTable/taskListTableCells/TaskCell'; -import AddSubTaskListRow from '@/pages/projects/project-view-1/taskList/taskListTable/taskListTableRows/AddSubTaskListRow'; -import { colors } from '@/styles/colors'; -import TimeTracker from '@/pages/projects/project-view-1/taskList/taskListTable/taskListTableCells/TimeTracker'; -import TaskContextMenu from '@/pages/projects/project-view-1/taskList/taskListTable/contextMenu/TaskContextMenu'; -import TaskProgress from '@/pages/projects/project-view-1/taskList/taskListTable/taskListTableCells/TaskProgress'; -import { useAppDispatch } from '@/hooks/useAppDispatch'; -import { deselectAll } from '@/features/projects/bulkActions/bulkActionSlice'; -import { useTranslation } from 'react-i18next'; -import { IProjectTask } from '@/types/project/projectTasksViewModel.types'; -import { ITaskListGroup } from '@/types/tasks/taskList.types'; -import Avatars from '@/components/avatars/avatars'; - -const TaskListTable = ({ - taskList, - tableId, -}: { - taskList: ITaskListGroup; - tableId: string | undefined; -}) => { - // these states manage the necessary states - const [hoverRow, setHoverRow] = useState(null); - const [selectedRows, setSelectedRows] = useState([]); - const [selectedTaskId, setSelectedTaskId] = useState(null); - const [expandedTasks, setExpandedTasks] = useState([]); - const [isSelectAll, setIsSelectAll] = useState(false); - // context menu state - const [contextMenuVisible, setContextMenuVisible] = useState(false); - const [contextMenuPosition, setContextMenuPosition] = useState({ - x: 0, - y: 0, - }); - // state to check scroll - const [scrollingTables, setScrollingTables] = useState<{ - [key: string]: boolean; - }>({}); - - // localization - const { t } = useTranslation('task-list-table'); - - const dispatch = useAppDispatch(); - - // get data theme data from redux - const themeMode = useAppSelector(state => state.themeReducer.mode); - - // get the selected project details - const selectedProject = useSelectedProject(); - - // get columns list details - const columnsVisibility = useAppSelector( - state => state.projectViewTaskListColumnsReducer.columnsVisibility - ); - const visibleColumns = columnList.filter( - column => columnsVisibility[column.key as keyof typeof columnsVisibility] - ); - - // toggle subtasks visibility - const toggleTaskExpansion = (taskId: string) => { - setExpandedTasks(prev => - prev.includes(taskId) ? prev.filter(id => id !== taskId) : [...prev, taskId] - ); - }; - - // toggle all task select when header checkbox click - const toggleSelectAll = () => { - if (isSelectAll) { - setSelectedRows([]); - dispatch(deselectAll()); - } else { - // const allTaskIds = - // task-list?.flatMap((task) => [ - // task.taskId, - // ...(task.subTasks?.map((subtask) => subtask.taskId) || []), - // ]) || []; - // setSelectedRows(allTaskIds); - // dispatch(selectTaskIds(allTaskIds)); - // console.log('selected tasks and subtasks (all):', allTaskIds); - } - setIsSelectAll(!isSelectAll); - }; - - // toggle selected row - const toggleRowSelection = (task: IProjectTask) => { - setSelectedRows(prevSelectedRows => - prevSelectedRows.includes(task.id || '') - ? prevSelectedRows.filter(id => id !== task.id) - : [...prevSelectedRows, task.id || ''] - ); - }; - - // this use effect for realtime update the selected rows - useEffect(() => { - console.log('Selected tasks and subtasks:', selectedRows); - }, [selectedRows]); - - // select one row this triggers only in handle the context menu ==> righ click mouse event - const selectOneRow = (task: IProjectTask) => { - setSelectedRows([task.id || '']); - - // log the task object when selected - if (!selectedRows.includes(task.id || '')) { - console.log('Selected task:', task); - } - }; - - // handle custom task context menu - const handleContextMenu = (e: React.MouseEvent, task: IProjectTask) => { - e.preventDefault(); - setSelectedTaskId(task.id || ''); - selectOneRow(task); - setContextMenuPosition({ x: e.clientX, y: e.clientY }); - setContextMenuVisible(true); - }; - - // trigger the table scrolling - useEffect(() => { - const tableContainer = document.querySelector(`.tasklist-container-${tableId}`); - const handleScroll = () => { - if (tableContainer) { - setScrollingTables(prev => ({ - ...prev, - [tableId]: tableContainer.scrollLeft > 0, - })); - } - }; - tableContainer?.addEventListener('scroll', handleScroll); - return () => tableContainer?.removeEventListener('scroll', handleScroll); - }, [tableId]); - - // layout styles for table and the columns - const customBorderColor = themeMode === 'dark' && ' border-[#303030]'; - - const customHeaderColumnStyles = (key: string) => - `border px-2 text-left ${key === 'selector' && 'sticky left-0 z-10'} ${key === 'task' && `sticky left-[33px] z-10 after:content after:absolute after:top-0 after:-right-1 after:-z-10 after:h-[42px] after:w-1.5 after:bg-transparent ${scrollingTables[tableId] ? '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 customBodyColumnStyles = (key: string) => - `border px-2 ${key === 'selector' && 'sticky left-0 z-10'} ${key === 'task' && `sticky left-[33px] z-10 after:content after:absolute after:top-0 after:-right-1 after:-z-10 after:min-h-[40px] after:w-1.5 after:bg-transparent ${scrollingTables[tableId] ? 'after:bg-gradient-to-r after:from-[rgba(0,0,0,0.12)] after:to-transparent' : ''}`} ${themeMode === 'dark' ? 'bg-[#141414] border-[#303030]' : 'bg-white'}`; - - // function to render the column content based on column key - const renderColumnContent = ( - columnKey: string, - task: IProjectTask, - isSubtask: boolean = false - ) => { - switch (columnKey) { - // task ID column - case 'taskId': - return ( - - {task.task_key || ''} - - ); - - // task name column - case 'task': - return ( - // custom task cell component - - ); - - // description column - case 'description': - return ; - - // progress column - case 'progress': { - return task?.progress || task?.progress === 0 ? ( - - ) : ( -
- ); - } - - // members column - case 'members': - return ( - - - {/* - {task.assignees?.map(member => ( - - ))} - */} - - - ); - - // labels column - case 'labels': - return ( - - {task?.labels && task?.labels?.length <= 2 ? ( - task?.labels?.map(label => ) - ) : ( - - - - {/* this component show other label names */} - - - )} - - - ); - - // phase column - case 'phases': - return ; - - // status column - case 'status': - return ; - - // priority column - case 'priority': - return ; - - // time tracking column - case 'timeTracking': - return ; - - // estimation column - case 'estimation': - return 0h 0m; - - // start date column - case 'startDate': - return task.start_date ? ( - {simpleDateFormat(task.start_date)} - ) : ( - - ); - - // due date column - case 'dueDate': - return task.end_date ? ( - {simpleDateFormat(task.end_date)} - ) : ( - - ); - - // completed date column - case 'completedDate': - return {durationDateFormat(task.completed_at || null)}; - - // created date column - case 'createdDate': - return {durationDateFormat(task.created_at || null)}; - - // last updated column - case 'lastUpdated': - return {durationDateFormat(task.updated_at || null)}; - - // recorder column - case 'reporter': - return {task.reporter}; - - // default case for unsupported columns - default: - return null; - } - }; - - return ( -
-
- - - - {/* this cell render the select all task checkbox */} - - {/* other header cells */} - {visibleColumns.map(column => ( - - ))} - - - - {taskList?.tasks?.map(task => ( - - handleContextMenu(e, task)} - className={`${taskList.tasks.length === 0 ? 'h-0' : 'h-[42px]'}`} - > - {/* this cell render the select the related task checkbox */} - - {/* other cells */} - {visibleColumns.map(column => ( - - ))} - - - {/* this is for sub tasks */} - {expandedTasks.includes(task.id || '') && - task?.sub_tasks?.map(subtask => ( - handleContextMenu(e, subtask)} - className={`${taskList.tasks.length === 0 ? 'h-0' : 'h-[42px]'}`} - > - {/* this cell render the select the related task checkbox */} - - - {/* other sub tasks cells */} - {visibleColumns.map(column => ( - - ))} - - ))} - - {expandedTasks.includes(task.id || '') && ( - - - - )} - - ))} - -
- - - {column.key === 'phases' - ? column.columnHeader - : t(`${column.columnHeader}Column`)} -
- toggleRowSelection(task)} - /> - - {renderColumnContent(column.key, task)} -
- toggleRowSelection(subtask)} - /> - - {renderColumnContent(column.key, subtask, true)} -
- -
-
- - {/* add a main task to the table */} - - - {/* custom task context menu */} - setContextMenuVisible(false)} - /> -
- ); -}; - -export default TaskListTable; diff --git a/worklenz-frontend/src/pages/projects/project-view-1/task-list/task-list-table-old/task-list-table.css b/worklenz-frontend/src/pages/projects/project-view-1/task-list/task-list-table-old/task-list-table.css deleted file mode 100644 index ce92b84ae..000000000 --- a/worklenz-frontend/src/pages/projects/project-view-1/task-list/task-list-table-old/task-list-table.css +++ /dev/null @@ -1,19 +0,0 @@ -.tasks-table { - width: max-content; - margin-left: 3px; - border-right: 1px solid #f0f0f0; -} - -.flex-table { - display: flex; - width: max-content; -} - -.table-container { - overflow: auto; - display: flex; -} - -.position-relative { - position: relative; -} diff --git a/worklenz-frontend/src/pages/projects/project-view-1/task-list/task-list-table-wrapper/task-list-table-wrapper.css b/worklenz-frontend/src/pages/projects/project-view-1/task-list/task-list-table-wrapper/task-list-table-wrapper.css deleted file mode 100644 index 6e5ca7c8e..000000000 --- a/worklenz-frontend/src/pages/projects/project-view-1/task-list/task-list-table-wrapper/task-list-table-wrapper.css +++ /dev/null @@ -1,15 +0,0 @@ -/* custom collapse styles for content box and the left border */ -.ant-collapse-header { - margin-bottom: 6px !important; -} - -.custom-collapse-content-box .ant-collapse-content-box { - padding: 0 !important; -} - -:where(.css-dev-only-do-not-override-1w6wsvq).ant-collapse-ghost - > .ant-collapse-item - > .ant-collapse-content - > .ant-collapse-content-box { - padding: 0; -} diff --git a/worklenz-frontend/src/pages/projects/project-view-1/task-list/task-list-table-wrapper/task-list-table-wrapper.tsx b/worklenz-frontend/src/pages/projects/project-view-1/task-list/task-list-table-wrapper/task-list-table-wrapper.tsx deleted file mode 100644 index f31497675..000000000 --- a/worklenz-frontend/src/pages/projects/project-view-1/task-list/task-list-table-wrapper/task-list-table-wrapper.tsx +++ /dev/null @@ -1,190 +0,0 @@ -import { Badge, Button, Collapse, ConfigProvider, Dropdown, Flex, Input, Typography } from 'antd'; -import { useState } from 'react'; -import { TaskType } from '@/types/task.types'; -import { EditOutlined, EllipsisOutlined, RetweetOutlined, RightOutlined } from '@ant-design/icons'; -import { colors } from '@/styles/colors'; -import './task-list-table-wrapper.css'; -import TaskListTable from '../task-list-table-old/task-list-table-old'; -import { MenuProps } from 'antd/lib'; -import { useAppSelector } from '@/hooks/useAppSelector'; -import { useTranslation } from 'react-i18next'; -import { ITaskListGroup } from '@/types/tasks/taskList.types'; -import TaskListCustom from '../task-list-custom'; - -type TaskListTableWrapperProps = { - taskList: ITaskListGroup; - groupId: string | undefined; - name: string | undefined; - color: string | undefined; - onRename?: (name: string) => void; - onStatusCategoryChange?: (category: string) => void; -}; - -const TaskListTableWrapper = ({ - taskList, - groupId, - name, - color, - onRename, - onStatusCategoryChange, -}: TaskListTableWrapperProps) => { - const [tableName, setTableName] = useState(name || ''); - const [isRenaming, setIsRenaming] = useState(false); - const [isExpanded, setIsExpanded] = useState(true); - - const type = 'status'; - - // localization - const { t } = useTranslation('task-list-table'); - - // function to handle toggle expand - const handlToggleExpand = () => { - setIsExpanded(!isExpanded); - }; - - // these codes only for status type tables - // function to handle rename this functionality only available for status type tables - const handleRename = () => { - if (onRename) { - onRename(tableName); - } - setIsRenaming(false); - }; - - // function to handle category change - const handleCategoryChange = (category: string) => { - if (onStatusCategoryChange) { - onStatusCategoryChange(category); - } - }; - - // find the available status for the currently active project - const statusList = useAppSelector(state => state.statusReducer.status); - - const getStatusColor = (status: string) => { - switch (status) { - case 'todo': - return '#d8d7d8'; - case 'doing': - return '#c0d5f6'; - case 'done': - return '#c2e4d0'; - default: - return '#d8d7d8'; - } - }; - - // dropdown options - const items: MenuProps['items'] = [ - { - key: '1', - icon: , - label: 'Rename', - onClick: () => setIsRenaming(true), - }, - { - key: '2', - icon: , - label: 'Change category', - children: statusList?.map(status => ({ - key: status.id, - label: ( - handleCategoryChange(status.category)}> - - {status.name} - - ), - })), - }, - ]; - - return ( - - - - - {type === 'status' && !isRenaming && ( - - - - ); -}; - -export default LabelsFilterDropdown; diff --git a/worklenz-frontend/src/pages/projects/project-view-1/taskList/taskListFilters/MembersFilterDropdown.tsx b/worklenz-frontend/src/pages/projects/project-view-1/taskList/taskListFilters/MembersFilterDropdown.tsx deleted file mode 100644 index 1690b9375..000000000 --- a/worklenz-frontend/src/pages/projects/project-view-1/taskList/taskListFilters/MembersFilterDropdown.tsx +++ /dev/null @@ -1,140 +0,0 @@ -/* eslint-disable react-hooks/exhaustive-deps */ -import { CaretDownFilled } from '@ant-design/icons'; -import { - Badge, - Button, - Card, - Checkbox, - Dropdown, - Empty, - Flex, - Input, - InputRef, - List, - Space, - Typography, -} from 'antd'; -import { useMemo, useRef, useState } from 'react'; -import { useAppSelector } from '@/hooks/useAppSelector'; -import { colors } from '@/styles/colors'; -import CustomAvatar from '@components/CustomAvatar'; -import { useTranslation } from 'react-i18next'; - -const MembersFilterDropdown = () => { - const [selectedCount, setSelectedCount] = useState(0); - const membersInputRef = useRef(null); - - const members = useAppSelector(state => state.memberReducer.membersList); - - const { t } = useTranslation('task-list-filters'); - - const membersList = [ - ...members, - useAppSelector(state => state.memberReducer.owner), - ]; - - const themeMode = useAppSelector(state => state.themeReducer.mode); - - // this is for get the current string that type on search bar - const [searchQuery, setSearchQuery] = useState(''); - - // used useMemo hook for re render the list when searching - const filteredMembersData = useMemo(() => { - return membersList.filter(member => - member.memberName.toLowerCase().includes(searchQuery.toLowerCase()) - ); - }, [membersList, searchQuery]); - - // handle selected filters count - const handleSelectedFiltersCount = (checked: boolean) => { - setSelectedCount(prev => (checked ? prev + 1 : prev - 1)); - }; - - // custom dropdown content - const membersDropdownContent = ( - - - setSearchQuery(e.currentTarget.value)} - placeholder={t('searchInputPlaceholder')} - /> - - - {filteredMembersData.length ? ( - filteredMembersData.map(member => ( - - handleSelectedFiltersCount(e.target.checked)} - /> -
- -
- - {member.memberName} - - - {member.memberEmail} - - -
- )) - ) : ( - - )} -
-
-
- ); - - // function to focus members input - const handleMembersDropdownOpen = (open: boolean) => { - if (open) { - setTimeout(() => { - membersInputRef.current?.focus(); - }, 0); - } - }; - - return ( - membersDropdownContent} - onOpenChange={handleMembersDropdownOpen} - > - - - ); -}; - -export default MembersFilterDropdown; diff --git a/worklenz-frontend/src/pages/projects/project-view-1/taskList/taskListFilters/PriorityFilterDropdown.tsx b/worklenz-frontend/src/pages/projects/project-view-1/taskList/taskListFilters/PriorityFilterDropdown.tsx deleted file mode 100644 index dafc5af29..000000000 --- a/worklenz-frontend/src/pages/projects/project-view-1/taskList/taskListFilters/PriorityFilterDropdown.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { CaretDownFilled } from '@ant-design/icons'; -import { Badge, Button, Card, Checkbox, Dropdown, List, Space } from 'antd'; -import { useState } from 'react'; - -import { colors } from '@/styles/colors'; -import { useTranslation } from 'react-i18next'; -import { ITaskPriority } from '@/types/tasks/taskPriority.types'; -import { useAppSelector } from '@/hooks/useAppSelector'; - -const PriorityFilterDropdown = (props: { priorities: ITaskPriority[] }) => { - const [selectedCount, setSelectedCount] = useState(0); - - // localization - const { t } = useTranslation('task-list-filters'); - const themeMode = useAppSelector(state => state.themeReducer.mode); - - // handle selected filters count - const handleSelectedFiltersCount = (checked: boolean) => { - setSelectedCount(prev => (checked ? prev + 1 : prev - 1)); - }; - - // custom dropdown content - const priorityDropdownContent = ( - - - {props.priorities?.map(item => ( - - - handleSelectedFiltersCount(e.target.checked)} /> - - {item.name} - - - ))} - - - ); - - return ( - priorityDropdownContent} - > - - - ); -}; - -export default PriorityFilterDropdown; diff --git a/worklenz-frontend/src/pages/projects/project-view-1/taskList/taskListFilters/SearchDropdown.tsx b/worklenz-frontend/src/pages/projects/project-view-1/taskList/taskListFilters/SearchDropdown.tsx deleted file mode 100644 index 8eb0d3b0b..000000000 --- a/worklenz-frontend/src/pages/projects/project-view-1/taskList/taskListFilters/SearchDropdown.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { SearchOutlined } from '@ant-design/icons'; -import { Button, Card, Dropdown, Flex, Input, InputRef, Space } from 'antd'; -import { useRef } from 'react'; -import { useTranslation } from 'react-i18next'; - -const SearchDropdown = () => { - // localization - const { t } = useTranslation('task-list-filters'); - - const searchInputRef = useRef(null); - - const handleSearchInputChange = (e: React.ChangeEvent) => { - const value = e.target.value; - }; - - // custom dropdown content - const searchDropdownContent = ( - - - - - - - - - - ); - - // function to focus search input - const handleSearchDropdownOpen = (open: boolean) => { - if (open) { - setTimeout(() => { - searchInputRef.current?.focus(); - }, 0); - } - }; - - return ( - searchDropdownContent} - onOpenChange={handleSearchDropdownOpen} - > - - - ); -}; - -export default ShowFieldsFilterDropdown; diff --git a/worklenz-frontend/src/pages/projects/project-view-1/taskList/taskListFilters/SortFilterDropdown.tsx b/worklenz-frontend/src/pages/projects/project-view-1/taskList/taskListFilters/SortFilterDropdown.tsx deleted file mode 100644 index 85c2cfd53..000000000 --- a/worklenz-frontend/src/pages/projects/project-view-1/taskList/taskListFilters/SortFilterDropdown.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import { CaretDownFilled, SortAscendingOutlined, SortDescendingOutlined } from '@ant-design/icons'; -import { Badge, Button, Card, Checkbox, Dropdown, List, Space } from 'antd'; -import React, { useState } from 'react'; -import { colors } from '../../../../../styles/colors'; -import { useTranslation } from 'react-i18next'; -import { useAppSelector } from '@/hooks/useAppSelector'; - -const SortFilterDropdown = () => { - const [selectedCount, setSelectedCount] = useState(0); - const [sortState, setSortState] = useState>({}); - - const themeMode = useAppSelector(state => state.themeReducer.mode); - - // localization - const { t } = useTranslation('task-list-filters'); - - // handle selected filters count - const handleSelectedFiltersCount = (checked: boolean) => { - setSelectedCount(prev => (checked ? prev + 1 : prev - 1)); - }; - - // fuction for handle sort - const handleSort = (key: string) => { - setSortState(prev => ({ - ...prev, - [key]: prev[key] === 'ascending' ? 'descending' : 'ascending', - })); - }; - - // sort dropdown items - type SortFieldsType = { - key: string; - label: string; - }; - - const sortFieldsList: SortFieldsType[] = [ - { key: 'task', label: t('taskText') }, - { key: 'status', label: t('statusText') }, - { key: 'priority', label: t('priorityText') }, - { key: 'startDate', label: t('startDateText') }, - { key: 'endDate', label: t('endDateText') }, - { key: 'completedDate', label: t('completedDateText') }, - { key: 'createdDate', label: t('createdDateText') }, - { key: 'lastUpdated', label: t('lastUpdatedText') }, - ]; - - // custom dropdown content - const sortDropdownContent = ( - - - {sortFieldsList.map(item => ( - - - handleSelectedFiltersCount(e.target.checked)} - /> - {item.label} - - - - ); -}; - -export default SortFilterDropdown; diff --git a/worklenz-frontend/src/pages/projects/project-view-1/taskList/taskListFilters/TaskListFilters.tsx b/worklenz-frontend/src/pages/projects/project-view-1/taskList/taskListFilters/TaskListFilters.tsx deleted file mode 100644 index d794c4142..000000000 --- a/worklenz-frontend/src/pages/projects/project-view-1/taskList/taskListFilters/TaskListFilters.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { Checkbox, Flex, Typography } from 'antd'; -import SearchDropdown from './SearchDropdown'; -import SortFilterDropdown from './SortFilterDropdown'; -import LabelsFilterDropdown from './LabelsFilterDropdown'; -import MembersFilterDropdown from './MembersFilterDropdown'; -import GroupByFilterDropdown from './GroupByFilterDropdown'; -import ShowFieldsFilterDropdown from './ShowFieldsFilterDropdown'; -import PriorityFilterDropdown from './PriorityFilterDropdown'; -import { useTranslation } from 'react-i18next'; -import { useAppSelector } from '@/hooks/useAppSelector'; -import { useEffect } from 'react'; -import { fetchPriorities } from '@/features/taskAttributes/taskPrioritySlice'; -import { fetchLabels } from '@/features/taskAttributes/taskLabelSlice'; -import { useAppDispatch } from '@/hooks/useAppDispatch'; - -interface TaskListFiltersProps { - position: 'board' | 'list'; -} - -const TaskListFilters: React.FC = ({ position }) => { - const { t } = useTranslation('task-list-filters'); - const dispatch = useAppDispatch(); - - // Selectors - const priorities = useAppSelector(state => state.priorityReducer.priorities); - const labels = useAppSelector(state => state.taskLabelsReducer.labels); - - // Fetch initial data - useEffect(() => { - const fetchInitialData = async () => { - if (!priorities.length) { - await dispatch(fetchPriorities()); - } - if (!labels.length) { - await dispatch(fetchLabels()); - } - }; - - fetchInitialData(); - }, [dispatch, priorities.length, labels.length]); - - return ( - - - {/* search dropdown */} - - {/* sort dropdown */} - - {/* prioriy dropdown */} - - {/* labels dropdown */} - - {/* members dropdown */} - - {/* group by dropdown */} - {} - - - {position === 'list' && ( - - - - {t('showArchivedText')} - - {/* show fields dropdown */} - - - )} - - ); -}; - -export default TaskListFilters; diff --git a/worklenz-frontend/src/pages/projects/project-view-1/taskList/taskListTable/TaskListTable.tsx b/worklenz-frontend/src/pages/projects/project-view-1/taskList/taskListTable/TaskListTable.tsx deleted file mode 100644 index 218ccc2dd..000000000 --- a/worklenz-frontend/src/pages/projects/project-view-1/taskList/taskListTable/TaskListTable.tsx +++ /dev/null @@ -1,425 +0,0 @@ -import { useAppSelector } from '@/hooks/useAppSelector'; -import { columnList } from './columns/columnList'; -import AddTaskListRow from './taskListTableRows/AddTaskListRow'; -import { Checkbox, Flex, Tag, Tooltip } from 'antd'; -import React, { useEffect, useState } from 'react'; -import { useSelectedProject } from '@/hooks/useSelectedProject'; -import TaskCell from './taskListTableCells/TaskCell'; -import AddSubTaskListRow from './taskListTableRows/AddSubTaskListRow'; -import { colors } from '@/styles/colors'; -import TaskContextMenu from './contextMenu/TaskContextMenu'; -import { useAppDispatch } from '@/hooks/useAppDispatch'; -import { deselectAll } from '@features/projects/bulkActions/bulkActionSlice'; -import { useTranslation } from 'react-i18next'; -import { IProjectTask } from '@/types/project/projectTasksViewModel.types'; -import { HolderOutlined } from '@ant-design/icons'; - -const TaskListTable = ({ - taskList, - tableId, -}: { - taskList: IProjectTask[] | null; - tableId: string; -}) => { - // these states manage the necessary states - const [hoverRow, setHoverRow] = useState(null); - const [selectedRows, setSelectedRows] = useState([]); - const [selectedTaskId, setSelectedTaskId] = useState(null); - const [expandedTasks, setExpandedTasks] = useState([]); - const [isSelectAll, setIsSelectAll] = useState(false); - // context menu state - const [contextMenuVisible, setContextMenuVisible] = useState(false); - const [contextMenuPosition, setContextMenuPosition] = useState({ - x: 0, - y: 0, - }); - // state to check scroll - const [scrollingTables, setScrollingTables] = useState<{ - [key: string]: boolean; - }>({}); - - // localization - const { t } = useTranslation('task-list-table'); - - const dispatch = useAppDispatch(); - - // get data theme data from redux - const themeMode = useAppSelector(state => state.themeReducer.mode); - - // get the selected project details - const selectedProject = useSelectedProject(); - - // get columns list details - const columnsVisibility = useAppSelector( state => state.projectViewTaskListColumnsReducer.columnList ); - const visibleColumns = columnList.filter( - column => columnsVisibility[column.key as keyof typeof columnsVisibility] - ); - - // toggle subtasks visibility - const toggleTaskExpansion = (taskId: string) => { - setExpandedTasks(prev => - prev.includes(taskId) ? prev.filter(id => id !== taskId) : [...prev, taskId] - ); - }; - - // toggle all task select when header checkbox click - const toggleSelectAll = () => { - if (isSelectAll) { - setSelectedRows([]); - dispatch(deselectAll()); - } else { - const allTaskIds = - taskList?.flatMap(task => [ - task.id, - ...(task.sub_tasks?.map(subtask => subtask.id) || []), - ]) || []; - - // setSelectedRows(allTaskIds); - // dispatch(selectTaskIds(allTaskIds)); - // console.log('selected tasks and subtasks (all):', allTaskIds); - } - setIsSelectAll(!isSelectAll); - }; - - // toggle selected row - const toggleRowSelection = (task: IProjectTask) => { - setSelectedRows(prevSelectedRows => - prevSelectedRows.includes(task.id || '') - ? prevSelectedRows.filter(id => id !== task.id || '') - : [...prevSelectedRows, task.id || ''] - ); - }; - - // this use effect for realtime update the selected rows - useEffect(() => { - console.log('Selected tasks and subtasks:', selectedRows); - }, [selectedRows]); - - // select one row this triggers only in handle the context menu ==> righ click mouse event - const selectOneRow = (task: IProjectTask) => { - setSelectedRows([task.id || '']); - - // log the task object when selected - if (!selectedRows.includes(task.id || '')) { - console.log('Selected task:', task); - } - }; - - // handle custom task context menu - const handleContextMenu = (e: React.MouseEvent, task: IProjectTask) => { - e.preventDefault(); - setSelectedTaskId(task.id || ''); - selectOneRow(task); - setContextMenuPosition({ x: e.clientX, y: e.clientY }); - setContextMenuVisible(true); - }; - - // trigger the table scrolling - useEffect(() => { - const tableContainer = document.querySelector(`.tasklist-container-${tableId}`); - const handleScroll = () => { - if (tableContainer) { - setScrollingTables(prev => ({ - ...prev, - [tableId]: tableContainer.scrollLeft > 0, - })); - } - }; - tableContainer?.addEventListener('scroll', handleScroll); - return () => tableContainer?.removeEventListener('scroll', handleScroll); - }, [tableId]); - - // layout styles for table and the columns - const customBorderColor = themeMode === 'dark' && ' border-[#303030]'; - - const customHeaderColumnStyles = (key: string) => - `border px-2 text-left ${key === 'selector' && 'sticky left-0 z-10'} ${key === 'task' && `sticky left-[33px] z-10 after:content after:absolute after:top-0 after:-right-1 after:-z-10 after:h-[42px] after:w-1.5 after:bg-transparent ${scrollingTables[tableId] ? '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 customBodyColumnStyles = (key: string) => - `border px-2 ${key === 'selector' && 'sticky left-0 z-10'} ${key === 'task' && `sticky left-[33px] z-10 after:content after:absolute after:top-0 after:-right-1 after:-z-10 after:min-h-[40px] after:w-1.5 after:bg-transparent ${scrollingTables[tableId] ? 'after:bg-gradient-to-r after:from-[rgba(0,0,0,0.12)] after:to-transparent' : ''}`} ${themeMode === 'dark' ? 'bg-[#141414] border-[#303030]' : 'bg-white'}`; - - // function to render the column content based on column key - const renderColumnContent = ( - columnKey: string, - task: IProjectTask, - isSubtask: boolean = false - ) => { - switch (columnKey) { - // task ID column - case 'taskId': - return ( - - {task.task_key} - - ); - - // task column - case 'task': - return ( - // custom task cell component - - ); - - // description column - case 'description': - return ( -
- {/* - {task.description || ''} - */} -
- ); - - // progress column - case 'progress': { - return
; - } - - // members column - case 'members': - return
; - - // labels column - case 'labels': - return
; - - // phase column - case 'phases': - return
; - - // status column - case 'status': - return
; - - // priority column - case 'priority': - return
; - - // // time tracking column - // case 'timeTracking': - // return ( - // - // ); - - // estimation column - case 'estimation': - return
; - - // start date column - case 'startDate': - return
; - - // due date column - case 'dueDate': - return
; - - // completed date column - case 'completedDate': - return
; - - // created date column - case 'createdDate': - return
; - - // last updated column - case 'lastUpdated': - return
; - - // recorder column - case 'reporter': - return
; - - // default case for unsupported columns - default: - return null; - } - }; - - return ( -
-
- - - - {/* this cell render the select all task checkbox */} - - {/* other header cells */} - {visibleColumns.map(column => ( - - ))} - - - - {taskList?.map(task => ( - - handleContextMenu(e, task)} - className={`${taskList.length === 0 ? 'h-0' : 'h-[42px]'}`} - > - {/* this cell render the select the related task checkbox */} - - {/* other cells */} - {visibleColumns.map(column => ( - - ))} - - - {/* this is for sub tasks */} - {expandedTasks.includes(task.id || '') && - task?.sub_tasks?.map(subtask => ( - handleContextMenu(e, subtask)} - onMouseEnter={() => setHoverRow(subtask.id || '')} - onMouseLeave={() => setHoverRow(null)} - className={`${taskList.length === 0 ? 'h-0' : 'h-[42px]'}`} - > - {/* this cell render the select the related task checkbox */} - - - {/* other sub tasks cells */} - {visibleColumns.map(column => ( - - ))} - - ))} - - {expandedTasks.includes(task.id || '') && ( - - - - )} - - ))} - -
- - - - - {column.key === 'phases' - ? column.columnHeader - : t(`${column.columnHeader}Column`)} -
- - - toggleRowSelection(task)} - /> - - - {renderColumnContent(column.key, task)} -
- toggleRowSelection(subtask)} - /> - - {renderColumnContent(column.key, subtask, true)} -
- -
-
- - {/* add a main task to the table */} - - - {/* custom task context menu */} - setContextMenuVisible(false)} - /> -
- ); -}; - -export default TaskListTable; diff --git a/worklenz-frontend/src/pages/projects/project-view-1/taskList/taskListTable/TaskListTableWrapper.tsx b/worklenz-frontend/src/pages/projects/project-view-1/taskList/taskListTable/TaskListTableWrapper.tsx deleted file mode 100644 index d73c2714f..000000000 --- a/worklenz-frontend/src/pages/projects/project-view-1/taskList/taskListTable/TaskListTableWrapper.tsx +++ /dev/null @@ -1,216 +0,0 @@ -import { Badge, Button, Collapse, ConfigProvider, Dropdown, Flex, Input, Typography } from 'antd'; -import { useState } from 'react'; -import { TaskType } from '../../../../../types/task.types'; -import { EditOutlined, EllipsisOutlined, RetweetOutlined, RightOutlined } from '@ant-design/icons'; -import { colors } from '../../../../../styles/colors'; -import './taskListTableWrapper.css'; -import TaskListTable from './TaskListTable'; -import { MenuProps } from 'antd/lib'; -import { useAppSelector } from '@/hooks/useAppSelector'; -import { useTranslation } from 'react-i18next'; -import { IProjectTask } from '@/types/project/projectTasksViewModel.types'; - -type TaskListTableWrapperProps = { - taskList: IProjectTask[]; - tableId: string; - type: string; - name: string; - color: string; - statusCategory?: string | null; - priorityCategory?: string | null; - onRename?: (name: string) => void; - onStatusCategoryChange?: (category: string) => void; -}; - -const TaskListTableWrapper = ({ - taskList, - tableId, - name, - type, - color, - statusCategory = null, - priorityCategory = null, - onRename, - onStatusCategoryChange, -}: TaskListTableWrapperProps) => { - const [tableName, setTableName] = useState(name); - const [isRenaming, setIsRenaming] = useState(false); - const [isExpanded, setIsExpanded] = useState(true); - const [currentCategory, setCurrentCategory] = useState(statusCategory); - - // localization - const { t } = useTranslation('task-list-table'); - - // function to handle toggle expand - const handlToggleExpand = () => { - setIsExpanded(!isExpanded); - }; - - const themeMode = useAppSelector(state => state.themeReducer.mode); - - // this is for get the color for every typed tables - const getBgColorClassName = (type: string) => { - switch (type) { - case 'status': - if (currentCategory === 'todo') - return themeMode === 'dark' ? 'after:bg-[#3a3a3a]' : 'after:bg-[#d8d7d8]'; - else if (currentCategory === 'doing') - return themeMode === 'dark' ? 'after:bg-[#3d506e]' : 'after:bg-[#c0d5f6]'; - else if (currentCategory === 'done') - return themeMode === 'dark' ? 'after:bg-[#3b6149]' : 'after:bg-[#c2e4d0]'; - else return themeMode === 'dark' ? 'after:bg-[#3a3a3a]' : 'after:bg-[#d8d7d8]'; - - case 'priority': - if (priorityCategory === 'low') - return themeMode === 'dark' ? 'after:bg-[#3b6149]' : 'after:bg-[#c2e4d0]'; - else if (priorityCategory === 'medium') - return themeMode === 'dark' ? 'after:bg-[#916c33]' : 'after:bg-[#f9e3b1]'; - else if (priorityCategory === 'high') - return themeMode === 'dark' ? 'after:bg-[#8b3a3b]' : 'after:bg-[#f6bfc0]'; - else return themeMode === 'dark' ? 'after:bg-[#916c33]' : 'after:bg-[#f9e3b1]'; - default: - return ''; - } - }; - - // these codes only for status type tables - // function to handle rename this functionality only available for status type tables - const handleRename = () => { - if (onRename) { - onRename(tableName); - } - setIsRenaming(false); - }; - - // function to handle category change - const handleCategoryChange = (category: string) => { - setCurrentCategory(category); - if (onStatusCategoryChange) { - onStatusCategoryChange(category); - } - }; - - // find the available status for the currently active project - const statusList = useAppSelector(state => state.statusReducer.status); - - const getStatusColor = (status: string) => { - switch (status) { - case 'todo': - return '#d8d7d8'; - case 'doing': - return '#c0d5f6'; - case 'done': - return '#c2e4d0'; - default: - return '#d8d7d8'; - } - }; - - // dropdown options - const items: MenuProps['items'] = [ - { - key: '1', - icon: , - label: 'Rename', - onClick: () => setIsRenaming(true), - }, - { - key: '2', - icon: , - label: 'Change category', - children: statusList?.map(status => ({ - key: status.id, - label: ( - handleCategoryChange(status.category)}> - - {status.name} - - ), - })), - }, - ]; - - return ( - - - - - {type === 'status' && !isRenaming && ( - - - ); - }; - - // show expand button on hover for tasks without subtasks - const renderToggleButtonForNonSubtasks = (taskId: string, isSubTask: boolean) => { - return !isSubTask ? ( - - ) : ( -
- ); - }; - - // render the double arrow icon and count label for tasks with subtasks - const renderSubtasksCountLabel = (taskId: string, isSubTask: boolean, subTasksCount: number) => { - return ( - !isSubTask && ( - - ) - ); - }; - - return ( - - - {!!task?.sub_tasks?.length && task.id ? ( - renderToggleButtonForHasSubTasks(task.id, !!task?.sub_tasks?.length) - ) : ( -
- )} - - {isSubTask && } - - {task.name} - - {renderSubtasksCountLabel(task.id || '', isSubTask, task?.sub_tasks?.length || 0)} -
- - -
- ); -}; - -export default TaskCell; diff --git a/worklenz-frontend/src/pages/projects/project-view-1/taskList/taskListTable/taskListTableCells/TaskProgress.css b/worklenz-frontend/src/pages/projects/project-view-1/taskList/taskListTable/taskListTableCells/TaskProgress.css deleted file mode 100644 index 64d0697c6..000000000 --- a/worklenz-frontend/src/pages/projects/project-view-1/taskList/taskListTable/taskListTableCells/TaskProgress.css +++ /dev/null @@ -1,22 +0,0 @@ -/* Set the stroke width to 9px for the progress circle */ -.task-progress.ant-progress-circle .ant-progress-circle-path { - stroke-width: 9px !important; /* Adjust the stroke width */ -} - -/* Adjust the inner check mark for better alignment and visibility */ -.task-progress.ant-progress-circle.ant-progress-status-success .ant-progress-inner .anticon-check { - font-size: 8px; /* Adjust font size for the check mark */ - color: green; /* Optional: Set a color */ - transform: translate(-50%, -50%); /* Center align */ - position: absolute; - top: 50%; - left: 50%; - padding: 0; - width: 8px; -} - -/* Adjust the text inside the progress circle */ -.task-progress.ant-progress-circle .ant-progress-text { - font-size: 10px; /* Ensure the text size fits well */ - line-height: 1; -} diff --git a/worklenz-frontend/src/pages/projects/project-view-1/taskList/taskListTable/taskListTableCells/TaskProgress.tsx b/worklenz-frontend/src/pages/projects/project-view-1/taskList/taskListTable/taskListTableCells/TaskProgress.tsx deleted file mode 100644 index 24df83f92..000000000 --- a/worklenz-frontend/src/pages/projects/project-view-1/taskList/taskListTable/taskListTableCells/TaskProgress.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { Progress, Tooltip } from 'antd'; -import React from 'react'; -import './TaskProgress.css'; - -type TaskProgressProps = { - progress: number; - numberOfSubTasks: number; -}; - -const TaskProgress = ({ progress = 0, numberOfSubTasks = 0 }: TaskProgressProps) => { - const totalTasks = numberOfSubTasks + 1; - const completedTasks = 0; - - const size = progress === 100 ? 21 : 26; - - return ( - - - - ); -}; - -export default TaskProgress; diff --git a/worklenz-frontend/src/pages/projects/project-view-1/taskList/taskListTable/taskListTableCells/TimeTracker.tsx b/worklenz-frontend/src/pages/projects/project-view-1/taskList/taskListTable/taskListTableCells/TimeTracker.tsx deleted file mode 100644 index 03496d0a8..000000000 --- a/worklenz-frontend/src/pages/projects/project-view-1/taskList/taskListTable/taskListTableCells/TimeTracker.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import React from 'react'; -import { Divider, Empty, Flex, Popover, Typography } from 'antd'; -import { PlayCircleFilled } from '@ant-design/icons'; -import { colors } from '../../../../../../styles/colors'; -import CustomAvatar from '../../../../../../components/CustomAvatar'; -import { mockTimeLogs } from './mockTimeLogs'; - -type TimeTrackerProps = { - taskId: string | null | undefined; - initialTime?: number; -}; - -const TimeTracker = ({ taskId, initialTime = 0 }: TimeTrackerProps) => { - const minutes = Math.floor(initialTime / 60); - const seconds = initialTime % 60; - const formattedTime = `${minutes}m ${seconds}s`; - - const timeTrackingLogCard = - initialTime > 0 ? ( - - {mockTimeLogs.map(log => ( - - - - - - - {log.username} - {` logged ${log.duration} ${ - log.via ? `via ${log.via}` : '' - }`} - - - {log.date} - - - - - - ))} - - ) : ( - - ); - - return ( - - - - Time Tracking Log - - - } - content={timeTrackingLogCard} - trigger="click" - placement="bottomRight" - > - {formattedTime} - - - ); -}; - -export default TimeTracker; diff --git a/worklenz-frontend/src/pages/projects/project-view-1/taskList/taskListTable/taskListTableRows/AddSubTaskListRow.tsx b/worklenz-frontend/src/pages/projects/project-view-1/taskList/taskListTable/taskListTableRows/AddSubTaskListRow.tsx deleted file mode 100644 index 5fc71fdf6..000000000 --- a/worklenz-frontend/src/pages/projects/project-view-1/taskList/taskListTable/taskListTableRows/AddSubTaskListRow.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { Input } from 'antd'; -import React, { useState } from 'react'; -import { useAppSelector } from '@/hooks/useAppSelector'; -import { colors } from '@/styles/colors'; -import { useTranslation } from 'react-i18next'; - -const AddSubTaskListRow = () => { - const [isEdit, setIsEdit] = useState(false); - - // localization - const { t } = useTranslation('task-list-table'); - - // get data theme data from redux - const themeMode = useAppSelector(state => state.themeReducer.mode); - const customBorderColor = themeMode === 'dark' && ' border-[#303030]'; - - return ( -
- {isEdit ? ( - setIsEdit(false)} - /> - ) : ( - setIsEdit(true)} - className="w-[300px] border-none" - value={t('addSubTaskText')} - /> - )} -
- ); -}; - -export default AddSubTaskListRow; diff --git a/worklenz-frontend/src/pages/projects/project-view-1/taskList/taskListTable/taskListTableRows/AddTaskListRow.tsx b/worklenz-frontend/src/pages/projects/project-view-1/taskList/taskListTable/taskListTableRows/AddTaskListRow.tsx deleted file mode 100644 index fa7e9f852..000000000 --- a/worklenz-frontend/src/pages/projects/project-view-1/taskList/taskListTable/taskListTableRows/AddTaskListRow.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { Input } from 'antd'; -import React, { useState } from 'react'; -import { useAppSelector } from '@/hooks/useAppSelector'; -import { colors } from '@/styles/colors'; -import { useTranslation } from 'react-i18next'; - -const AddTaskListRow = () => { - const [isEdit, setIsEdit] = useState(false); - - // localization - const { t } = useTranslation('task-list-table'); - - // get data theme data from redux - const themeMode = useAppSelector(state => state.themeReducer.mode); - const customBorderColor = themeMode === 'dark' && ' border-[#303030]'; - - return ( -
- {isEdit ? ( - setIsEdit(false)} - /> - ) : ( - setIsEdit(true)} - className="w-[300px] border-none" - value={t('addTaskText')} - /> - )} -
- ); -}; - -export default AddTaskListRow; diff --git a/worklenz-frontend/src/pages/projects/project-view-1/taskList/taskListTable/taskListTableWrapper.css b/worklenz-frontend/src/pages/projects/project-view-1/taskList/taskListTable/taskListTableWrapper.css deleted file mode 100644 index 6e5ca7c8e..000000000 --- a/worklenz-frontend/src/pages/projects/project-view-1/taskList/taskListTable/taskListTableWrapper.css +++ /dev/null @@ -1,15 +0,0 @@ -/* custom collapse styles for content box and the left border */ -.ant-collapse-header { - margin-bottom: 6px !important; -} - -.custom-collapse-content-box .ant-collapse-content-box { - padding: 0 !important; -} - -:where(.css-dev-only-do-not-override-1w6wsvq).ant-collapse-ghost - > .ant-collapse-item - > .ant-collapse-content - > .ant-collapse-content-box { - padding: 0; -} diff --git a/worklenz-frontend/src/pages/projects/project-view-1/updates/project-view-updates.css b/worklenz-frontend/src/pages/projects/project-view-1/updates/project-view-updates.css deleted file mode 100644 index 9bf0cab2b..000000000 --- a/worklenz-frontend/src/pages/projects/project-view-1/updates/project-view-updates.css +++ /dev/null @@ -1,19 +0,0 @@ -.mentions-light .mentions { - background-color: #e9e2e2; - font-weight: 500; - border-radius: 4px; - padding: 2px 4px; -} - -.mentions-dark .mentions { - background-color: #2c2c2c; - font-weight: 500; - border-radius: 4px; - padding: 2px 4px; -} - -.tooltip-comment .mentions { - background-color: transparent; - font-weight: 500; - padding: 0; -} diff --git a/worklenz-frontend/src/pages/projects/project-view-1/updates/project-view-updates.tsx b/worklenz-frontend/src/pages/projects/project-view-1/updates/project-view-updates.tsx deleted file mode 100644 index 684cb5f95..000000000 --- a/worklenz-frontend/src/pages/projects/project-view-1/updates/project-view-updates.tsx +++ /dev/null @@ -1,263 +0,0 @@ -import { Button, ConfigProvider, Flex, Form, Mentions, Skeleton, Space, Tooltip, Typography } from 'antd'; -import { useEffect, useState, useCallback } from 'react'; -import { useTranslation } from 'react-i18next'; -import DOMPurify from 'dompurify'; -import { useParams } from 'react-router-dom'; - -import CustomAvatar from '@components/CustomAvatar'; -import { colors } from '@/styles/colors'; -import { - IMentionMemberSelectOption, - IMentionMemberViewModel, -} from '@/types/project/projectComments.types'; -import { projectsApiService } from '@/api/projects/projects.api.service'; -import { projectCommentsApiService } from '@/api/projects/comments/project-comments.api.service'; -import { IProjectUpdateCommentViewModel } from '@/types/project/project.types'; -import { calculateTimeDifference } from '@/utils/calculate-time-difference'; -import { getUserSession } from '@/utils/session-helper'; -import './project-view-updates.css'; -import { useAppSelector } from '@/hooks/useAppSelector'; -import { DeleteOutlined } from '@ant-design/icons'; - -const MAX_COMMENT_LENGTH = 2000; - -const ProjectViewUpdates = () => { - const { projectId } = useParams(); - const [characterLength, setCharacterLength] = useState(0); - const [isCommentBoxExpand, setIsCommentBoxExpand] = useState(false); - const [members, setMembers] = useState([]); - const [selectedMembers, setSelectedMembers] = useState<{ id: string; name: string }[]>([]); - const [comments, setComments] = useState([]); - const [isLoading, setIsLoading] = useState(false); - const [isLoadingComments, setIsLoadingComments] = useState(false); - const [isSubmitting, setIsSubmitting] = useState(false); - const [commentValue, setCommentValue] = useState(''); - const theme = useAppSelector(state => state.themeReducer.mode); - const { refreshTimestamp } = useAppSelector(state => state.projectReducer); - - const { t } = useTranslation('project-view-updates'); - const [form] = Form.useForm(); - - const getMembers = useCallback(async () => { - if (!projectId) return; - try { - setIsLoading(true); - const res = await projectCommentsApiService.getMentionMembers(projectId, 1, 15, null, null, null); - if (res.done) { - setMembers(res.body as IMentionMemberViewModel[]); - } - } catch (error) { - console.error('Failed to fetch members:', error); - } finally { - setIsLoading(false); - } - }, [projectId]); - - const getComments = useCallback(async () => { - if (!projectId) return; - try { - setIsLoadingComments(true); - const res = await projectCommentsApiService.getByProjectId(projectId); - if (res.done) { - setComments(res.body); - } - } catch (error) { - console.error('Failed to fetch comments:', error); - } finally { - setIsLoadingComments(false); - } - }, [projectId]); - - const handleAddComment = async () => { - if (!projectId || characterLength === 0) return; - - try { - setIsSubmitting(true); - - if (!commentValue) { - console.error('Comment content is empty'); - return; - } - - const body = { - project_id: projectId, - team_id: getUserSession()?.team_id, - content: commentValue.trim(), - mentions: selectedMembers - }; - - const res = await projectCommentsApiService.createProjectComment(body); - if (res.done) { - await getComments(); - handleCancel(); - } - } catch (error) { - console.error('Failed to add comment:', error); - } finally { - setIsSubmitting(false); - setCommentValue(''); - - - } - }; - - useEffect(() => { - void getMembers(); - void getComments(); - }, [getMembers, getComments,refreshTimestamp]); - - const handleCancel = useCallback(() => { - form.resetFields(['comment']); - setCharacterLength(0); - setIsCommentBoxExpand(false); - setSelectedMembers([]); - }, [form]); - - const mentionsOptions = - members?.map(member => ({ - value: member.id, - label: member.name, - })) ?? []; - - const memberSelectHandler = useCallback((member: IMentionMemberSelectOption) => { - if (!member?.value || !member?.label) return; - setSelectedMembers(prev => - prev.some(mention => mention.id === member.value) - ? prev - : [...prev, { id: member.value, name: member.label }] - ); - - setCommentValue(prev => { - const parts = prev.split('@'); - const lastPart = parts[parts.length - 1]; - const mentionText = member.label; - // Keep only the part before the @ and add the new mention - return prev.slice(0, prev.length - lastPart.length) + mentionText; - }); - }, []); - - const handleCommentChange = useCallback((value: string) => { - // Only update the value without trying to replace mentions - setCommentValue(value); - setCharacterLength(value.trim().length); - }, []); - - const handleDeleteComment = useCallback( - async (commentId: string | undefined) => { - if (!commentId) return; - try { - const res = await projectCommentsApiService.deleteComment(commentId); - if (res.done) { - void getComments(); - } - } catch (error) { - console.error('Failed to delete comment:', error); - } - }, - [getComments] - ); - - return ( - - - { - isLoadingComments ? ( - - ): - comments.map(comment => ( - - - - - - {comment.created_by || ''} - - - - {calculateTimeDifference(comment.created_at || '')} - - - - -
- - - - - - - )} - - - ); -}; - -export default ProjectViewUpdates; diff --git a/worklenz-frontend/src/pages/projects/project-view-1/workload/ProjectViewWorkload.tsx b/worklenz-frontend/src/pages/projects/project-view-1/workload/ProjectViewWorkload.tsx deleted file mode 100644 index c36ea1483..000000000 --- a/worklenz-frontend/src/pages/projects/project-view-1/workload/ProjectViewWorkload.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import React from 'react'; - -const ProjectViewWorkload = () => { - return
ProjectViewWorkload
; -}; - -export default ProjectViewWorkload; diff --git a/worklenz-frontend/src/pages/projects/project-view-1/workload/projectViewWorkload.css b/worklenz-frontend/src/pages/projects/project-view-1/workload/projectViewWorkload.css deleted file mode 100644 index e69de29bb..000000000 diff --git a/worklenz-frontend/src/pages/projects/projectView/taskList/components/task-group/task-group.tsx b/worklenz-frontend/src/pages/projects/projectView/taskList/components/task-group/task-group.tsx deleted file mode 100644 index e5800fe48..000000000 --- a/worklenz-frontend/src/pages/projects/projectView/taskList/components/task-group/task-group.tsx +++ /dev/null @@ -1,241 +0,0 @@ -import React, { useState, useMemo } from 'react'; -import { useTranslation } from 'react-i18next'; -import { useDroppable } from '@dnd-kit/core'; -import Flex from 'antd/es/flex'; -import Badge from 'antd/es/badge'; -import Button from 'antd/es/button'; -import Dropdown from 'antd/es/dropdown'; -import Input from 'antd/es/input'; -import Typography from 'antd/es/typography'; -import { MenuProps } from 'antd/es/menu'; -import { EditOutlined, EllipsisOutlined, RetweetOutlined, RightOutlined } from '@ant-design/icons'; - -import { colors } from '@/styles/colors'; -import { useAppSelector } from '@/hooks/useAppSelector'; -import { useAppDispatch } from '@/hooks/useAppDispatch'; -import { IProjectTask } from '@/types/project/projectTasksViewModel.types'; -import { ITaskListGroup } from '@/types/tasks/taskList.types'; -import Collapsible from '@/components/collapsible/collapsible'; -import TaskListTable from '../../task-list-table/task-list-table'; -import { IGroupBy, updateTaskGroupColor } from '@/features/tasks/tasks.slice'; -import { useAuthService } from '@/hooks/useAuth'; -import { statusApiService } from '@/api/taskAttributes/status/status.api.service'; -import { phasesApiService } from '@/api/taskAttributes/phases/phases.api.service'; -import { ITaskPhase } from '@/types/tasks/taskPhase.types'; -import { fetchPhasesByProjectId } from '@/features/projects/singleProject/phase/phases.slice'; -import { fetchStatuses } from '@/features/taskAttributes/taskStatusSlice'; -import { useMixpanelTracking } from '@/hooks/useMixpanelTracking'; -import { evt_project_board_column_setting_click } from '@/shared/worklenz-analytics-events'; -import { ALPHA_CHANNEL } from '@/shared/constants'; -import useIsProjectManager from '@/hooks/useIsProjectManager'; -import logger from '@/utils/errorLogger'; - -interface TaskGroupProps { - taskGroup: ITaskListGroup; - groupBy: string; - color: string; - activeId?: string | null; -} - -const TaskGroup: React.FC = ({ - taskGroup, - groupBy, - color, - activeId -}) => { - const { t } = useTranslation('task-list-table'); - const dispatch = useAppDispatch(); - const { trackMixpanelEvent } = useMixpanelTracking(); - const isProjectManager = useIsProjectManager(); - const currentSession = useAuthService().getCurrentSession(); - - const [isExpanded, setIsExpanded] = useState(true); - const [isRenaming, setIsRenaming] = useState(false); - const [groupName, setGroupName] = useState(taskGroup.name || ''); - - const { projectId } = useAppSelector((state: any) => state.projectReducer); - const themeMode = useAppSelector((state: any) => state.themeReducer.mode); - - // Memoize droppable configuration - const { setNodeRef } = useDroppable({ - id: taskGroup.id, - data: { - type: 'group', - groupId: taskGroup.id, - }, - }); - - // Memoize task count - const taskCount = useMemo(() => taskGroup.tasks?.length || 0, [taskGroup.tasks]); - - // Memoize dropdown items - const dropdownItems: MenuProps['items'] = useMemo(() => { - if (groupBy !== IGroupBy.STATUS || !isProjectManager) return []; - - return [ - { - key: 'rename', - label: t('renameText'), - icon: , - onClick: () => setIsRenaming(true), - }, - { - key: 'change-category', - label: t('changeCategoryText'), - icon: , - children: [ - { - key: 'todo', - label: t('todoText'), - onClick: () => handleStatusCategoryChange('0'), - }, - { - key: 'doing', - label: t('doingText'), - onClick: () => handleStatusCategoryChange('1'), - }, - { - key: 'done', - label: t('doneText'), - onClick: () => handleStatusCategoryChange('2'), - }, - ], - }, - ]; - }, [groupBy, isProjectManager, t]); - - const handleStatusCategoryChange = async (category: string) => { - if (!projectId || !taskGroup.id) return; - - try { - await statusApiService.updateStatus({ - id: taskGroup.id, - category_id: category, - project_id: projectId, - }); - - dispatch(fetchStatuses()); - trackMixpanelEvent(evt_project_board_column_setting_click, { - column_id: taskGroup.id, - action: 'change_category', - category, - }); - } catch (error) { - logger.error('Error updating status category:', error); - } - }; - - const handleRename = async () => { - if (!projectId || !taskGroup.id || !groupName.trim()) return; - - try { - if (groupBy === IGroupBy.STATUS) { - await statusApiService.updateStatus({ - id: taskGroup.id, - name: groupName.trim(), - project_id: projectId, - }); - dispatch(fetchStatuses()); - } else if (groupBy === IGroupBy.PHASE) { - const phaseData: ITaskPhase = { - id: taskGroup.id, - name: groupName.trim(), - project_id: projectId, - color_code: taskGroup.color_code, - }; - await phasesApiService.updatePhase(phaseData); - dispatch(fetchPhasesByProjectId(projectId)); - } - - setIsRenaming(false); - } catch (error) { - logger.error('Error renaming group:', error); - } - }; - - const handleColorChange = async (newColor: string) => { - if (!projectId || !taskGroup.id) return; - - try { - const baseColor = newColor.endsWith(ALPHA_CHANNEL) - ? newColor.slice(0, -ALPHA_CHANNEL.length) - : newColor; - - if (groupBy === IGroupBy.PHASE) { - const phaseData: ITaskPhase = { - id: taskGroup.id, - name: taskGroup.name || '', - project_id: projectId, - color_code: baseColor, - }; - await phasesApiService.updatePhase(phaseData); - dispatch(fetchPhasesByProjectId(projectId)); - } - - dispatch(updateTaskGroupColor({ - groupId: taskGroup.id, - color: baseColor, - })); - } catch (error) { - logger.error('Error updating group color:', error); - } - }; - - return ( -
- - {/* Group Header */} - - - - {dropdownItems.length > 0 && !isRenaming && ( - -
- ); -}; - -export default React.memo(TaskGroup); \ No newline at end of file diff --git a/worklenz-frontend/src/pages/settings/project-templates/projectTemplateEditView/ProjectTemplateEditView.tsx b/worklenz-frontend/src/pages/settings/project-templates/projectTemplateEditView/ProjectTemplateEditView.tsx index de56a6e8b..ba1d7b031 100644 --- a/worklenz-frontend/src/pages/settings/project-templates/projectTemplateEditView/ProjectTemplateEditView.tsx +++ b/worklenz-frontend/src/pages/settings/project-templates/projectTemplateEditView/ProjectTemplateEditView.tsx @@ -1,18 +1,19 @@ import { Button, Flex, Select, Typography } from 'antd'; import { useState } from 'react'; -import StatusGroupTables from '../../../projects/project-view-1/taskList/statusTables/StatusGroupTables'; +import TaskGroupList from '@/pages/projects/projectView/taskList/groupTables/TaskGroupList'; import { TaskType } from '../../../../types/task.types'; import { useAppSelector } from '../../../../hooks/useAppSelector'; import { PageHeader } from '@ant-design/pro-components'; import { ArrowLeftOutlined, CaretDownFilled } from '@ant-design/icons'; import { useNavigate, useParams } from 'react-router-dom'; -import SearchDropdown from '../../../projects/project-view-1/taskList/taskListFilters/SearchDropdown'; +import TaskListFilters from '@/pages/projects/projectView/taskList/task-list-filters/task-list-filters'; import { useSelectedProject } from '../../../../hooks/useSelectedProject'; import { useTranslation } from 'react-i18next'; import { toggleDrawer as togglePhaseDrawer } from '../../../../features/projects/singleProject/phase/phases.slice'; import { toggleDrawer } from '../../../../features/projects/status/StatusSlice'; import { useAppDispatch } from '../../../../hooks/useAppDispatch'; import React from 'react'; +import { ITaskListGroup } from '@/types/tasks/taskList.types'; const PhaseDrawer = React.lazy(() => import('@features/projects/singleProject/phase/PhaseDrawer')); const StatusDrawer = React.lazy( @@ -20,7 +21,8 @@ const StatusDrawer = React.lazy( ); const ProjectTemplateEditView = () => { - const dataSource: TaskType[] = useAppSelector(state => state.taskReducer.tasks); + const dataSource: ITaskListGroup[] = useAppSelector(state => state.taskReducer.taskGroups); + const groupBy = useAppSelector(state => state.taskReducer.groupBy); const dispatch = useAppDispatch(); const navigate = useNavigate(); const { templateId, templateName } = useParams(); @@ -40,7 +42,7 @@ const ProjectTemplateEditView = () => { //get phases details from phases slice const phase = useAppSelector(state => state.phaseReducer.phaseList).find( - phase => phase.projectId === selectedProject?.id + phase => phase.id === selectedProject?.id ) || null; const groupDropdownMenuItems = [ @@ -49,7 +51,7 @@ const ProjectTemplateEditView = () => { { key: 'phase', value: 'phase', - label: phase ? phase?.phase : t('phaseText'), + label: phase ? phase?.name : t('phaseText'), }, ]; return ( @@ -68,7 +70,7 @@ const ProjectTemplateEditView = () => { /> - + {t('groupByText')}: e.stopPropagation()} placeholder={t('searchByCategory')} value={searchText} onChange={e => setSearchText(e.target.value)} + style={{ fontSize: '14px' }} />
+ + {/* Actions */} {categories.length > 0 && ( -
- e.stopPropagation()} - onChange={handleSelectAllChange} - checked={selectAll} - > - {t('selectAll')} - +
+ + + + +
)} -
+ + {/* No Category Option */} +
e.stopPropagation()} checked={noCategory} onChange={e => handleNoCategoryChange(e.target.checked)} + style={{ fontSize: '14px' }} > - {t('noCategory')} + {t('noCategory')} + {noCategory && ( + + )}
- + + + + {/* Items */}
{
e.stopPropagation()} checked={item.selected} onChange={e => handleCheckboxChange(item.id || '', e.target.checked)} + style={{ fontSize: '14px' }} > - {item.name} + {item.name} + {item.selected && ( + + )}
)) ) : ( -
+
{t('noCategories')}
)} @@ -126,8 +242,45 @@ const Categories: React.FC = () => { } }} > -
diff --git a/worklenz-frontend/src/pages/reporting/timeReports/page-header/members.tsx b/worklenz-frontend/src/pages/reporting/timeReports/page-header/members.tsx index 499a8b1ae..b60b411e8 100644 --- a/worklenz-frontend/src/pages/reporting/timeReports/page-header/members.tsx +++ b/worklenz-frontend/src/pages/reporting/timeReports/page-header/members.tsx @@ -1,10 +1,10 @@ -import React, { useState } from 'react'; +import React, { useState, useMemo } from 'react'; import { useAppDispatch } from '@/hooks/useAppDispatch'; import { useAppSelector } from '@/hooks/useAppSelector'; import { setSelectOrDeselectAllMembers, setSelectOrDeselectMember } from '@/features/reporting/time-reports/time-reports-overview.slice'; -import { Button, Checkbox, Divider, Dropdown, Input, Avatar, theme } from 'antd'; +import { Button, Checkbox, Divider, Dropdown, Input, Avatar, theme, Space } from 'antd'; import { CheckboxChangeEvent } from 'antd/es/checkbox'; -import { CaretDownFilled } from '@ant-design/icons'; +import { CaretDownFilled, FilterOutlined, CheckCircleFilled } from '@ant-design/icons'; import { useTranslation } from 'react-i18next'; const Members: React.FC = () => { @@ -16,11 +16,36 @@ const Members: React.FC = () => { const [searchText, setSearchText] = useState(''); const [selectAll, setSelectAll] = useState(true); + // Calculate active filters count + const activeFiltersCount = useMemo(() => { + return members.filter(member => member.selected).length; + }, [members]); + + // Check if all options are selected + const isAllSelected = members.length > 0 && members.every(member => member.selected); + const isNoneSelected = members.length > 0 && !members.some(member => member.selected); + // Filter members based on search text const filteredMembers = members.filter(member => member.name?.toLowerCase().includes(searchText.toLowerCase()) ); + // Theme-aware colors + const isDark = token.colorBgContainer !== '#ffffff'; + const colors = { + headerText: isDark ? token.colorTextSecondary : '#262626', + borderColor: isDark ? token.colorBorder : '#f0f0f0', + linkActive: token.colorPrimary, + linkDisabled: isDark ? token.colorTextDisabled : '#d9d9d9', + successColor: token.colorSuccess, + errorColor: token.colorError, + buttonBorder: activeFiltersCount > 0 ? token.colorPrimary : token.colorBorder, + buttonText: activeFiltersCount > 0 ? token.colorPrimary : token.colorTextSecondary, + buttonBg: activeFiltersCount > 0 ? (isDark ? token.colorPrimaryBg : '#f6ffed') : 'transparent', + dropdownBg: token.colorBgElevated, + dropdownBorder: token.colorBorderSecondary, + }; + // Handle checkbox change for individual members const handleCheckboxChange = (id: string, checked: boolean) => { dispatch(setSelectOrDeselectMember({ id, selected: checked })); @@ -33,6 +58,25 @@ const Members: React.FC = () => { dispatch(setSelectOrDeselectAllMembers(isChecked)); }; + // Handle select all button click + const handleSelectAllClick = () => { + const newValue = !isAllSelected; + setSelectAll(newValue); + dispatch(setSelectOrDeselectAllMembers(newValue)); + }; + + // Handle clear all + const handleClearAll = () => { + setSelectAll(false); + dispatch(setSelectOrDeselectAllMembers(false)); + }; + + const getButtonText = () => { + if (isNoneSelected) return t('members'); + if (isAllSelected) return `All ${t('members')}`; + return `${t('members')} (${activeFiltersCount})`; + }; + return ( { dropdownRender={() => (
-
+ {/* Header */} +
+ {t('searchByMember')} +
+ + {/* Search */} +
e.stopPropagation()} placeholder={t('searchByMember')} value={searchText} onChange={e => setSearchText(e.target.value)} + style={{ fontSize: '14px' }} />
-
- e.stopPropagation()} - onChange={handleSelectAllChange} - checked={selectAll} - > - {t('selectAll')} - + + {/* Actions */} +
+ + + + +
- + + + + {/* Items */}
{
- + e.stopPropagation()} checked={member.selected} onChange={e => handleCheckboxChange(member.id, e.target.checked)} + style={{ fontSize: '14px' }} > - {member.name} + {member.name} + {member.selected && ( + + )}
))}
)} > - ); diff --git a/worklenz-frontend/src/pages/reporting/timeReports/page-header/projects.tsx b/worklenz-frontend/src/pages/reporting/timeReports/page-header/projects.tsx index bb1712cf4..5502fa6b5 100644 --- a/worklenz-frontend/src/pages/reporting/timeReports/page-header/projects.tsx +++ b/worklenz-frontend/src/pages/reporting/timeReports/page-header/projects.tsx @@ -1,10 +1,10 @@ import { setSelectOrDeselectAllProjects, setSelectOrDeselectProject } from '@/features/reporting/time-reports/time-reports-overview.slice'; import { useAppDispatch } from '@/hooks/useAppDispatch'; import { useAppSelector } from '@/hooks/useAppSelector'; -import { CaretDownFilled } from '@ant-design/icons'; -import { Button, Checkbox, Divider, Dropdown, Input, theme } from 'antd'; +import { CaretDownFilled, FilterOutlined, CheckCircleFilled } from '@ant-design/icons'; +import { Button, Checkbox, Divider, Dropdown, Input, theme, Space } from 'antd'; import { CheckboxChangeEvent } from 'antd/es/checkbox'; -import React, { useState } from 'react'; +import React, { useState, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; const Projects: React.FC = () => { @@ -17,11 +17,36 @@ const Projects: React.FC = () => { const { projects, loadingProjects } = useAppSelector(state => state.timeReportsOverviewReducer); const { token } = theme.useToken(); + // Calculate active filters count + const activeFiltersCount = useMemo(() => { + return projects.filter(project => project.selected).length; + }, [projects]); + + // Check if all options are selected + const isAllSelected = projects.length > 0 && projects.every(project => project.selected); + const isNoneSelected = projects.length > 0 && !projects.some(project => project.selected); + // Filter items based on search text const filteredItems = projects.filter(item => item.name?.toLowerCase().includes(searchText.toLowerCase()) ); + // Theme-aware colors + const isDark = token.colorBgContainer !== '#ffffff'; + const colors = { + headerText: isDark ? token.colorTextSecondary : '#262626', + borderColor: isDark ? token.colorBorder : '#f0f0f0', + linkActive: token.colorPrimary, + linkDisabled: isDark ? token.colorTextDisabled : '#d9d9d9', + successColor: token.colorSuccess, + errorColor: token.colorError, + buttonBorder: activeFiltersCount > 0 ? token.colorPrimary : token.colorBorder, + buttonText: activeFiltersCount > 0 ? token.colorPrimary : token.colorTextSecondary, + buttonBg: activeFiltersCount > 0 ? (isDark ? token.colorPrimaryBg : '#f6ffed') : 'transparent', + dropdownBg: token.colorBgElevated, + dropdownBorder: token.colorBorderSecondary, + }; + // Handle checkbox change for individual items const handleCheckboxChange = (key: string, checked: boolean) => { dispatch(setSelectOrDeselectProject({ id: key, selected: checked })); @@ -34,6 +59,25 @@ const Projects: React.FC = () => { dispatch(setSelectOrDeselectAllProjects(isChecked)); }; + // Handle select all button click + const handleSelectAllClick = () => { + const newValue = !isAllSelected; + setSelectAll(newValue); + dispatch(setSelectOrDeselectAllProjects(newValue)); + }; + + // Handle clear all + const handleClearAll = () => { + setSelectAll(false); + dispatch(setSelectOrDeselectAllProjects(false)); + }; + + const getButtonText = () => { + if (isNoneSelected) return t('projects'); + if (isAllSelected) return `All ${t('projects')}`; + return `${t('projects')} (${activeFiltersCount})`; + }; + return (
{ trigger={['click']} dropdownRender={() => (
-
+ {/* Header */} +
+ {t('searchByProject')} +
+ + {/* Search */} +
e.stopPropagation()} placeholder={t('searchByProject')} value={searchText} onChange={e => setSearchText(e.target.value)} + style={{ fontSize: '14px' }} />
-
- e.stopPropagation()} - onChange={handleSelectAllChange} - checked={selectAll} - > - {t('selectAll')} - + + {/* Actions */} +
+ + + + +
- + + + + {/* Items */}
{
e.stopPropagation()} checked={item.selected} onChange={e => handleCheckboxChange(item.id || '', e.target.checked)} + style={{ fontSize: '14px' }} > - {item.name} + {item.name} + {item.selected && ( + + )}
))}
@@ -102,8 +198,45 @@ const Projects: React.FC = () => { } }} > -
diff --git a/worklenz-frontend/src/pages/reporting/timeReports/page-header/team.tsx b/worklenz-frontend/src/pages/reporting/timeReports/page-header/team.tsx index 0ceb342c3..778a24651 100644 --- a/worklenz-frontend/src/pages/reporting/timeReports/page-header/team.tsx +++ b/worklenz-frontend/src/pages/reporting/timeReports/page-header/team.tsx @@ -1,6 +1,6 @@ -import { CaretDownFilled } from '@ant-design/icons'; -import { Button, Checkbox, Divider, Dropdown, Input, theme } from 'antd'; -import React, { useEffect, useState } from 'react'; +import { CaretDownFilled, FilterOutlined, CheckCircleFilled } from '@ant-design/icons'; +import { Button, Checkbox, Divider, Dropdown, Input, theme, Space } from 'antd'; +import React, { useEffect, useState, useMemo } from 'react'; import type { CheckboxChangeEvent } from 'antd/es/checkbox'; import { useTranslation } from 'react-i18next'; import { ISelectableTeam } from '@/types/reporting/reporting-filters.types'; @@ -21,10 +21,35 @@ const Team: React.FC = () => { const { teams, loadingTeams } = useAppSelector(state => state.timeReportsOverviewReducer); + // Calculate active filters count + const activeFiltersCount = useMemo(() => { + return teams.filter(team => team.selected).length; + }, [teams]); + + // Check if all options are selected + const isAllSelected = teams.length > 0 && teams.every(team => team.selected); + const isNoneSelected = teams.length > 0 && !teams.some(team => team.selected); + const filteredItems = teams.filter(item => item.name?.toLowerCase().includes(searchText.toLowerCase()) ); + // Theme-aware colors + const isDark = token.colorBgContainer !== '#ffffff'; + const colors = { + headerText: isDark ? token.colorTextSecondary : '#262626', + borderColor: isDark ? token.colorBorder : '#f0f0f0', + linkActive: token.colorPrimary, + linkDisabled: isDark ? token.colorTextDisabled : '#d9d9d9', + successColor: token.colorSuccess, + errorColor: token.colorError, + buttonBorder: activeFiltersCount > 0 ? token.colorPrimary : token.colorBorder, + buttonText: activeFiltersCount > 0 ? token.colorPrimary : token.colorTextSecondary, + buttonBg: activeFiltersCount > 0 ? (isDark ? token.colorPrimaryBg : '#f6ffed') : 'transparent', + dropdownBg: token.colorBgElevated, + dropdownBorder: token.colorBorderSecondary, + }; + const handleCheckboxChange = async (key: string, checked: boolean) => { dispatch(setSelectOrDeselectTeam({ id: key, selected: checked })); await dispatch(fetchReportingCategories()); @@ -39,6 +64,29 @@ const Team: React.FC = () => { await dispatch(fetchReportingProjects()); }; + // Handle clear all + const handleClearAll = async () => { + setSelectAll(false); + dispatch(setSelectOrDeselectAllTeams(false)); + await dispatch(fetchReportingCategories()); + await dispatch(fetchReportingProjects()); + }; + + // Handle select all button click + const handleSelectAllClick = async () => { + const newValue = !isAllSelected; + setSelectAll(newValue); + dispatch(setSelectOrDeselectAllTeams(newValue)); + await dispatch(fetchReportingCategories()); + await dispatch(fetchReportingProjects()); + }; + + const getButtonText = () => { + if (isNoneSelected) return t('teams'); + if (isAllSelected) return `All ${t('teams')}`; + return `${t('teams')} (${activeFiltersCount})`; + }; + return (
{ trigger={['click']} dropdownRender={() => (
-
+ {/* Header */} +
+ {t('searchByName')} +
+ + {/* Search */} +
setSearchText(e.target.value)} onClick={e => e.stopPropagation()} + style={{ fontSize: '14px' }} />
-
- e.stopPropagation()} - onChange={handleSelectAllChange} - checked={selectAll} - > - {t('selectAll')} - + + {/* Actions */} +
+ + + + +
- + + + + {/* Items */}
{
e.stopPropagation()} checked={item.selected} onChange={e => handleCheckboxChange(item.id || '', e.target.checked)} + style={{ fontSize: '14px' }} > - {item.name} + {item.name} + {item.selected && ( + + )}
))}
@@ -104,8 +207,45 @@ const Team: React.FC = () => { } }} > -
diff --git a/worklenz-frontend/src/pages/reporting/timeReports/page-header/utilization.tsx b/worklenz-frontend/src/pages/reporting/timeReports/page-header/utilization.tsx index 14ed2f2e2..e3b964c9f 100644 --- a/worklenz-frontend/src/pages/reporting/timeReports/page-header/utilization.tsx +++ b/worklenz-frontend/src/pages/reporting/timeReports/page-header/utilization.tsx @@ -1,12 +1,11 @@ -import React, { useState } from 'react'; +import React, { useState, useMemo } from 'react'; import { useAppDispatch } from '@/hooks/useAppDispatch'; import { useAppSelector } from '@/hooks/useAppSelector'; import { setSelectOrDeselectAllMembers, setSelectOrDeselectAllUtilization, setSelectOrDeselectMember, setSelectOrDeselectUtilization } from '@/features/reporting/time-reports/time-reports-overview.slice'; -import { Button, Checkbox, Divider, Dropdown, Input, Avatar, theme } from 'antd'; +import { Button, Checkbox, Divider, Dropdown, Input, Avatar, theme, Space } from 'antd'; import { CheckboxChangeEvent } from 'antd/es/checkbox'; -import { CaretDownFilled } from '@ant-design/icons'; +import { CaretDownFilled, FilterOutlined, CheckCircleFilled } from '@ant-design/icons'; import { useTranslation } from 'react-i18next'; -import { id } from 'date-fns/locale'; const Utilization: React.FC = () => { const dispatch = useAppDispatch(); @@ -17,10 +16,36 @@ const Utilization: React.FC = () => { const [searchText, setSearchText] = useState(''); const [selectAll, setSelectAll] = useState(true); + // Calculate active filters count + const activeFiltersCount = useMemo(() => { + return utilization.filter(item => item.selected).length; + }, [utilization]); + + // Check if all options are selected + const isAllSelected = utilization.length > 0 && utilization.every(item => item.selected); + const isNoneSelected = utilization.length > 0 && !utilization.some(item => item.selected); + // Filter members based on search text const filteredItems = utilization.filter(item => item.name?.toLowerCase().includes(searchText.toLowerCase()) ); + + // Theme-aware colors + const isDark = token.colorBgContainer !== '#ffffff'; + const colors = { + headerText: isDark ? token.colorTextSecondary : '#262626', + borderColor: isDark ? token.colorBorder : '#f0f0f0', + linkActive: token.colorPrimary, + linkDisabled: isDark ? token.colorTextDisabled : '#d9d9d9', + successColor: token.colorSuccess, + errorColor: token.colorError, + buttonBorder: activeFiltersCount > 0 ? token.colorPrimary : token.colorBorder, + buttonText: activeFiltersCount > 0 ? token.colorPrimary : token.colorTextSecondary, + buttonBg: activeFiltersCount > 0 ? (isDark ? token.colorPrimaryBg : '#f6ffed') : 'transparent', + dropdownBg: token.colorBgElevated, + dropdownBorder: token.colorBorderSecondary, + }; + // Handle checkbox change for individual members const handleCheckboxChange = (id: string, selected: boolean) => { dispatch(setSelectOrDeselectUtilization({ id, selected })); @@ -32,6 +57,25 @@ const Utilization: React.FC = () => { dispatch(setSelectOrDeselectAllUtilization(isChecked)); }; + // Handle select all button click + const handleSelectAllClick = () => { + const newValue = !isAllSelected; + setSelectAll(newValue); + dispatch(setSelectOrDeselectAllUtilization(newValue)); + }; + + // Handle clear all + const handleClearAll = () => { + setSelectAll(false); + dispatch(setSelectOrDeselectAllUtilization(false)); + }; + + const getButtonText = () => { + if (isNoneSelected) return t('utilization'); + if (isAllSelected) return `All ${t('utilization')}`; + return `${t('utilization')} (${activeFiltersCount})`; + }; + return ( { dropdownRender={() => (
-
+ {/* Header */} +
+ {t('utilization')}
-
- e.stopPropagation()} - onChange={handleSelectAll} - checked={selectAll} - > - {t('selectAll')} - + + {/* Actions */} +
+ + + + +
- + + + + {/* Items */}
{
e.stopPropagation()} checked={ut.selected} onChange={e => handleCheckboxChange(ut.id, e.target.checked)} + style={{ fontSize: '14px' }} > - {ut.name} + {ut.name} + {ut.selected && ( + + )}
))}
)} > - ); diff --git a/worklenz-frontend/src/pages/reporting/timeReports/timeReportingRightHeader/TimeReportingRightHeader.tsx b/worklenz-frontend/src/pages/reporting/timeReports/timeReportingRightHeader/TimeReportingRightHeader.tsx index d3c358354..d2594db0f 100644 --- a/worklenz-frontend/src/pages/reporting/timeReports/timeReportingRightHeader/TimeReportingRightHeader.tsx +++ b/worklenz-frontend/src/pages/reporting/timeReports/timeReportingRightHeader/TimeReportingRightHeader.tsx @@ -14,7 +14,11 @@ interface headerState { export: (key: string) => void; } -const TimeReportingRightHeader: React.FC = ({ title, exportType, export: exportFn }) => { +const TimeReportingRightHeader: React.FC = ({ + title, + exportType, + export: exportFn, +}) => { const { t } = useTranslation('time-report'); const dispatch = useAppDispatch(); const { archived } = useAppSelector(state => state.timeReportsOverviewReducer); @@ -22,7 +26,7 @@ const TimeReportingRightHeader: React.FC = ({ title, exportType, ex const menuItems = exportType.map(item => ({ key: item.key, label: item.label, - onClick: () => exportFn(item.key) + onClick: () => exportFn(item.key), })); return ( @@ -36,8 +40,8 @@ const TimeReportingRightHeader: React.FC = ({ title, exportType, ex - - From 593e6cfa98e1b94ab49c802b10ebd46b9a9243e7 Mon Sep 17 00:00:00 2001 From: chamiakJ Date: Tue, 3 Jun 2025 10:49:24 +0530 Subject: [PATCH 080/105] refactor(config): migrate configuration files to ES module syntax - Updated jest.config.js, postcss.config.js, and tailwind.config.js to use ES module export syntax. - Added "type": "module" to package.json to support ES module imports. - Refactored copy-tinymce.js to utilize ES module imports and defined __dirname for compatibility. --- worklenz-frontend/jest.config.js | 2 +- worklenz-frontend/package.json | 1 + worklenz-frontend/postcss.config.js | 2 +- worklenz-frontend/scripts/copy-tinymce.js | 9 +++++++-- worklenz-frontend/tailwind.config.js | 2 +- 5 files changed, 11 insertions(+), 5 deletions(-) diff --git a/worklenz-frontend/jest.config.js b/worklenz-frontend/jest.config.js index f3d2d0274..167bb4d6b 100644 --- a/worklenz-frontend/jest.config.js +++ b/worklenz-frontend/jest.config.js @@ -1,4 +1,4 @@ -module.exports = { +export default { setupFilesAfterEnv: ['/src/setupTests.ts'], moduleNameMapper: { '^@/(.*)$': '/src/$1', diff --git a/worklenz-frontend/package.json b/worklenz-frontend/package.json index 67a8fed47..62d10a318 100644 --- a/worklenz-frontend/package.json +++ b/worklenz-frontend/package.json @@ -2,6 +2,7 @@ "name": "worklenz", "version": "1.0.0", "private": true, + "type": "module", "scripts": { "start": "vite", "prebuild": "node scripts/copy-tinymce.js", diff --git a/worklenz-frontend/postcss.config.js b/worklenz-frontend/postcss.config.js index 12a703d90..2aa7205d4 100644 --- a/worklenz-frontend/postcss.config.js +++ b/worklenz-frontend/postcss.config.js @@ -1,4 +1,4 @@ -module.exports = { +export default { plugins: { tailwindcss: {}, autoprefixer: {}, diff --git a/worklenz-frontend/scripts/copy-tinymce.js b/worklenz-frontend/scripts/copy-tinymce.js index 8f801c467..45396759d 100644 --- a/worklenz-frontend/scripts/copy-tinymce.js +++ b/worklenz-frontend/scripts/copy-tinymce.js @@ -1,5 +1,10 @@ -const fs = require('fs'); -const path = require('path'); +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +// Get __dirname equivalent for ES modules +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); // Create the directory if it doesn't exist const targetDir = path.join(__dirname, '..', 'public', 'tinymce'); diff --git a/worklenz-frontend/tailwind.config.js b/worklenz-frontend/tailwind.config.js index 9b6645eb5..4dda98e92 100644 --- a/worklenz-frontend/tailwind.config.js +++ b/worklenz-frontend/tailwind.config.js @@ -1,5 +1,5 @@ /** @type {import('tailwindcss').Config} */ -module.exports = { +export default { content: ['./src/**/*.{js,jsx,ts,tsx}'], theme: { extend: {}, From e8bf84ef3af4845a7f8333cc38536be1cf20c906 Mon Sep 17 00:00:00 2001 From: chamiakJ Date: Tue, 3 Jun 2025 10:55:37 +0530 Subject: [PATCH 081/105] feat(config): optimize dependency management and enhance isomorphic layout effect hook - Added a comprehensive list of dependencies to optimize for faster development builds in vite.config.ts. - Improved the useIsomorphicLayoutEffect hook with additional safety checks to ensure React hooks are available in both client and server environments. --- .../src/hooks/useIsomorphicLayoutEffect.ts | 21 +++++++++++++++- worklenz-frontend/vite.config.ts | 25 +++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/worklenz-frontend/src/hooks/useIsomorphicLayoutEffect.ts b/worklenz-frontend/src/hooks/useIsomorphicLayoutEffect.ts index 206b00843..46bd8cdac 100644 --- a/worklenz-frontend/src/hooks/useIsomorphicLayoutEffect.ts +++ b/worklenz-frontend/src/hooks/useIsomorphicLayoutEffect.ts @@ -1,6 +1,25 @@ import { useLayoutEffect, useEffect } from 'react'; // Use useLayoutEffect in browser environments and useEffect in SSR environments -const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect; +// with additional safety checks to ensure React hooks are available +const useIsomorphicLayoutEffect = (() => { + // Check if we're in a browser environment + if (typeof window === 'undefined') { + // Server-side: return useEffect (which won't execute anyway) + return useEffect; + } + + // Client-side: ensure React hooks are available + try { + if (useLayoutEffect && typeof useLayoutEffect === 'function') { + return useLayoutEffect; + } + } catch (error) { + console.warn('useLayoutEffect not available, falling back to useEffect:', error); + } + + // Fallback to useEffect if useLayoutEffect is not available + return useEffect; +})(); export default useIsomorphicLayoutEffect; \ No newline at end of file diff --git a/worklenz-frontend/vite.config.ts b/worklenz-frontend/vite.config.ts index e39484ace..bdae10614 100644 --- a/worklenz-frontend/vite.config.ts +++ b/worklenz-frontend/vite.config.ts @@ -28,6 +28,31 @@ export default defineConfig(({ command }) => { ], }, + // **Optimize Dependencies** + optimizeDeps: { + include: [ + 'react', + 'react-dom', + 'react/jsx-runtime', + 'react/jsx-dev-runtime', + 'antd', + '@dnd-kit/core', + '@dnd-kit/sortable', + '@dnd-kit/modifiers', + '@dnd-kit/utilities', + 'react-redux', + '@reduxjs/toolkit', + 'i18next', + 'react-i18next', + 'react-router-dom', + 'moment', + 'date-fns', + 'axios', + 'socket.io-client' + ], + force: true, // Force re-optimization on every dev server start + }, + // **Build** build: { // **Target** From 5ec7a2741ca33277bd3227146cce442fd93187eb Mon Sep 17 00:00:00 2001 From: chamiakJ Date: Tue, 3 Jun 2025 11:00:41 +0530 Subject: [PATCH 082/105] refactor(vite.config): streamline dependency management and enhance SSR configuration - Removed redundant dependencies from the optimization list and added them to the SSR configuration for better handling. - Updated chunking strategy to include @dnd-kit packages with React for improved loading order. - Introduced additional configuration for ES modules to ensure compatibility with global definitions. --- worklenz-frontend/vite.config.ts | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/worklenz-frontend/vite.config.ts b/worklenz-frontend/vite.config.ts index bdae10614..74d3ba778 100644 --- a/worklenz-frontend/vite.config.ts +++ b/worklenz-frontend/vite.config.ts @@ -36,10 +36,6 @@ export default defineConfig(({ command }) => { 'react/jsx-runtime', 'react/jsx-dev-runtime', 'antd', - '@dnd-kit/core', - '@dnd-kit/sortable', - '@dnd-kit/modifiers', - '@dnd-kit/utilities', 'react-redux', '@reduxjs/toolkit', 'i18next', @@ -50,9 +46,26 @@ export default defineConfig(({ command }) => { 'axios', 'socket.io-client' ], + exclude: [ + '@dnd-kit/core', + '@dnd-kit/sortable', + '@dnd-kit/modifiers', + '@dnd-kit/utilities' + ], force: true, // Force re-optimization on every dev server start }, + // **SSR Configuration** + ssr: { + noExternal: ['@dnd-kit/core', '@dnd-kit/utilities', '@dnd-kit/sortable', '@dnd-kit/modifiers'], + }, + + // **Additional Configuration for ES Modules** + define: { + // Ensure global is defined for some packages that expect it + global: 'globalThis', + }, + // **Build** build: { // **Target** @@ -96,8 +109,8 @@ export default defineConfig(({ command }) => { output: { // **Enhanced Chunking Strategy** manualChunks(id) { - // Core React dependencies - if (['react', 'react-dom'].includes(id)) return 'react-vendor'; + // Core React dependencies - include @dnd-kit with React to fix loading order + if (['react', 'react-dom'].includes(id) || id.includes('@dnd-kit')) return 'react-vendor'; // Router and navigation if (id.includes('react-router-dom') || id.includes('react-router')) return 'router'; @@ -114,9 +127,6 @@ export default defineConfig(({ command }) => { // Date and time utilities if (id.includes('moment') || id.includes('dayjs') || id.includes('date-fns')) return 'date-utils'; - // Drag and drop - if (id.includes('@dnd-kit')) return 'dnd-kit'; - // Charts and visualization if (id.includes('chart') || id.includes('echarts') || id.includes('highcharts') || id.includes('recharts')) return 'charts'; From 3bfb886de7e0ef6f545f8af83615a1314ddef19a Mon Sep 17 00:00:00 2001 From: chamiakJ Date: Tue, 3 Jun 2025 11:05:37 +0530 Subject: [PATCH 083/105] feat(react-integration): add React polyfills and ensure global availability - Introduced a React polyfill to prevent undefined errors in dependencies by making React globally available in both window and globalThis. - Updated the App component to allow optional children prop for improved flexibility. - Created a new dnd-kit-wrapper utility to ensure React is available globally before importing @dnd-kit utilities. --- worklenz-frontend/src/App.tsx | 2 +- worklenz-frontend/src/index.tsx | 3 +++ worklenz-frontend/src/utils/dnd-kit-wrapper.ts | 12 ++++++++++++ worklenz-frontend/src/utils/react-polyfill.ts | 12 ++++++++++++ 4 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 worklenz-frontend/src/utils/dnd-kit-wrapper.ts create mode 100644 worklenz-frontend/src/utils/react-polyfill.ts diff --git a/worklenz-frontend/src/App.tsx b/worklenz-frontend/src/App.tsx index d80023f9b..c5753f6b3 100644 --- a/worklenz-frontend/src/App.tsx +++ b/worklenz-frontend/src/App.tsx @@ -21,7 +21,7 @@ import { Language } from './features/i18n/localesSlice'; import logger from './utils/errorLogger'; import { SuspenseFallback } from './components/suspense-fallback/suspense-fallback'; -const App: React.FC<{ children: React.ReactNode }> = ({ children }) => { +const App: React.FC<{ children?: React.ReactNode }> = ({ children }) => { const themeMode = useAppSelector(state => state.themeReducer.mode); const language = useAppSelector(state => state.localesReducer.lng); diff --git a/worklenz-frontend/src/index.tsx b/worklenz-frontend/src/index.tsx index cf2c161c5..3822bb975 100644 --- a/worklenz-frontend/src/index.tsx +++ b/worklenz-frontend/src/index.tsx @@ -1,3 +1,6 @@ +// Import React polyfill first to ensure React is available globally +import './utils/react-polyfill'; + import React from 'react'; import ReactDOM from 'react-dom/client'; import './index.css'; diff --git a/worklenz-frontend/src/utils/dnd-kit-wrapper.ts b/worklenz-frontend/src/utils/dnd-kit-wrapper.ts new file mode 100644 index 000000000..5918f3f49 --- /dev/null +++ b/worklenz-frontend/src/utils/dnd-kit-wrapper.ts @@ -0,0 +1,12 @@ +import React from 'react'; + +// Ensure React is available globally before any @dnd-kit imports +if (typeof globalThis !== 'undefined') { + (globalThis as any).React = React; +} + +// Re-export @dnd-kit utilities with React dependency assured +export * from '@dnd-kit/core'; +export * from '@dnd-kit/sortable'; +export * from '@dnd-kit/modifiers'; +export * from '@dnd-kit/utilities'; \ No newline at end of file diff --git a/worklenz-frontend/src/utils/react-polyfill.ts b/worklenz-frontend/src/utils/react-polyfill.ts new file mode 100644 index 000000000..bc0a14c81 --- /dev/null +++ b/worklenz-frontend/src/utils/react-polyfill.ts @@ -0,0 +1,12 @@ +import React from 'react'; + +// Polyfill React hooks globally to prevent undefined errors in dependencies +if (typeof window !== 'undefined') { + // Ensure React is available globally + (window as any).React = React; +} + +// Also ensure it's available in globalThis for ES modules +if (typeof globalThis !== 'undefined') { + (globalThis as any).React = React; +} \ No newline at end of file From 0136f6d3cbb3897f3f3c134ba8feb761133fd683 Mon Sep 17 00:00:00 2001 From: chamiakJ Date: Tue, 3 Jun 2025 11:12:06 +0530 Subject: [PATCH 084/105] chore(dependencies): update package versions and resolutions for improved compatibility - Downgraded @dnd-kit/modifiers and @dnd-kit/sortable to specific versions for better stability. - Updated various dependencies including @adobe/css-tools, @ant-design/colors, and Babel packages to their latest versions. - Added resolutions for @dnd-kit packages to ensure consistent versions across the project. - Removed deprecated react-is dependency from rc-form to streamline package management. --- worklenz-frontend/package-lock.json | 1412 +++++++++++---------------- worklenz-frontend/package.json | 10 +- 2 files changed, 582 insertions(+), 840 deletions(-) diff --git a/worklenz-frontend/package-lock.json b/worklenz-frontend/package-lock.json index f1e62a1ba..024a1b090 100644 --- a/worklenz-frontend/package-lock.json +++ b/worklenz-frontend/package-lock.json @@ -13,8 +13,8 @@ "@ant-design/icons": "^5.4.0", "@ant-design/pro-components": "^2.7.19", "@dnd-kit/core": "^6.3.1", - "@dnd-kit/modifiers": "^9.0.0", - "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/modifiers": "^6.0.1", + "@dnd-kit/sortable": "^7.0.2", "@dnd-kit/utilities": "^3.2.2", "@emotion/react": "^11.14.0", "@paddle/paddle-js": "^1.3.3", @@ -77,9 +77,9 @@ } }, "node_modules/@adobe/css-tools": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.2.tgz", - "integrity": "sha512-baYZExFpsdkBNuvGKTKWCwKH57HRZLVtycZS05WTQNVOiXVSeAki3nU35zlRbToeMW8aHlJfyS+1C4BOv27q0A==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.3.tgz", + "integrity": "sha512-VQKMkwriZbaOgVCby1UDY/LDk5fIjhQicCvVPFqfe+69fWaPWydbWJ3wRt59/YzIwda1I81loas3oCoHxnqvdA==", "dev": true, "license": "MIT" }, @@ -111,9 +111,9 @@ } }, "node_modules/@ant-design/colors": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-7.2.0.tgz", - "integrity": "sha512-bjTObSnZ9C/O8MB/B4OUtd/q9COomuJAR2SYfhxLyHvCKn4EKwCN3e+fWGMo7H5InAyV0wL17jdE9ALrdOW/6A==", + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-7.2.1.tgz", + "integrity": "sha512-lCHDcEzieu4GA3n8ELeZ5VQ8pKQAWcGGLRTQ50aQM2iqPpq2evTxER84jfdPvsPAtEcZ7m44NI45edFMo8oOYQ==", "license": "MIT", "dependencies": { "@ant-design/fast-color": "^2.0.6" @@ -413,12 +413,6 @@ "shallowequal": "^1.1.0" } }, - "node_modules/@ant-design/pro-list/node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT" - }, "node_modules/@ant-design/pro-provider": { "version": "2.15.4", "resolved": "https://registry.npmjs.org/@ant-design/pro-provider/-/pro-provider-2.15.4.tgz", @@ -484,34 +478,6 @@ "react-dom": ">=17.0.0" } }, - "node_modules/@ant-design/pro-table/node_modules/@dnd-kit/modifiers": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/@dnd-kit/modifiers/-/modifiers-6.0.1.tgz", - "integrity": "sha512-rbxcsg3HhzlcMHVHWDuh9LCjpOVAgqbV78wLGI8tziXY3+qcMQ61qVXIvNKQFuhj75dSfD+o+PYZQ/NUk2A23A==", - "license": "MIT", - "dependencies": { - "@dnd-kit/utilities": "^3.2.1", - "tslib": "^2.0.0" - }, - "peerDependencies": { - "@dnd-kit/core": "^6.0.6", - "react": ">=16.8.0" - } - }, - "node_modules/@ant-design/pro-table/node_modules/@dnd-kit/sortable": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-7.0.2.tgz", - "integrity": "sha512-wDkBHHf9iCi1veM834Gbk1429bd4lHX4RpAwT0y2cHLf246GAvU2sVw/oxWNpPKQNQRQaeGXhAVgrOl1IT+iyA==", - "license": "MIT", - "dependencies": { - "@dnd-kit/utilities": "^3.2.0", - "tslib": "^2.0.0" - }, - "peerDependencies": { - "@dnd-kit/core": "^6.0.7", - "react": ">=16.8.0" - } - }, "node_modules/@ant-design/pro-utils": { "version": "2.17.0", "resolved": "https://registry.npmjs.org/@ant-design/pro-utils/-/pro-utils-2.17.0.tgz", @@ -552,23 +518,23 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.26.2", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", - "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.25.9", + "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", - "picocolors": "^1.0.0" + "picocolors": "^1.1.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/compat-data": { - "version": "7.26.8", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.26.8.tgz", - "integrity": "sha512-oH5UPLMWR3L2wEFLnFJ1TZXqHufiTKAiLfqw5zkhS4dKXLJ10yVztfil/twG8EDTA4F/tvVNw9nOl4ZMslB8rQ==", + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.27.3.tgz", + "integrity": "sha512-V42wFfx1ymFte+ecf6iXghnnP8kWTO+ZLXIyZq+1LAXHHvTZdVxicn4yiVYdYMGaCO3tmqub11AorKkv+iodqw==", "dev": true, "license": "MIT", "engines": { @@ -576,22 +542,22 @@ } }, "node_modules/@babel/core": { - "version": "7.26.10", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.10.tgz", - "integrity": "sha512-vMqyb7XCDMPvJFFOaT9kxtiRh42GwlZEg1/uIgtZshS5a/8OaduUfCi7kynKgc3Tw/6Uo2D+db9qBttghhmxwQ==", + "version": "7.27.4", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.27.4.tgz", + "integrity": "sha512-bXYxrXFubeYdvB0NhD/NBB3Qi6aZeV20GOWVI47t2dkecCEoneR4NPVcb7abpXDEvejgrUfFtG6vG/zxAKmg+g==", "dev": true, "license": "MIT", "dependencies": { "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.26.2", - "@babel/generator": "^7.26.10", - "@babel/helper-compilation-targets": "^7.26.5", - "@babel/helper-module-transforms": "^7.26.0", - "@babel/helpers": "^7.26.10", - "@babel/parser": "^7.26.10", - "@babel/template": "^7.26.9", - "@babel/traverse": "^7.26.10", - "@babel/types": "^7.26.10", + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.27.3", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.27.3", + "@babel/helpers": "^7.27.4", + "@babel/parser": "^7.27.4", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.27.4", + "@babel/types": "^7.27.3", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -614,13 +580,13 @@ "license": "MIT" }, "node_modules/@babel/generator": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.0.tgz", - "integrity": "sha512-VybsKvpiN1gU1sdMZIp7FcqphVVKEwcuj02x73uvcHE0PTihx1nlBcowYWhDwjpoAXRv43+gDzyggGnn1XZhVw==", + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.3.tgz", + "integrity": "sha512-xnlJYj5zepml8NXtjkG0WquFUv8RskFqyFcVgTBp5k+NaA/8uw/K+OSVf8AMGw5e9HKP2ETd5xpK5MLZQD6b4Q==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.27.0", - "@babel/types": "^7.27.0", + "@babel/parser": "^7.27.3", + "@babel/types": "^7.27.3", "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25", "jsesc": "^3.0.2" @@ -630,14 +596,14 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.0.tgz", - "integrity": "sha512-LVk7fbXml0H2xH34dFzKQ7TDZ2G4/rVTOrq9V+icbbadjbVxxeFeDsNHv2SrZeWoA+6ZiTyWYWtScEIW07EAcA==", + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.26.8", - "@babel/helper-validator-option": "^7.25.9", + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -647,28 +613,28 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.25.9.tgz", - "integrity": "sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", "license": "MIT", "dependencies": { - "@babel/traverse": "^7.25.9", - "@babel/types": "^7.25.9" + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.26.0.tgz", - "integrity": "sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw==", + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.27.3.tgz", + "integrity": "sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.25.9", - "@babel/helper-validator-identifier": "^7.25.9", - "@babel/traverse": "^7.25.9" + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.27.3" }, "engines": { "node": ">=6.9.0" @@ -678,9 +644,9 @@ } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.26.5", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.26.5.tgz", - "integrity": "sha512-RS+jZcRdZdRFzMyr+wcsaqOmld1/EqTghfaBGQQd/WnRdzdlvSZ//kF7U8VQTxf1ynZ4cjUcYgjVGx13ewNPMg==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", "dev": true, "license": "MIT", "engines": { @@ -688,27 +654,27 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", - "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", - "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-option": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.25.9.tgz", - "integrity": "sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", "dev": true, "license": "MIT", "engines": { @@ -716,26 +682,26 @@ } }, "node_modules/@babel/helpers": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.0.tgz", - "integrity": "sha512-U5eyP/CTFPuNE3qk+WZMxFkp/4zUzdceQlfzf7DdGdhp+Fezd7HD+i8Y24ZuTMKX3wQBld449jijbGq6OdGNQg==", + "version": "7.27.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.4.tgz", + "integrity": "sha512-Y+bO6U+I7ZKaM5G5rDUZiYfUvQPUibYmAFe7EnKdnKBbVXDZxvp+MWOH5gYciY0EPk4EScsuFMQBbEfpdRKSCQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.27.0", - "@babel/types": "^7.27.0" + "@babel/template": "^7.27.2", + "@babel/types": "^7.27.3" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.0.tgz", - "integrity": "sha512-iaepho73/2Pz7w2eMS0Q5f83+0RKI7i4xmiYeBmDzfRVbQtTOG7Ts0S4HzJVsTMGI9keU8rNfuZr8DKfSt7Yyg==", + "version": "7.27.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.4.tgz", + "integrity": "sha512-BRmLHGwpUqLFR2jzx9orBuX/ABDkj2jLKOXrHDTN2aOKL+jFDDKaRNo9nyYsIl9h/UE/7lMKdDjKQQyxKKDZ7g==", "license": "MIT", "dependencies": { - "@babel/types": "^7.27.0" + "@babel/types": "^7.27.3" }, "bin": { "parser": "bin/babel-parser.js" @@ -745,13 +711,13 @@ } }, "node_modules/@babel/plugin-transform-react-jsx-self": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.25.9.tgz", - "integrity": "sha512-y8quW6p0WHkEhmErnfe58r7x0A70uKphQm8Sp8cV7tjNQwK56sNVK0M73LK3WuYmsuyrftut4xAkjjgU0twaMg==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -761,13 +727,13 @@ } }, "node_modules/@babel/plugin-transform-react-jsx-source": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.25.9.tgz", - "integrity": "sha512-+iqjT8xmXhhYv4/uiYd8FNQsraMFZIfxVSqxxVSZP0WbbSAWvBXAul0m/zu+7Vv4O/3WtApy9pmaTMiumEZgfg==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -777,42 +743,39 @@ } }, "node_modules/@babel/runtime": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz", - "integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==", + "version": "7.27.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.4.tgz", + "integrity": "sha512-t3yaEOuGu9NlIZ+hIeGbBjFtZT7j2cb2tg0fuaJKeGotchRjjLfrBA9Kwf8quhpP1EUuxModQg04q/mBwyg8uA==", "license": "MIT", - "dependencies": { - "regenerator-runtime": "^0.14.0" - }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/template": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.0.tgz", - "integrity": "sha512-2ncevenBqXI6qRMukPlXwHKHchC7RyMuu4xv5JBXRfOGVcTy1mXCD12qrp7Jsoxll1EV3+9sE4GugBVRjT2jFA==", + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.26.2", - "@babel/parser": "^7.27.0", - "@babel/types": "^7.27.0" + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.0.tgz", - "integrity": "sha512-19lYZFzYVQkkHkl4Cy4WrAVcqBkgvV2YM2TU3xG6DIwO7O3ecbDPfW3yM3bjAGcqcQHi+CCtjMR3dIEHxsd6bA==", + "version": "7.27.4", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.4.tgz", + "integrity": "sha512-oNcu2QbHqts9BtOWJosOVJapWjBDSxGCpFvikNR5TGDYDQf3JwpIoMzIKrvfoti93cLfPJEG4tH9SPVeyCGgdA==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.26.2", - "@babel/generator": "^7.27.0", - "@babel/parser": "^7.27.0", - "@babel/template": "^7.27.0", - "@babel/types": "^7.27.0", + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.27.3", + "@babel/parser": "^7.27.4", + "@babel/template": "^7.27.2", + "@babel/types": "^7.27.3", "debug": "^4.3.1", "globals": "^11.1.0" }, @@ -821,13 +784,13 @@ } }, "node_modules/@babel/types": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.0.tgz", - "integrity": "sha512-H45s8fVLYjbhFH62dIJ3WtmJ6RSPt/3DRO0ZcT2SUiYiQyz3BLVb9ADEnLl91m74aQPS3AzzeajZHYOalWe3bg==", + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.3.tgz", + "integrity": "sha512-Y1GkI4ktrtvmawoSq+4FCVHNryea6uR+qUQy0AGxLSsjCX0nVmkYQMBLHDkXZuo5hGx7eYdnIaslsdBFm7zbUw==", "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.25.9", - "@babel/helper-validator-identifier": "^7.25.9" + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -883,30 +846,30 @@ } }, "node_modules/@dnd-kit/modifiers": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/@dnd-kit/modifiers/-/modifiers-9.0.0.tgz", - "integrity": "sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/modifiers/-/modifiers-6.0.1.tgz", + "integrity": "sha512-rbxcsg3HhzlcMHVHWDuh9LCjpOVAgqbV78wLGI8tziXY3+qcMQ61qVXIvNKQFuhj75dSfD+o+PYZQ/NUk2A23A==", "license": "MIT", "dependencies": { - "@dnd-kit/utilities": "^3.2.2", + "@dnd-kit/utilities": "^3.2.1", "tslib": "^2.0.0" }, "peerDependencies": { - "@dnd-kit/core": "^6.3.0", + "@dnd-kit/core": "^6.0.6", "react": ">=16.8.0" } }, "node_modules/@dnd-kit/sortable": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", - "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-7.0.2.tgz", + "integrity": "sha512-wDkBHHf9iCi1veM834Gbk1429bd4lHX4RpAwT0y2cHLf246GAvU2sVw/oxWNpPKQNQRQaeGXhAVgrOl1IT+iyA==", "license": "MIT", "dependencies": { - "@dnd-kit/utilities": "^3.2.2", + "@dnd-kit/utilities": "^3.2.0", "tslib": "^2.0.0" }, "peerDependencies": { - "@dnd-kit/core": "^6.3.0", + "@dnd-kit/core": "^6.0.7", "react": ">=16.8.0" } }, @@ -1067,9 +1030,9 @@ "license": "MIT" }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.2.tgz", - "integrity": "sha512-wCIboOL2yXZym2cgm6mlA742s9QeJ8DjGVaL39dLN4rRwrOgOyYSnOaFPhKZGLb2ngj4EyfAFjsNJwPXZvseag==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz", + "integrity": "sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==", "cpu": [ "ppc64" ], @@ -1084,9 +1047,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.2.tgz", - "integrity": "sha512-NQhH7jFstVY5x8CKbcfa166GoV0EFkaPkCKBQkdPJFvo5u+nGXLEH/ooniLb3QI8Fk58YAx7nsPLozUWfCBOJA==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.5.tgz", + "integrity": "sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==", "cpu": [ "arm" ], @@ -1101,9 +1064,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.2.tgz", - "integrity": "sha512-5ZAX5xOmTligeBaeNEPnPaeEuah53Id2tX4c2CVP3JaROTH+j4fnfHCkr1PjXMd78hMst+TlkfKcW/DlTq0i4w==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.5.tgz", + "integrity": "sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg==", "cpu": [ "arm64" ], @@ -1118,9 +1081,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.2.tgz", - "integrity": "sha512-Ffcx+nnma8Sge4jzddPHCZVRvIfQ0kMsUsCMcJRHkGJ1cDmhe4SsrYIjLUKn1xpHZybmOqCWwB0zQvsjdEHtkg==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.5.tgz", + "integrity": "sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw==", "cpu": [ "x64" ], @@ -1135,9 +1098,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.2.tgz", - "integrity": "sha512-MpM6LUVTXAzOvN4KbjzU/q5smzryuoNjlriAIx+06RpecwCkL9JpenNzpKd2YMzLJFOdPqBpuub6eVRP5IgiSA==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.5.tgz", + "integrity": "sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ==", "cpu": [ "arm64" ], @@ -1152,9 +1115,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.2.tgz", - "integrity": "sha512-5eRPrTX7wFyuWe8FqEFPG2cU0+butQQVNcT4sVipqjLYQjjh8a8+vUTfgBKM88ObB85ahsnTwF7PSIt6PG+QkA==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.5.tgz", + "integrity": "sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ==", "cpu": [ "x64" ], @@ -1169,9 +1132,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.2.tgz", - "integrity": "sha512-mLwm4vXKiQ2UTSX4+ImyiPdiHjiZhIaE9QvC7sw0tZ6HoNMjYAqQpGyui5VRIi5sGd+uWq940gdCbY3VLvsO1w==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.5.tgz", + "integrity": "sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw==", "cpu": [ "arm64" ], @@ -1186,9 +1149,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.2.tgz", - "integrity": "sha512-6qyyn6TjayJSwGpm8J9QYYGQcRgc90nmfdUb0O7pp1s4lTY+9D0H9O02v5JqGApUyiHOtkz6+1hZNvNtEhbwRQ==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.5.tgz", + "integrity": "sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw==", "cpu": [ "x64" ], @@ -1203,9 +1166,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.2.tgz", - "integrity": "sha512-UHBRgJcmjJv5oeQF8EpTRZs/1knq6loLxTsjc3nxO9eXAPDLcWW55flrMVc97qFPbmZP31ta1AZVUKQzKTzb0g==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.5.tgz", + "integrity": "sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw==", "cpu": [ "arm" ], @@ -1220,9 +1183,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.2.tgz", - "integrity": "sha512-gq/sjLsOyMT19I8obBISvhoYiZIAaGF8JpeXu1u8yPv8BE5HlWYobmlsfijFIZ9hIVGYkbdFhEqC0NvM4kNO0g==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.5.tgz", + "integrity": "sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg==", "cpu": [ "arm64" ], @@ -1237,9 +1200,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.2.tgz", - "integrity": "sha512-bBYCv9obgW2cBP+2ZWfjYTU+f5cxRoGGQ5SeDbYdFCAZpYWrfjjfYwvUpP8MlKbP0nwZ5gyOU/0aUzZ5HWPuvQ==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.5.tgz", + "integrity": "sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA==", "cpu": [ "ia32" ], @@ -1254,9 +1217,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.2.tgz", - "integrity": "sha512-SHNGiKtvnU2dBlM5D8CXRFdd+6etgZ9dXfaPCeJtz+37PIUlixvlIhI23L5khKXs3DIzAn9V8v+qb1TRKrgT5w==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.5.tgz", + "integrity": "sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg==", "cpu": [ "loong64" ], @@ -1271,9 +1234,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.2.tgz", - "integrity": "sha512-hDDRlzE6rPeoj+5fsADqdUZl1OzqDYow4TB4Y/3PlKBD0ph1e6uPHzIQcv2Z65u2K0kpeByIyAjCmjn1hJgG0Q==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.5.tgz", + "integrity": "sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg==", "cpu": [ "mips64el" ], @@ -1288,9 +1251,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.2.tgz", - "integrity": "sha512-tsHu2RRSWzipmUi9UBDEzc0nLc4HtpZEI5Ba+Omms5456x5WaNuiG3u7xh5AO6sipnJ9r4cRWQB2tUjPyIkc6g==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.5.tgz", + "integrity": "sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ==", "cpu": [ "ppc64" ], @@ -1305,9 +1268,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.2.tgz", - "integrity": "sha512-k4LtpgV7NJQOml/10uPU0s4SAXGnowi5qBSjaLWMojNCUICNu7TshqHLAEbkBdAszL5TabfvQ48kK84hyFzjnw==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.5.tgz", + "integrity": "sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA==", "cpu": [ "riscv64" ], @@ -1322,9 +1285,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.2.tgz", - "integrity": "sha512-GRa4IshOdvKY7M/rDpRR3gkiTNp34M0eLTaC1a08gNrh4u488aPhuZOCpkF6+2wl3zAN7L7XIpOFBhnaE3/Q8Q==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.5.tgz", + "integrity": "sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ==", "cpu": [ "s390x" ], @@ -1339,9 +1302,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.2.tgz", - "integrity": "sha512-QInHERlqpTTZ4FRB0fROQWXcYRD64lAoiegezDunLpalZMjcUcld3YzZmVJ2H/Cp0wJRZ8Xtjtj0cEHhYc/uUg==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.5.tgz", + "integrity": "sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw==", "cpu": [ "x64" ], @@ -1356,9 +1319,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.2.tgz", - "integrity": "sha512-talAIBoY5M8vHc6EeI2WW9d/CkiO9MQJ0IOWX8hrLhxGbro/vBXJvaQXefW2cP0z0nQVTdQ/eNyGFV1GSKrxfw==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.5.tgz", + "integrity": "sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw==", "cpu": [ "arm64" ], @@ -1373,9 +1336,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.2.tgz", - "integrity": "sha512-voZT9Z+tpOxrvfKFyfDYPc4DO4rk06qamv1a/fkuzHpiVBMOhpjK+vBmWM8J1eiB3OLSMFYNaOaBNLXGChf5tg==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.5.tgz", + "integrity": "sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ==", "cpu": [ "x64" ], @@ -1390,9 +1353,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.2.tgz", - "integrity": "sha512-dcXYOC6NXOqcykeDlwId9kB6OkPUxOEqU+rkrYVqJbK2hagWOMrsTGsMr8+rW02M+d5Op5NNlgMmjzecaRf7Tg==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.5.tgz", + "integrity": "sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw==", "cpu": [ "arm64" ], @@ -1407,9 +1370,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.2.tgz", - "integrity": "sha512-t/TkWwahkH0Tsgoq1Ju7QfgGhArkGLkF1uYz8nQS/PPFlXbP5YgRpqQR3ARRiC2iXoLTWFxc6DJMSK10dVXluw==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.5.tgz", + "integrity": "sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg==", "cpu": [ "x64" ], @@ -1424,9 +1387,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.2.tgz", - "integrity": "sha512-cfZH1co2+imVdWCjd+D1gf9NjkchVhhdpgb1q5y6Hcv9TP6Zi9ZG/beI3ig8TvwT9lH9dlxLq5MQBBgwuj4xvA==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.5.tgz", + "integrity": "sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA==", "cpu": [ "x64" ], @@ -1441,9 +1404,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.2.tgz", - "integrity": "sha512-7Loyjh+D/Nx/sOTzV8vfbB3GJuHdOQyrOryFdZvPHLf42Tk9ivBU5Aedi7iyX+x6rbn2Mh68T4qq1SDqJBQO5Q==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.5.tgz", + "integrity": "sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw==", "cpu": [ "arm64" ], @@ -1458,9 +1421,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.2.tgz", - "integrity": "sha512-WRJgsz9un0nqZJ4MfhabxaD9Ft8KioqU3JMinOTvobbX6MOSUigSBlogP8QB3uxpJDsFS6yN+3FDBdqE5lg9kg==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.5.tgz", + "integrity": "sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ==", "cpu": [ "ia32" ], @@ -1475,9 +1438,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.2.tgz", - "integrity": "sha512-kM3HKb16VIXZyIeVrM1ygYmZBKybX8N4p754bw390wGO3Tf2j4L2/WYL+4suWujpgf6GBYs3jv7TyUivdd05JA==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.5.tgz", + "integrity": "sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g==", "cpu": [ "x64" ], @@ -1613,9 +1576,9 @@ } }, "node_modules/@paddle/paddle-js": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@paddle/paddle-js/-/paddle-js-1.4.0.tgz", - "integrity": "sha512-pX6Yx+RswB1rHMuYl8RKcAAVZhVJ6nd5f8w8l4kVM63pM3HNeQ5/Xuk4sK/X9P5fUE2dmN0mTti7+gZ8cZtqvg==", + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@paddle/paddle-js/-/paddle-js-1.4.1.tgz", + "integrity": "sha512-GKuXVnUAIGq4H1AxrPRRMZXl+pTSGiKMStpRlvF6+dv03BwhkqbyHJJZ39e6bMquVbYSa33/9cu6fuW8pie8aQ==", "license": "Apache-2.0" }, "node_modules/@pkgjs/parseargs": { @@ -1779,11 +1742,13 @@ } }, "node_modules/@reduxjs/toolkit": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.6.1.tgz", - "integrity": "sha512-SSlIqZNYhqm/oMkXbtofwZSt9lrncblzo6YcZ9zoX+zLngRBrCOjK4lNLdkNucJF58RHOWrD9txT3bT3piH7Zw==", + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.8.2.tgz", + "integrity": "sha512-MYlOhQ0sLdw4ud48FoC5w0dH9VfWQjtCjreKwYTT3l+r427qYC5Y8PihNutepr8XrNaBUDQo9khWUwQxZaqt5A==", "license": "MIT", "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", "immer": "^10.0.3", "redux": "^5.0.1", "redux-thunk": "^3.1.0", @@ -1811,10 +1776,17 @@ "node": ">=14.0.0" } }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.9", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.9.tgz", + "integrity": "sha512-e9MeMtVWo186sgvFFJOPGy7/d2j2mZhLJIdVW0C/xDluuOvymEATqz6zKsP0ZmXGzQtqlyjz5sC1sYQUoJG98w==", + "dev": true, + "license": "MIT" + }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.39.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.39.0.tgz", - "integrity": "sha512-lGVys55Qb00Wvh8DMAocp5kIcaNzEFTmGhfFd88LfaogYTRKrdxgtlO5H6S49v2Nd8R2C6wLOal0qv6/kCkOwA==", + "version": "4.41.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.41.1.tgz", + "integrity": "sha512-NELNvyEWZ6R9QMkiytB4/L4zSEaBC03KIXEghptLGLZWJ6VPrL63ooZQCOnlx36aQPGhzuOMwDerC1Eb2VmrLw==", "cpu": [ "arm" ], @@ -1826,9 +1798,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.39.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.39.0.tgz", - "integrity": "sha512-It9+M1zE31KWfqh/0cJLrrsCPiF72PoJjIChLX+rEcujVRCb4NLQ5QzFkzIZW8Kn8FTbvGQBY5TkKBau3S8cCQ==", + "version": "4.41.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.41.1.tgz", + "integrity": "sha512-DXdQe1BJ6TK47ukAoZLehRHhfKnKg9BjnQYUu9gzhI8Mwa1d2fzxA1aw2JixHVl403bwp1+/o/NhhHtxWJBgEA==", "cpu": [ "arm64" ], @@ -1840,9 +1812,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.39.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.39.0.tgz", - "integrity": "sha512-lXQnhpFDOKDXiGxsU9/l8UEGGM65comrQuZ+lDcGUx+9YQ9dKpF3rSEGepyeR5AHZ0b5RgiligsBhWZfSSQh8Q==", + "version": "4.41.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.41.1.tgz", + "integrity": "sha512-5afxvwszzdulsU2w8JKWwY8/sJOLPzf0e1bFuvcW5h9zsEg+RQAojdW0ux2zyYAz7R8HvvzKCjLNJhVq965U7w==", "cpu": [ "arm64" ], @@ -1854,9 +1826,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.39.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.39.0.tgz", - "integrity": "sha512-mKXpNZLvtEbgu6WCkNij7CGycdw9cJi2k9v0noMb++Vab12GZjFgUXD69ilAbBh034Zwn95c2PNSz9xM7KYEAQ==", + "version": "4.41.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.41.1.tgz", + "integrity": "sha512-egpJACny8QOdHNNMZKf8xY0Is6gIMz+tuqXlusxquWu3F833DcMwmGM7WlvCO9sB3OsPjdC4U0wHw5FabzCGZg==", "cpu": [ "x64" ], @@ -1868,9 +1840,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.39.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.39.0.tgz", - "integrity": "sha512-jivRRlh2Lod/KvDZx2zUR+I4iBfHcu2V/BA2vasUtdtTN2Uk3jfcZczLa81ESHZHPHy4ih3T/W5rPFZ/hX7RtQ==", + "version": "4.41.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.41.1.tgz", + "integrity": "sha512-DBVMZH5vbjgRk3r0OzgjS38z+atlupJ7xfKIDJdZZL6sM6wjfDNo64aowcLPKIx7LMQi8vybB56uh1Ftck/Atg==", "cpu": [ "arm64" ], @@ -1882,9 +1854,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.39.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.39.0.tgz", - "integrity": "sha512-8RXIWvYIRK9nO+bhVz8DwLBepcptw633gv/QT4015CpJ0Ht8punmoHU/DuEd3iw9Hr8UwUV+t+VNNuZIWYeY7Q==", + "version": "4.41.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.41.1.tgz", + "integrity": "sha512-3FkydeohozEskBxNWEIbPfOE0aqQgB6ttTkJ159uWOFn42VLyfAiyD9UK5mhu+ItWzft60DycIN1Xdgiy8o/SA==", "cpu": [ "x64" ], @@ -1896,9 +1868,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.39.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.39.0.tgz", - "integrity": "sha512-mz5POx5Zu58f2xAG5RaRRhp3IZDK7zXGk5sdEDj4o96HeaXhlUwmLFzNlc4hCQi5sGdR12VDgEUqVSHer0lI9g==", + "version": "4.41.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.41.1.tgz", + "integrity": "sha512-wC53ZNDgt0pqx5xCAgNunkTzFE8GTgdZ9EwYGVcg+jEjJdZGtq9xPjDnFgfFozQI/Xm1mh+D9YlYtl+ueswNEg==", "cpu": [ "arm" ], @@ -1910,9 +1882,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.39.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.39.0.tgz", - "integrity": "sha512-+YDwhM6gUAyakl0CD+bMFpdmwIoRDzZYaTWV3SDRBGkMU/VpIBYXXEvkEcTagw/7VVkL2vA29zU4UVy1mP0/Yw==", + "version": "4.41.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.41.1.tgz", + "integrity": "sha512-jwKCca1gbZkZLhLRtsrka5N8sFAaxrGz/7wRJ8Wwvq3jug7toO21vWlViihG85ei7uJTpzbXZRcORotE+xyrLA==", "cpu": [ "arm" ], @@ -1924,9 +1896,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.39.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.39.0.tgz", - "integrity": "sha512-EKf7iF7aK36eEChvlgxGnk7pdJfzfQbNvGV/+l98iiMwU23MwvmV0Ty3pJ0p5WQfm3JRHOytSIqD9LB7Bq7xdQ==", + "version": "4.41.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.41.1.tgz", + "integrity": "sha512-g0UBcNknsmmNQ8V2d/zD2P7WWfJKU0F1nu0k5pW4rvdb+BIqMm8ToluW/eeRmxCared5dD76lS04uL4UaNgpNA==", "cpu": [ "arm64" ], @@ -1938,9 +1910,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.39.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.39.0.tgz", - "integrity": "sha512-vYanR6MtqC7Z2SNr8gzVnzUul09Wi1kZqJaek3KcIlI/wq5Xtq4ZPIZ0Mr/st/sv/NnaPwy/D4yXg5x0B3aUUA==", + "version": "4.41.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.41.1.tgz", + "integrity": "sha512-XZpeGB5TKEZWzIrj7sXr+BEaSgo/ma/kCgrZgL0oo5qdB1JlTzIYQKel/RmhT6vMAvOdM2teYlAaOGJpJ9lahg==", "cpu": [ "arm64" ], @@ -1952,9 +1924,9 @@ ] }, "node_modules/@rollup/rollup-linux-loongarch64-gnu": { - "version": "4.39.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.39.0.tgz", - "integrity": "sha512-NMRUT40+h0FBa5fb+cpxtZoGAggRem16ocVKIv5gDB5uLDgBIwrIsXlGqYbLwW8YyO3WVTk1FkFDjMETYlDqiw==", + "version": "4.41.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.41.1.tgz", + "integrity": "sha512-bkCfDJ4qzWfFRCNt5RVV4DOw6KEgFTUZi2r2RuYhGWC8WhCA8lCAJhDeAmrM/fdiAH54m0mA0Vk2FGRPyzI+tw==", "cpu": [ "loong64" ], @@ -1966,9 +1938,9 @@ ] }, "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.39.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.39.0.tgz", - "integrity": "sha512-0pCNnmxgduJ3YRt+D+kJ6Ai/r+TaePu9ZLENl+ZDV/CdVczXl95CbIiwwswu4L+K7uOIGf6tMo2vm8uadRaICQ==", + "version": "4.41.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.41.1.tgz", + "integrity": "sha512-3mr3Xm+gvMX+/8EKogIZSIEF0WUu0HL9di+YWlJpO8CQBnoLAEL/roTCxuLncEdgcfJcvA4UMOf+2dnjl4Ut1A==", "cpu": [ "ppc64" ], @@ -1980,9 +1952,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.39.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.39.0.tgz", - "integrity": "sha512-t7j5Zhr7S4bBtksT73bO6c3Qa2AV/HqiGlj9+KB3gNF5upcVkx+HLgxTm8DK4OkzsOYqbdqbLKwvGMhylJCPhQ==", + "version": "4.41.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.41.1.tgz", + "integrity": "sha512-3rwCIh6MQ1LGrvKJitQjZFuQnT2wxfU+ivhNBzmxXTXPllewOF7JR1s2vMX/tWtUYFgphygxjqMl76q4aMotGw==", "cpu": [ "riscv64" ], @@ -1994,9 +1966,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.39.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.39.0.tgz", - "integrity": "sha512-m6cwI86IvQ7M93MQ2RF5SP8tUjD39Y7rjb1qjHgYh28uAPVU8+k/xYWvxRO3/tBN2pZkSMa5RjnPuUIbrwVxeA==", + "version": "4.41.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.41.1.tgz", + "integrity": "sha512-LdIUOb3gvfmpkgFZuccNa2uYiqtgZAz3PTzjuM5bH3nvuy9ty6RGc/Q0+HDFrHrizJGVpjnTZ1yS5TNNjFlklw==", "cpu": [ "riscv64" ], @@ -2008,9 +1980,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.39.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.39.0.tgz", - "integrity": "sha512-iRDJd2ebMunnk2rsSBYlsptCyuINvxUfGwOUldjv5M4tpa93K8tFMeYGpNk2+Nxl+OBJnBzy2/JCscGeO507kA==", + "version": "4.41.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.41.1.tgz", + "integrity": "sha512-oIE6M8WC9ma6xYqjvPhzZYk6NbobIURvP/lEbh7FWplcMO6gn7MM2yHKA1eC/GvYwzNKK/1LYgqzdkZ8YFxR8g==", "cpu": [ "s390x" ], @@ -2022,9 +1994,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.39.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.39.0.tgz", - "integrity": "sha512-t9jqYw27R6Lx0XKfEFe5vUeEJ5pF3SGIM6gTfONSMb7DuG6z6wfj2yjcoZxHg129veTqU7+wOhY6GX8wmf90dA==", + "version": "4.41.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.41.1.tgz", + "integrity": "sha512-cWBOvayNvA+SyeQMp79BHPK8ws6sHSsYnK5zDcsC3Hsxr1dgTABKjMnMslPq1DvZIp6uO7kIWhiGwaTdR4Og9A==", "cpu": [ "x64" ], @@ -2036,9 +2008,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.39.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.39.0.tgz", - "integrity": "sha512-ThFdkrFDP55AIsIZDKSBWEt/JcWlCzydbZHinZ0F/r1h83qbGeenCt/G/wG2O0reuENDD2tawfAj2s8VK7Bugg==", + "version": "4.41.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.41.1.tgz", + "integrity": "sha512-y5CbN44M+pUCdGDlZFzGGBSKCA4A/J2ZH4edTYSSxFg7ce1Xt3GtydbVKWLlzL+INfFIZAEg1ZV6hh9+QQf9YQ==", "cpu": [ "x64" ], @@ -2050,9 +2022,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.39.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.39.0.tgz", - "integrity": "sha512-jDrLm6yUtbOg2TYB3sBF3acUnAwsIksEYjLeHL+TJv9jg+TmTwdyjnDex27jqEMakNKf3RwwPahDIt7QXCSqRQ==", + "version": "4.41.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.41.1.tgz", + "integrity": "sha512-lZkCxIrjlJlMt1dLO/FbpZbzt6J/A8p4DnqzSa4PWqPEUUUnzXLeki/iyPLfV0BmHItlYgHUqJe+3KiyydmiNQ==", "cpu": [ "arm64" ], @@ -2064,9 +2036,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.39.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.39.0.tgz", - "integrity": "sha512-6w9uMuza+LbLCVoNKL5FSLE7yvYkq9laSd09bwS0tMjkwXrmib/4KmoJcrKhLWHvw19mwU+33ndC69T7weNNjQ==", + "version": "4.41.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.41.1.tgz", + "integrity": "sha512-+psFT9+pIh2iuGsxFYYa/LhS5MFKmuivRsx9iPJWNSGbh2XVEjk90fmpUEjCnILPEPJnikAU6SFDiEUyOv90Pg==", "cpu": [ "ia32" ], @@ -2078,9 +2050,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.39.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.39.0.tgz", - "integrity": "sha512-yAkUOkIKZlK5dl7u6dg897doBgLXmUHhIINM2c+sND3DZwnrdQkkSiDh7N75Ll4mM4dxSkYfXqU9fW3lLkMFug==", + "version": "4.41.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.41.1.tgz", + "integrity": "sha512-Wq2zpapRYLfi4aKxf2Xff0tN+7slj2d4R87WEzqw7ZLsVvO5zwYCIuEGSZYiK41+GlwUo1HiR+GdkLEJnCKTCw==", "cpu": [ "x64" ], @@ -2109,13 +2081,25 @@ "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", + "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@tanstack/react-table": { - "version": "8.21.2", - "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.2.tgz", - "integrity": "sha512-11tNlEDTdIhMJba2RBH+ecJ9l1zgS2kjmexDPAraulc8jeNA4xocSNeyzextT0XJyASil4XsCYlJmf5jEWAtYg==", + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz", + "integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==", "license": "MIT", "dependencies": { - "@tanstack/table-core": "8.21.2" + "@tanstack/table-core": "8.21.3" }, "engines": { "node": ">=12" @@ -2130,12 +2114,12 @@ } }, "node_modules/@tanstack/react-virtual": { - "version": "3.13.6", - "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.6.tgz", - "integrity": "sha512-WT7nWs8ximoQ0CDx/ngoFP7HbQF9Q2wQe4nh2NB+u2486eX3nZRE40P9g6ccCVq7ZfTSH5gFOuCoVH5DLNS/aA==", + "version": "3.13.9", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.9.tgz", + "integrity": "sha512-SPWC8kwG/dWBf7Py7cfheAPOxuvIv4fFQ54PdmYbg7CpXfsKxkucak43Q0qKsxVthhUJQ1A7CIMAIplq4BjVwA==", "license": "MIT", "dependencies": { - "@tanstack/virtual-core": "3.13.6" + "@tanstack/virtual-core": "3.13.9" }, "funding": { "type": "github", @@ -2147,9 +2131,9 @@ } }, "node_modules/@tanstack/table-core": { - "version": "8.21.2", - "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.2.tgz", - "integrity": "sha512-uvXk/U4cBiFMxt+p9/G7yUWI/UbHYbyghLCjlpWZ3mLeIZiUBSKcUnw9UnKkdRz7Z/N4UBuFLWQdJCjUe7HjvA==", + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", + "integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==", "license": "MIT", "engines": { "node": ">=12" @@ -2160,9 +2144,9 @@ } }, "node_modules/@tanstack/virtual-core": { - "version": "3.13.6", - "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.6.tgz", - "integrity": "sha512-cnQUeWnhNP8tJ4WsGcYiX24Gjkc9ALstLbHcBj1t3E7EimN6n6kHH+DPV4PpDnuw00NApQp+ViojMj1GRdwYQg==", + "version": "3.13.9", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.9.tgz", + "integrity": "sha512-3jztt0jpaoJO5TARe2WIHC1UQC3VMLAFUW5mmMo0yrkwtDB2AQP0+sh10BVUpWrnvHjSLvzFizydtEGLCJKFoQ==", "license": "MIT", "funding": { "type": "github", @@ -2311,9 +2295,9 @@ } }, "node_modules/@types/babel__generator": { - "version": "7.6.8", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.8.tgz", - "integrity": "sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw==", + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", "dev": true, "license": "MIT", "dependencies": { @@ -2341,6 +2325,16 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/chai": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.2.tgz", + "integrity": "sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*" + } + }, "node_modules/@types/chart.js": { "version": "2.9.41", "resolved": "https://registry.npmjs.org/@types/chart.js/-/chart.js-2.9.41.tgz", @@ -2357,6 +2351,13 @@ "integrity": "sha512-nl09VhutdjINdWyXxHWN/w9zlNCfr60JUqJbd24YXUuCwgeL0TpFSdElCwb6cxfB6ybE19Gjj4g0jsgkXxKv1Q==", "license": "MIT" }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/dompurify": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz", @@ -2386,23 +2387,23 @@ } }, "node_modules/@types/lodash": { - "version": "4.17.16", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.16.tgz", - "integrity": "sha512-HX7Em5NYQAXKW+1T+FiuG27NGwzJfCX3s1GjOa7ujxZa52kjJLOr4FUxT+giF6Tgxv1e+/czV/iTtBw27WTU9g==", + "version": "4.17.17", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.17.tgz", + "integrity": "sha512-RRVJ+J3J+WmyOTqnz3PiBLA501eKwXl2noseKOrNo/6+XEHjTAxO4xHvxQB6QuNm+s4WRbn6rSiap8+EA+ykFQ==", "dev": true, "license": "MIT" }, "node_modules/@types/mixpanel-browser": { - "version": "2.54.0", - "resolved": "https://registry.npmjs.org/@types/mixpanel-browser/-/mixpanel-browser-2.54.0.tgz", - "integrity": "sha512-7DMzIH0M9TlpCTMZidaeXris+aMUyAgMMEZtV1xeD6fSQgpCGklUKqyRgidq5hKPKuNEOWBp73549Gusig/xBA==", + "version": "2.60.0", + "resolved": "https://registry.npmjs.org/@types/mixpanel-browser/-/mixpanel-browser-2.60.0.tgz", + "integrity": "sha512-70oe8T3KdxHwsSo5aZphALdoqcsIorQBrlisnouIn9Do4dmC2C6/D56978CmSE/BO2QHgb85ojPGa4R8OFvVHA==", "dev": true, "license": "MIT" }, "node_modules/@types/node": { - "version": "20.17.30", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.30.tgz", - "integrity": "sha512-7zf4YyHA+jvBNfVrk2Gtvs6x7E8V+YDW05bNfG2XkWDJfYRXrTiP/DsB2zSYTaHX0bGIujTBQdMVAhb+j7mwpg==", + "version": "20.17.57", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.57.tgz", + "integrity": "sha512-f3T4y6VU4fVQDKVqJV4Uppy8c1p/sVvS3peyqxyWnzkqXFJLRU7Y1Bl7rMS1Qe9z0v4M6McY0Fp9yBsgHJUsWQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2479,17 +2480,18 @@ } }, "node_modules/@vitejs/plugin-react": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.3.4.tgz", - "integrity": "sha512-SCCPBJtYLdE8PX/7ZQAs1QAZ8Jqwih+0VBLum1EGqmCCQal+MIUqLCzj3ZUy8ufbC0cAM4LRlSTm7IQJwWT4ug==", + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.5.1.tgz", + "integrity": "sha512-uPZBqSI0YD4lpkIru6M35sIfylLGTyhGHvDZbNLuMA73lMlwJKz5xweH7FajfcCAc2HnINciejA9qTz0dr0M7A==", "dev": true, "license": "MIT", "dependencies": { - "@babel/core": "^7.26.0", + "@babel/core": "^7.26.10", "@babel/plugin-transform-react-jsx-self": "^7.25.9", "@babel/plugin-transform-react-jsx-source": "^7.25.9", + "@rolldown/pluginutils": "1.0.0-beta.9", "@types/babel__core": "^7.20.5", - "react-refresh": "^0.14.2" + "react-refresh": "^0.17.0" }, "engines": { "node": "^14.18.0 || >=16.0.0" @@ -2499,14 +2501,15 @@ } }, "node_modules/@vitest/expect": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.1.1.tgz", - "integrity": "sha512-q/zjrW9lgynctNbwvFtQkGK9+vvHA5UzVi2V8APrp1C6fG6/MuYYkmlx4FubuqLycCeSdHD5aadWfua/Vr0EUA==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.0.tgz", + "integrity": "sha512-0v4YVbhDKX3SKoy0PHWXpKhj44w+3zZkIoVES9Ex2pq+u6+Bijijbi2ua5kE+h3qT6LBWFTNZSCOEU37H8Y5sA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.1.1", - "@vitest/utils": "3.1.1", + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.0", + "@vitest/utils": "3.2.0", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" }, @@ -2515,13 +2518,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.1.1.tgz", - "integrity": "sha512-bmpJJm7Y7i9BBELlLuuM1J1Q6EQ6K5Ye4wcyOpOMXMcePYKSIYlpcrCm4l/O6ja4VJA5G2aMJiuZkZdnxlC3SA==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.0.tgz", + "integrity": "sha512-HFcW0lAMx3eN9vQqis63H0Pscv0QcVMo1Kv8BNysZbxcmHu3ZUYv59DS6BGYiGQ8F5lUkmsfMMlPm4DJFJdf/A==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.1.1", + "@vitest/spy": "3.2.0", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, @@ -2530,7 +2533,7 @@ }, "peerDependencies": { "msw": "^2.4.9", - "vite": "^5.0.0 || ^6.0.0" + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "peerDependenciesMeta": { "msw": { @@ -2542,9 +2545,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.1.1.tgz", - "integrity": "sha512-dg0CIzNx+hMMYfNmSqJlLSXEmnNhMswcn3sXO7Tpldr0LiGmg3eXdLLhwkv2ZqgHb/d5xg5F7ezNFRA1fA13yA==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.0.tgz", + "integrity": "sha512-gUUhaUmPBHFkrqnOokmfMGRBMHhgpICud9nrz/xpNV3/4OXCn35oG+Pl8rYYsKaTNd/FAIrqRHnwpDpmYxCYZw==", "dev": true, "license": "MIT", "dependencies": { @@ -2555,13 +2558,13 @@ } }, "node_modules/@vitest/runner": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.1.1.tgz", - "integrity": "sha512-X/d46qzJuEDO8ueyjtKfxffiXraPRfmYasoC4i5+mlLEJ10UvPb0XH5M9C3gWuxd7BAQhpK42cJgJtq53YnWVA==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.0.tgz", + "integrity": "sha512-bXdmnHxuB7fXJdh+8vvnlwi/m1zvu+I06i1dICVcDQFhyV4iKw2RExC/acavtDn93m/dRuawUObKsrNE1gJacA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "3.1.1", + "@vitest/utils": "3.2.0", "pathe": "^2.0.3" }, "funding": { @@ -2569,13 +2572,13 @@ } }, "node_modules/@vitest/snapshot": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.1.1.tgz", - "integrity": "sha512-bByMwaVWe/+1WDf9exFxWWgAixelSdiwo2p33tpqIlM14vW7PRV5ppayVXtfycqze4Qhtwag5sVhX400MLBOOw==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.0.tgz", + "integrity": "sha512-z7P/EneBRMe7hdvWhcHoXjhA6at0Q4ipcoZo6SqgxLyQQ8KSMMCmvw1cSt7FHib3ozt0wnRHc37ivuUMbxzG/A==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.1.1", + "@vitest/pretty-format": "3.2.0", "magic-string": "^0.30.17", "pathe": "^2.0.3" }, @@ -2584,26 +2587,26 @@ } }, "node_modules/@vitest/spy": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.1.1.tgz", - "integrity": "sha512-+EmrUOOXbKzLkTDwlsc/xrwOlPDXyVk3Z6P6K4oiCndxz7YLpp/0R0UsWVOKT0IXWjjBJuSMk6D27qipaupcvQ==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.0.tgz", + "integrity": "sha512-s3+TkCNUIEOX99S0JwNDfsHRaZDDZZR/n8F0mop0PmsEbQGKZikCGpTGZ6JRiHuONKew3Fb5//EPwCP+pUX9cw==", "dev": true, "license": "MIT", "dependencies": { - "tinyspy": "^3.0.2" + "tinyspy": "^4.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/utils": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.1.1.tgz", - "integrity": "sha512-1XIjflyaU2k3HMArJ50bwSh3wKWPD6Q47wz/NUSmRV0zNywPc4w79ARjg/i/aNINHwA+mIALhUVqD9/aUvZNgg==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.0.tgz", + "integrity": "sha512-gXXOe7Fj6toCsZKVQouTRLJftJwmvbhH5lKOBR6rlP950zUq9AitTUjnFoXS/CqjBC2aoejAztLPzzuva++XBw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.1.1", + "@vitest/pretty-format": "3.2.0", "loupe": "^3.1.3", "tinyrainbow": "^2.0.0" }, @@ -2666,12 +2669,12 @@ } }, "node_modules/antd": { - "version": "5.24.9", - "resolved": "https://registry.npmjs.org/antd/-/antd-5.24.9.tgz", - "integrity": "sha512-liB+Y/JwD5/KSKbK1Z1EVAbWcoWYvWJ1s97AbbT+mOdigpJQuWwH7kG8IXNEljI7onvj0DdD43TXhSRLUu9AMA==", + "version": "5.25.4", + "resolved": "https://registry.npmjs.org/antd/-/antd-5.25.4.tgz", + "integrity": "sha512-yXdWqq1NJSZnD1HoPZWnWuQJGVYYnB3h0Ufsz4sbt3T0N9SdJ4G9GPpLMk8Gn9zWtwBekfR4THPVZ9uzAyhBHQ==", "license": "MIT", "dependencies": { - "@ant-design/colors": "^7.2.0", + "@ant-design/colors": "^7.2.1", "@ant-design/cssinjs": "^1.23.0", "@ant-design/cssinjs-utils": "^1.1.3", "@ant-design/fast-color": "^2.0.6", @@ -2686,11 +2689,11 @@ "classnames": "^2.5.1", "copy-to-clipboard": "^3.3.3", "dayjs": "^1.11.11", - "rc-cascader": "~3.33.1", + "rc-cascader": "~3.34.0", "rc-checkbox": "~3.5.0", "rc-collapse": "~3.9.0", "rc-dialog": "~9.6.0", - "rc-drawer": "~7.2.0", + "rc-drawer": "~7.3.0", "rc-dropdown": "~4.2.1", "rc-field-form": "~2.7.0", "rc-image": "~7.12.0", @@ -2706,17 +2709,17 @@ "rc-rate": "~2.13.1", "rc-resize-observer": "^1.4.3", "rc-segmented": "~2.7.0", - "rc-select": "~14.16.6", + "rc-select": "~14.16.8", "rc-slider": "~11.1.8", "rc-steps": "~6.0.1", "rc-switch": "~4.1.0", - "rc-table": "~7.50.4", + "rc-table": "~7.50.5", "rc-tabs": "~15.6.1", "rc-textarea": "~1.10.0", "rc-tooltip": "~6.4.0", "rc-tree": "~5.13.1", "rc-tree-select": "~5.27.0", - "rc-upload": "~4.8.1", + "rc-upload": "~4.9.2", "rc-util": "^5.44.4", "scroll-into-view-if-needed": "^3.1.0", "throttle-debounce": "^5.0.2" @@ -2942,9 +2945,9 @@ } }, "node_modules/browserslist": { - "version": "4.24.4", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.4.tgz", - "integrity": "sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==", + "version": "4.25.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.0.tgz", + "integrity": "sha512-PJ8gYKeS5e/whHBh8xrwYK+dAvEj7JXtz6uTucnMRB8OiGTsKccFekoRrjajPBHV8oOY+2tI4uxeceSimKwMFA==", "dev": true, "funding": [ { @@ -2962,10 +2965,10 @@ ], "license": "MIT", "dependencies": { - "caniuse-lite": "^1.0.30001688", - "electron-to-chromium": "^1.5.73", + "caniuse-lite": "^1.0.30001718", + "electron-to-chromium": "^1.5.160", "node-releases": "^2.0.19", - "update-browserslist-db": "^1.1.1" + "update-browserslist-db": "^1.1.3" }, "bin": { "browserslist": "cli.js" @@ -3036,9 +3039,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001709", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001709.tgz", - "integrity": "sha512-NgL3vUTnDrPCZ3zTahp4fsugQ4dc7EKTSzwQDPEel6DMoMnfH2jhry9n2Zm8onbSR+f/QtKHFOA+iAQu4kbtWA==", + "version": "1.0.30001720", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001720.tgz", + "integrity": "sha512-Ec/2yV2nNPwb4DnTANEV99ZWwm3ZWfdlfkQbWSDDt+PsXEVYwlhPH8tdMaPunYTKKmz7AnHi2oNEi1GcmKCD8g==", "dev": true, "funding": [ { @@ -3076,13 +3079,6 @@ "node": ">=10.0.0" } }, - "node_modules/canvg/node_modules/regenerator-runtime": { - "version": "0.13.11", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", - "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", - "license": "MIT", - "optional": true - }, "node_modules/chai": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/chai/-/chai-5.2.0.tgz", @@ -3118,9 +3114,9 @@ } }, "node_modules/chart.js": { - "version": "4.4.8", - "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.4.8.tgz", - "integrity": "sha512-IkGZlVpXP+83QpMm4uxEiGqSI7jFizwVtF3+n5Pc3k7sMO+tkd0qxh2OzLhenM0K80xtmAONWGBn082EiBQSDA==", + "version": "4.4.9", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.4.9.tgz", + "integrity": "sha512-EyZ9wWKgpAU0fLJ43YAEIF8sr5F2W3LqbS40ZJyHIner2lY14ufqv2VMp69MAiZ2rpwxEUxEhIH/0U3xyRynxg==", "license": "MIT", "dependencies": { "@kurkle/color": "^0.3.0" @@ -3256,9 +3252,9 @@ } }, "node_modules/core-js": { - "version": "3.41.0", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.41.0.tgz", - "integrity": "sha512-SJ4/EHwS36QMJd6h/Rg+GyR4A5xE0FSI3eZ+iBVpfqf1x0eTSg1smWLHrA+2jQThZSh97fmSgFSU8B61nxosxA==", + "version": "3.42.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.42.0.tgz", + "integrity": "sha512-Sz4PP4ZA+Rq4II21qkNqOEDTDrCvcANId3xpIgB34NDkWc3UduWj2dqEtN9yZIq8Dk3HyPI33x9sqqU5C8sr0g==", "hasInstallScript": true, "license": "MIT", "optional": true, @@ -3384,9 +3380,9 @@ "license": "MIT" }, "node_modules/debug": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -3428,18 +3424,6 @@ "node": ">=6" } }, - "node_modules/detect-libc": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz", - "integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "peer": true, - "engines": { - "node": ">=8" - } - }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -3489,9 +3473,9 @@ "license": "MIT" }, "node_modules/dompurify": { - "version": "3.2.5", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.5.tgz", - "integrity": "sha512-mLPd29uoRe9HpvwP2TxClGQBzGXeEC/we/q+bFlmPPmj2p2Ugl3r6ATu/UU1v77DXNcehiBg9zsr1dREyA/dJQ==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.6.tgz", + "integrity": "sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -3519,9 +3503,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.130", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.130.tgz", - "integrity": "sha512-Ou2u7L9j2XLZbhqzyX0jWDj6gA8D3jIfVzt4rikLf3cGBa0VdReuFimBKS9tQJA4+XpeCxj1NoWlfBXzbMa9IA==", + "version": "1.5.162", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.162.tgz", + "integrity": "sha512-hQA+Zb5QQwoSaXJWEAGEw1zhk//O7qDzib05Z4qTqZfNju/FAkrm5ZInp0JbTp4Z18A6bilopdZWEYrFSsfllA==", "dev": true, "license": "ISC" }, @@ -3599,9 +3583,9 @@ } }, "node_modules/es-module-lexer": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.6.0.tgz", - "integrity": "sha512-qqnD1yMU6tk/jnaMosogGySTZP8YtUgAffA9nMN+E/rjxcfRQ6IEk7IiozUjgxKoFHBGjTLnrHB/YC45r/59EQ==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", "dev": true, "license": "MIT" }, @@ -3633,9 +3617,9 @@ } }, "node_modules/esbuild": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.2.tgz", - "integrity": "sha512-16854zccKPnC+toMywC+uKNeYSv+/eXkevRAfwRD/G9Cleq66m8XFIrigkbvauLLlCfDL45Q2cWegSg53gGBnQ==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.5.tgz", + "integrity": "sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -3646,31 +3630,31 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.2", - "@esbuild/android-arm": "0.25.2", - "@esbuild/android-arm64": "0.25.2", - "@esbuild/android-x64": "0.25.2", - "@esbuild/darwin-arm64": "0.25.2", - "@esbuild/darwin-x64": "0.25.2", - "@esbuild/freebsd-arm64": "0.25.2", - "@esbuild/freebsd-x64": "0.25.2", - "@esbuild/linux-arm": "0.25.2", - "@esbuild/linux-arm64": "0.25.2", - "@esbuild/linux-ia32": "0.25.2", - "@esbuild/linux-loong64": "0.25.2", - "@esbuild/linux-mips64el": "0.25.2", - "@esbuild/linux-ppc64": "0.25.2", - "@esbuild/linux-riscv64": "0.25.2", - "@esbuild/linux-s390x": "0.25.2", - "@esbuild/linux-x64": "0.25.2", - "@esbuild/netbsd-arm64": "0.25.2", - "@esbuild/netbsd-x64": "0.25.2", - "@esbuild/openbsd-arm64": "0.25.2", - "@esbuild/openbsd-x64": "0.25.2", - "@esbuild/sunos-x64": "0.25.2", - "@esbuild/win32-arm64": "0.25.2", - "@esbuild/win32-ia32": "0.25.2", - "@esbuild/win32-x64": "0.25.2" + "@esbuild/aix-ppc64": "0.25.5", + "@esbuild/android-arm": "0.25.5", + "@esbuild/android-arm64": "0.25.5", + "@esbuild/android-x64": "0.25.5", + "@esbuild/darwin-arm64": "0.25.5", + "@esbuild/darwin-x64": "0.25.5", + "@esbuild/freebsd-arm64": "0.25.5", + "@esbuild/freebsd-x64": "0.25.5", + "@esbuild/linux-arm": "0.25.5", + "@esbuild/linux-arm64": "0.25.5", + "@esbuild/linux-ia32": "0.25.5", + "@esbuild/linux-loong64": "0.25.5", + "@esbuild/linux-mips64el": "0.25.5", + "@esbuild/linux-ppc64": "0.25.5", + "@esbuild/linux-riscv64": "0.25.5", + "@esbuild/linux-s390x": "0.25.5", + "@esbuild/linux-x64": "0.25.5", + "@esbuild/netbsd-arm64": "0.25.5", + "@esbuild/netbsd-x64": "0.25.5", + "@esbuild/openbsd-arm64": "0.25.5", + "@esbuild/openbsd-x64": "0.25.5", + "@esbuild/sunos-x64": "0.25.5", + "@esbuild/win32-arm64": "0.25.5", + "@esbuild/win32-ia32": "0.25.5", + "@esbuild/win32-x64": "0.25.5" } }, "node_modules/escalade": { @@ -4049,12 +4033,6 @@ "react-is": "^16.7.0" } }, - "node_modules/hoist-non-react-statics/node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT" - }, "node_modules/html-parse-stringify": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", @@ -4107,9 +4085,9 @@ } }, "node_modules/i18next-browser-languagedetector": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.0.4.tgz", - "integrity": "sha512-f3frU3pIxD50/Tz20zx9TD9HobKYg47fmAETb117GKGPrhwcSSPJDoCposXlVycVebQ9GQohC3Efbpq7/nnJ5w==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.1.0.tgz", + "integrity": "sha512-mHZxNx1Lq09xt5kCauZ/4bsXOEA2pfpwSoU11/QTJB+pD94iONFwp+ohqi///PwiFvjFOxe1akYCdHyFo1ng5Q==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.23.2" @@ -4376,257 +4354,6 @@ "html2canvas": "^1.0.0-rc.5" } }, - "node_modules/lightningcss": { - "version": "1.29.3", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.29.3.tgz", - "integrity": "sha512-GlOJwTIP6TMIlrTFsxTerwC0W6OpQpCGuX1ECRLBUVRh6fpJH3xTqjCjRgQHTb4ZXexH9rtHou1Lf03GKzmhhQ==", - "dev": true, - "license": "MPL-2.0", - "optional": true, - "peer": true, - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-darwin-arm64": "1.29.3", - "lightningcss-darwin-x64": "1.29.3", - "lightningcss-freebsd-x64": "1.29.3", - "lightningcss-linux-arm-gnueabihf": "1.29.3", - "lightningcss-linux-arm64-gnu": "1.29.3", - "lightningcss-linux-arm64-musl": "1.29.3", - "lightningcss-linux-x64-gnu": "1.29.3", - "lightningcss-linux-x64-musl": "1.29.3", - "lightningcss-win32-arm64-msvc": "1.29.3", - "lightningcss-win32-x64-msvc": "1.29.3" - } - }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.29.3", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.29.3.tgz", - "integrity": "sha512-fb7raKO3pXtlNbQbiMeEu8RbBVHnpyqAoxTyTRMEWFQWmscGC2wZxoHzZ+YKAepUuKT9uIW5vL2QbFivTgprZg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.29.3", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.29.3.tgz", - "integrity": "sha512-KF2XZ4ZdmDGGtEYmx5wpzn6u8vg7AdBHaEOvDKu8GOs7xDL/vcU2vMKtTeNe1d4dogkDdi3B9zC77jkatWBwEQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.29.3", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.29.3.tgz", - "integrity": "sha512-VUWeVf+V1UM54jv9M4wen9vMlIAyT69Krl9XjI8SsRxz4tdNV/7QEPlW6JASev/pYdiynUCW0pwaFquDRYdxMw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.29.3", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.29.3.tgz", - "integrity": "sha512-UhgZ/XVNfXQVEJrMIWeK1Laj8KbhjbIz7F4znUk7G4zeGw7TRoJxhb66uWrEsonn1+O45w//0i0Fu0wIovYdYg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.29.3", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.29.3.tgz", - "integrity": "sha512-Pqau7jtgJNmQ/esugfmAT1aCFy/Gxc92FOxI+3n+LbMHBheBnk41xHDhc0HeYlx9G0xP5tK4t0Koy3QGGNqypw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.29.3", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.29.3.tgz", - "integrity": "sha512-dxakOk66pf7KLS7VRYFO7B8WOJLecE5OPL2YOk52eriFd/yeyxt2Km5H0BjLfElokIaR+qWi33gB8MQLrdAY3A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.29.3", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.29.3.tgz", - "integrity": "sha512-ySZTNCpbfbK8rqpKJeJR2S0g/8UqqV3QnzcuWvpI60LWxnFN91nxpSSwCbzfOXkzKfar9j5eOuOplf+klKtINg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.29.3", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.29.3.tgz", - "integrity": "sha512-3pVZhIzW09nzi10usAXfIGTTSTYQ141dk88vGFNCgawIzayiIzZQxEcxVtIkdvlEq2YuFsL9Wcj/h61JHHzuFQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.29.3", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.29.3.tgz", - "integrity": "sha512-VRnkAvtIkeWuoBJeGOTrZxsNp4HogXtcaaLm8agmbYtLDOhQdpgxW6NjZZjDXbvGF+eOehGulXZ3C1TiwHY4QQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.29.3", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.29.3.tgz", - "integrity": "sha512-IszwRPu2cPnDQsZpd7/EAr0x2W7jkaWqQ1SwCVIZ/tSbZVXPLt6k8s6FkcyBjViCzvB5CW0We0QbbP7zp2aBjQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -4832,9 +4559,9 @@ "license": "MIT" }, "node_modules/mixpanel-browser": { - "version": "2.63.0", - "resolved": "https://registry.npmjs.org/mixpanel-browser/-/mixpanel-browser-2.63.0.tgz", - "integrity": "sha512-h7M0J/LR/5YLWCVuvPaYuzwV7CgV9jkJz0m94uaTDPebWkhNQPEir63rf/ZpBZgntyvYjO1yMZp2pIpwQ1sBMQ==", + "version": "2.65.0", + "resolved": "https://registry.npmjs.org/mixpanel-browser/-/mixpanel-browser-2.65.0.tgz", + "integrity": "sha512-BtrVYqilloAqx3TIhoIpNikHznTocEy/z3QIf6WEiz4PFxrgI6LgSMFIVKqLqGZJ8svrPlHbpp/CJp5wQYUZWw==", "license": "Apache-2.0", "dependencies": { "rrweb": "2.0.0-alpha.18" @@ -5116,9 +4843,9 @@ } }, "node_modules/postcss": { - "version": "8.5.3", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", - "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", + "version": "8.5.4", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.4.tgz", + "integrity": "sha512-QSa9EBe+uwlGTFmHsPKokv3B/oEMQZxfqW0QqNCyhpa6mB1afzulwn8hihglqAb2pOw+BJgNlmXQ8la2VeHB7w==", "funding": [ { "type": "opencollective", @@ -5135,7 +4862,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.8", + "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -5282,9 +5009,9 @@ } }, "node_modules/prettier-plugin-tailwindcss": { - "version": "0.6.11", - "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.6.11.tgz", - "integrity": "sha512-YxaYSIvZPAqhrrEpRtonnrXdghZg1irNg4qrjboCXrpybLWVs55cW2N3juhspVJiO0JBvYJT8SYsJpc8OQSnsA==", + "version": "0.6.12", + "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.6.12.tgz", + "integrity": "sha512-OuTQKoqNwV7RnxTPwXWzOFXy6Jc4z8oeRZYGuMpRyG3WbuR3jjXdQFK8qFBMBx8UHWdHrddARz2fgUenild6aw==", "dev": true, "license": "MIT", "engines": { @@ -5396,9 +5123,9 @@ "license": "MIT" }, "node_modules/primereact": { - "version": "10.9.4", - "resolved": "https://registry.npmjs.org/primereact/-/primereact-10.9.4.tgz", - "integrity": "sha512-GMrelh07Wd1cwKjHpay3LCpwP346D43qBVkt8H/anGYC3z7kv5/AP0pizZv+aGQs2Fg5ufTTf+SI7IKWmyzgGg==", + "version": "10.9.6", + "resolved": "https://registry.npmjs.org/primereact/-/primereact-10.9.6.tgz", + "integrity": "sha512-0Jjz/KzfUURSHaPTXJwjL2Dc7CDPnbO17MivyJz7T5smGAMLY5d+IqpQhV61R22G/rDmhMh3+32LCNva2M8fRw==", "license": "MIT", "dependencies": { "@types/react-transition-group": "^4.4.1", @@ -5429,12 +5156,6 @@ "react-is": "^16.13.1" } }, - "node_modules/prop-types/node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT" - }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -5496,16 +5217,10 @@ "shallowequal": "^1.1.0" } }, - "node_modules/rc-animate/node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT" - }, "node_modules/rc-cascader": { - "version": "3.33.1", - "resolved": "https://registry.npmjs.org/rc-cascader/-/rc-cascader-3.33.1.tgz", - "integrity": "sha512-Kyl4EJ7ZfCBuidmZVieegcbFw0RcU5bHHSbtEdmuLYd0fYHCAiYKZ6zon7fWAVyC6rWWOOib0XKdTSf7ElC9rg==", + "version": "3.34.0", + "resolved": "https://registry.npmjs.org/rc-cascader/-/rc-cascader-3.34.0.tgz", + "integrity": "sha512-KpXypcvju9ptjW9FaN2NFcA2QH9E9LHKq169Y0eWtH4e/wHQ5Wh5qZakAgvb8EKZ736WZ3B0zLLOBsrsja5Dag==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.25.7", @@ -5568,9 +5283,9 @@ } }, "node_modules/rc-drawer": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/rc-drawer/-/rc-drawer-7.2.0.tgz", - "integrity": "sha512-9lOQ7kBekEJRdEpScHvtmEtXnAsy+NGDXiRWc2ZVC7QXAazNVbeT4EraQKYwCME8BJLa8Bxqxvs5swwyOepRwg==", + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/rc-drawer/-/rc-drawer-7.3.0.tgz", + "integrity": "sha512-DX6CIgiBWNpJIMGFO8BAISFkxiuKitoizooj4BDyee8/SnBn0zwO2FHrNDpqqepj0E/TFTDpmEBCyFuTgC7MOg==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.23.9", @@ -5651,12 +5366,6 @@ "shallowequal": "^1.1.0" } }, - "node_modules/rc-form/node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT" - }, "node_modules/rc-image": { "version": "7.12.0", "resolved": "https://registry.npmjs.org/rc-image/-/rc-image-7.12.0.tgz", @@ -5914,9 +5623,9 @@ } }, "node_modules/rc-select": { - "version": "14.16.6", - "resolved": "https://registry.npmjs.org/rc-select/-/rc-select-14.16.6.tgz", - "integrity": "sha512-YPMtRPqfZWOm2XGTbx5/YVr1HT0vn//8QS77At0Gjb3Lv+Lbut0IORJPKLWu1hQ3u4GsA0SrDzs7nI8JG7Zmyg==", + "version": "14.16.8", + "resolved": "https://registry.npmjs.org/rc-select/-/rc-select-14.16.8.tgz", + "integrity": "sha512-NOV5BZa1wZrsdkKaiK7LHRuo5ZjZYMDxPP6/1+09+FB4KoNi8jcG1ZqLE3AVCxEsYMBe65OBx71wFoHRTP3LRg==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.10.1", @@ -5987,9 +5696,9 @@ } }, "node_modules/rc-table": { - "version": "7.50.4", - "resolved": "https://registry.npmjs.org/rc-table/-/rc-table-7.50.4.tgz", - "integrity": "sha512-Y+YuncnQqoS5e7yHvfvlv8BmCvwDYDX/2VixTBEhkMDk9itS9aBINp4nhzXFKiBP/frG4w0pS9d9Rgisl0T1Bw==", + "version": "7.50.5", + "resolved": "https://registry.npmjs.org/rc-table/-/rc-table-7.50.5.tgz", + "integrity": "sha512-FDZu8aolhSYd3v9KOc3lZOVAU77wmRRu44R0Wfb8Oj1dXRUsloFaXMSl6f7yuWZUxArJTli7k8TEOX2mvhDl4A==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.10.1", @@ -6100,9 +5809,9 @@ } }, "node_modules/rc-upload": { - "version": "4.8.1", - "resolved": "https://registry.npmjs.org/rc-upload/-/rc-upload-4.8.1.tgz", - "integrity": "sha512-toEAhwl4hjLAI1u8/CgKWt30BR06ulPa4iGQSMvSXoHzO88gPCslxqV/mnn4gJU7PDoltGIC9Eh+wkeudqgHyw==", + "version": "4.9.2", + "resolved": "https://registry.npmjs.org/rc-upload/-/rc-upload-4.9.2.tgz", + "integrity": "sha512-nHx+9rbd1FKMiMRYsqQ3NkXUv7COHPBo3X1Obwq9SWS6/diF/A0aJ5OHubvwUAIDs+4RMleljV0pcrNUc823GQ==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.18.3", @@ -6135,9 +5844,9 @@ "license": "MIT" }, "node_modules/rc-virtual-list": { - "version": "3.18.5", - "resolved": "https://registry.npmjs.org/rc-virtual-list/-/rc-virtual-list-3.18.5.tgz", - "integrity": "sha512-1FuxVSxhzTj3y8k5xMPbhXCB0t2TOiI3Tq+qE2Bu+GGV7f+ECVuQl4OUg6lZ2qT5fordTW7CBpr9czdzXCI7Pg==", + "version": "3.18.6", + "resolved": "https://registry.npmjs.org/rc-virtual-list/-/rc-virtual-list-3.18.6.tgz", + "integrity": "sha512-TQ5SsutL3McvWmmxqQtMIbfeoE3dGjJrRSfKekgby7WQMpPIFvv4ghytp5Z0s3D8Nik9i9YNOCqHBfk86AwgAA==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.20.0", @@ -6199,9 +5908,9 @@ } }, "node_modules/react-i18next": { - "version": "15.4.1", - "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.4.1.tgz", - "integrity": "sha512-ahGab+IaSgZmNPYXdV1n+OYky95TGpFwnKRflX/16dY04DsYYKHtVLjeny7sBSCREEcoMbAgSkFiGLF5g5Oofw==", + "version": "15.5.2", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.5.2.tgz", + "integrity": "sha512-ePODyXgmZQAOYTbZXQn5rRsSBu3Gszo69jxW6aKmlSgxKAI1fOhDwSu6bT4EKHciWPKQ7v7lPrjeiadR6Gi+1A==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.25.0", @@ -6209,7 +5918,8 @@ }, "peerDependencies": { "i18next": ">= 23.2.3", - "react": ">= 16.8.0" + "react": ">= 16.8.0", + "typescript": "^5" }, "peerDependenciesMeta": { "react-dom": { @@ -6217,9 +5927,18 @@ }, "react-native": { "optional": true + }, + "typescript": { + "optional": true } } }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, "node_modules/react-lifecycles-compat": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", @@ -6264,9 +5983,9 @@ } }, "node_modules/react-refresh": { - "version": "0.14.2", - "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", - "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", "dev": true, "license": "MIT", "engines": { @@ -6292,9 +6011,9 @@ } }, "node_modules/react-router": { - "version": "6.30.0", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.0.tgz", - "integrity": "sha512-D3X8FyH9nBcTSHGdEKurK7r8OYE1kKFn3d/CF+CoxbSHkxU7o37+Uh7eAHRXr6k2tSExXYO++07PeXJtA/dEhQ==", + "version": "6.30.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.1.tgz", + "integrity": "sha512-X1m21aEmxGXqENEPG3T6u0Th7g0aS4ZmoNynhbs+Cn+q+QGTLt+d5IQ2bHAXKzKcxGJjxACpVbnYQSCRcfxHlQ==", "license": "MIT", "dependencies": { "@remix-run/router": "1.23.0" @@ -6307,13 +6026,13 @@ } }, "node_modules/react-router-dom": { - "version": "6.30.0", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.0.tgz", - "integrity": "sha512-x30B78HV5tFk8ex0ITwzC9TTZMua4jGyA9IUlH1JLQYQTFyxr/ZxwOJq7evg1JX1qGVUcvhsmQSKdPncQrjTgA==", + "version": "6.30.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.1.tgz", + "integrity": "sha512-llKsgOkZdbPU1Eg3zK8lCn+sjD9wMRZZPuzmdWWX5SUs8OFkN5HnFVC0u5KMeMaC9aoancFI/KoLuKPqN+hxHw==", "license": "MIT", "dependencies": { "@remix-run/router": "1.23.0", - "react-router": "6.30.0" + "react-router": "6.30.1" }, "engines": { "node": ">=14.0.0" @@ -6427,10 +6146,11 @@ } }, "node_modules/regenerator-runtime": { - "version": "0.14.1", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", - "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", - "license": "MIT" + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT", + "optional": true }, "node_modules/reselect": { "version": "5.1.1", @@ -6495,9 +6215,9 @@ } }, "node_modules/rollup": { - "version": "4.39.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.39.0.tgz", - "integrity": "sha512-thI8kNc02yNvnmJp8dr3fNWJ9tCONDhp6TV35X6HkKGGs9E6q7YWCHbe5vKiTa7TAiNcFEmXKj3X/pG2b3ci0g==", + "version": "4.41.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.41.1.tgz", + "integrity": "sha512-cPmwD3FnFv8rKMBc1MxWCwVQFxwf1JEmSX3iQXrRVVG15zerAIXRjMFVWnd5Q5QvgKF7Aj+5ykXFhUl+QGnyOw==", "dev": true, "license": "MIT", "dependencies": { @@ -6511,26 +6231,26 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.39.0", - "@rollup/rollup-android-arm64": "4.39.0", - "@rollup/rollup-darwin-arm64": "4.39.0", - "@rollup/rollup-darwin-x64": "4.39.0", - "@rollup/rollup-freebsd-arm64": "4.39.0", - "@rollup/rollup-freebsd-x64": "4.39.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.39.0", - "@rollup/rollup-linux-arm-musleabihf": "4.39.0", - "@rollup/rollup-linux-arm64-gnu": "4.39.0", - "@rollup/rollup-linux-arm64-musl": "4.39.0", - "@rollup/rollup-linux-loongarch64-gnu": "4.39.0", - "@rollup/rollup-linux-powerpc64le-gnu": "4.39.0", - "@rollup/rollup-linux-riscv64-gnu": "4.39.0", - "@rollup/rollup-linux-riscv64-musl": "4.39.0", - "@rollup/rollup-linux-s390x-gnu": "4.39.0", - "@rollup/rollup-linux-x64-gnu": "4.39.0", - "@rollup/rollup-linux-x64-musl": "4.39.0", - "@rollup/rollup-win32-arm64-msvc": "4.39.0", - "@rollup/rollup-win32-ia32-msvc": "4.39.0", - "@rollup/rollup-win32-x64-msvc": "4.39.0", + "@rollup/rollup-android-arm-eabi": "4.41.1", + "@rollup/rollup-android-arm64": "4.41.1", + "@rollup/rollup-darwin-arm64": "4.41.1", + "@rollup/rollup-darwin-x64": "4.41.1", + "@rollup/rollup-freebsd-arm64": "4.41.1", + "@rollup/rollup-freebsd-x64": "4.41.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.41.1", + "@rollup/rollup-linux-arm-musleabihf": "4.41.1", + "@rollup/rollup-linux-arm64-gnu": "4.41.1", + "@rollup/rollup-linux-arm64-musl": "4.41.1", + "@rollup/rollup-linux-loongarch64-gnu": "4.41.1", + "@rollup/rollup-linux-powerpc64le-gnu": "4.41.1", + "@rollup/rollup-linux-riscv64-gnu": "4.41.1", + "@rollup/rollup-linux-riscv64-musl": "4.41.1", + "@rollup/rollup-linux-s390x-gnu": "4.41.1", + "@rollup/rollup-linux-x64-gnu": "4.41.1", + "@rollup/rollup-linux-x64-musl": "4.41.1", + "@rollup/rollup-win32-arm64-msvc": "4.41.1", + "@rollup/rollup-win32-ia32-msvc": "4.41.1", + "@rollup/rollup-win32-x64-msvc": "4.41.1", "fsevents": "~2.3.2" } }, @@ -6803,9 +6523,9 @@ } }, "node_modules/std-env": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.8.1.tgz", - "integrity": "sha512-vj5lIj3Mwf9D79hBkltk5qmkFI+biIKWS2IBxEyEU3AX1tUf7AoL8nSazCOiiqQsGKIq01SClsKEzweu34uwvA==", + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz", + "integrity": "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==", "dev": true, "license": "MIT" }, @@ -7041,14 +6761,14 @@ } }, "node_modules/terser": { - "version": "5.39.0", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.39.0.tgz", - "integrity": "sha512-LBAhFyLho16harJoWMg/nZsQYgTrg5jXOn2nCYjRUcZZEdE3qa2zb8QEDRUGVZBW4rlazf2fxkg8tztybTaqWw==", + "version": "5.40.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.40.0.tgz", + "integrity": "sha512-cfeKl/jjwSR5ar7d0FGmave9hFGJT8obyo0z+CrQOylLDbk7X81nPU6vq9VORa5jU30SkDnT2FXjLbR8HLP+xA==", "dev": true, "license": "BSD-2-Clause", "dependencies": { "@jridgewell/source-map": "^0.3.3", - "acorn": "^8.8.2", + "acorn": "^8.14.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, @@ -7128,9 +6848,9 @@ "license": "MIT" }, "node_modules/tinyglobby": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz", - "integrity": "sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==", + "version": "0.2.14", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", + "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", "dev": true, "license": "MIT", "dependencies": { @@ -7145,9 +6865,9 @@ } }, "node_modules/tinyglobby/node_modules/fdir": { - "version": "6.4.4", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz", - "integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==", + "version": "6.4.5", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.5.tgz", + "integrity": "sha512-4BG7puHpVsIYxZUbiUE3RqGloLaSSwzYie5jvasC4LWuBWzZawynvYouhjbQKw2JuIGYdm0DzIxl8iVidKlUEw==", "dev": true, "license": "MIT", "peerDependencies": { @@ -7173,15 +6893,15 @@ } }, "node_modules/tinymce": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/tinymce/-/tinymce-7.7.2.tgz", - "integrity": "sha512-GX7Jd0ac9ph3QM2yei4uOoxytKX096CyG6VkkgQNikY39T6cDldoNgaqzHHlcm62WtdBMCd7Ch+PYaRnQo+NLA==", + "version": "7.9.1", + "resolved": "https://registry.npmjs.org/tinymce/-/tinymce-7.9.1.tgz", + "integrity": "sha512-zaOHwmiP1EqTeLRXAvVriDb00JYnfEjWGPdKEuac7MiZJ5aiDMZ4Unc98Gmajn+PBljOmO1GKV6G0KwWn3+k8A==", "license": "GPL-2.0-or-later" }, "node_modules/tinypool": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.0.2.tgz", - "integrity": "sha512-al6n+QEANGFOMf/dmUMsuS5/r9B06uwlyNjZZql/zv8J7ybHCgoihBNORZCY2mzUuAnomQa2JdhyHKzZxPCrFA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.0.tgz", + "integrity": "sha512-7CotroY9a8DKsKprEy/a14aCCm8jYVmR7aFy4fpkZM8sdpNJbKkixuNjgM50yCmip2ezc8z4N7k3oe2+rfRJCQ==", "dev": true, "license": "MIT", "engines": { @@ -7199,9 +6919,9 @@ } }, "node_modules/tinyspy": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", - "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.3.tgz", + "integrity": "sha512-t2T/WLB2WRgZ9EpE4jgPJ9w+i66UZfDc8wHh0xrwiRNN+UwH98GIJkTeZqX9rg0i0ptwzqW+uYeIF0T4F8LR7A==", "dev": true, "license": "MIT", "engines": { @@ -7241,9 +6961,9 @@ "license": "Apache-2.0" }, "node_modules/tsconfck": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/tsconfck/-/tsconfck-3.1.5.tgz", - "integrity": "sha512-CLDfGgUp7XPswWnezWwsCRxNmgQjhYq3VXHM0/XIRxhVrKw0M1if9agzryh1QS3nxjCROvV+xWxoJO1YctzzWg==", + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/tsconfck/-/tsconfck-3.1.6.tgz", + "integrity": "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==", "dev": true, "license": "MIT", "bin": { @@ -7271,7 +6991,7 @@ "version": "5.8.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -7420,17 +7140,17 @@ } }, "node_modules/vite-node": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.1.1.tgz", - "integrity": "sha512-V+IxPAE2FvXpTCHXyNem0M+gWm6J7eRyWPR6vYoG/Gl+IscNOjXzztUhimQgTxaAoUoj40Qqimaa0NLIOOAH4w==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.0.tgz", + "integrity": "sha512-8Fc5Ko5Y4URIJkmMF/iFP1C0/OJyY+VGVe9Nw6WAdZyw4bTO+eVg9mwxWkQp/y8NnAoQY3o9KAvE1ZdA2v+Vmg==", "dev": true, "license": "MIT", "dependencies": { "cac": "^6.7.14", - "debug": "^4.4.0", - "es-module-lexer": "^1.6.0", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", "pathe": "^2.0.3", - "vite": "^5.0.0 || ^6.0.0" + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "bin": { "vite-node": "vite-node.mjs" @@ -7463,9 +7183,9 @@ } }, "node_modules/vite/node_modules/fdir": { - "version": "6.4.4", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz", - "integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==", + "version": "6.4.5", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.5.tgz", + "integrity": "sha512-4BG7puHpVsIYxZUbiUE3RqGloLaSSwzYie5jvasC4LWuBWzZawynvYouhjbQKw2JuIGYdm0DzIxl8iVidKlUEw==", "dev": true, "license": "MIT", "peerDependencies": { @@ -7491,31 +7211,34 @@ } }, "node_modules/vitest": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.1.1.tgz", - "integrity": "sha512-kiZc/IYmKICeBAZr9DQ5rT7/6bD9G7uqQEki4fxazi1jdVl2mWGzedtBs5s6llz59yQhVb7FFY2MbHzHCnT79Q==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.0.tgz", + "integrity": "sha512-P7Nvwuli8WBNmeMHHek7PnGW4oAZl9za1fddfRVidZar8wDZRi7hpznLKQePQ8JPLwSBEYDK11g+++j7uFJV8Q==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "3.1.1", - "@vitest/mocker": "3.1.1", - "@vitest/pretty-format": "^3.1.1", - "@vitest/runner": "3.1.1", - "@vitest/snapshot": "3.1.1", - "@vitest/spy": "3.1.1", - "@vitest/utils": "3.1.1", + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.0", + "@vitest/mocker": "3.2.0", + "@vitest/pretty-format": "^3.2.0", + "@vitest/runner": "3.2.0", + "@vitest/snapshot": "3.2.0", + "@vitest/spy": "3.2.0", + "@vitest/utils": "3.2.0", "chai": "^5.2.0", - "debug": "^4.4.0", - "expect-type": "^1.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", "magic-string": "^0.30.17", "pathe": "^2.0.3", - "std-env": "^3.8.1", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", - "tinypool": "^1.0.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.0", "tinyrainbow": "^2.0.0", - "vite": "^5.0.0 || ^6.0.0", - "vite-node": "3.1.1", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.0", "why-is-node-running": "^2.3.0" }, "bin": { @@ -7531,8 +7254,8 @@ "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.1.1", - "@vitest/ui": "3.1.1", + "@vitest/browser": "3.2.0", + "@vitest/ui": "3.2.0", "happy-dom": "*", "jsdom": "*" }, @@ -7560,6 +7283,19 @@ } } }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/void-elements": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", @@ -7765,16 +7501,16 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.1.tgz", - "integrity": "sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz", + "integrity": "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==", "dev": true, "license": "ISC", "bin": { "yaml": "bin.mjs" }, "engines": { - "node": ">= 14" + "node": ">= 14.6" } } } diff --git a/worklenz-frontend/package.json b/worklenz-frontend/package.json index 62d10a318..79f533b6e 100644 --- a/worklenz-frontend/package.json +++ b/worklenz-frontend/package.json @@ -17,8 +17,8 @@ "@ant-design/icons": "^5.4.0", "@ant-design/pro-components": "^2.7.19", "@dnd-kit/core": "^6.3.1", - "@dnd-kit/modifiers": "^9.0.0", - "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/modifiers": "^6.0.1", + "@dnd-kit/sortable": "^7.0.2", "@dnd-kit/utilities": "^3.2.2", "@emotion/react": "^11.14.0", "@paddle/paddle-js": "^1.3.3", @@ -79,6 +79,12 @@ "vite-tsconfig-paths": "^5.1.4", "vitest": "^3.0.5" }, + "resolutions": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/utilities": "^3.2.2", + "@dnd-kit/sortable": "^7.0.2", + "@dnd-kit/modifiers": "^6.0.1" + }, "eslintConfig": { "extends": [ "react-app", From 66b7dc5322f987350d5aa9d1a1f77e7151c9c414 Mon Sep 17 00:00:00 2001 From: chamiakJ Date: Tue, 3 Jun 2025 11:19:55 +0530 Subject: [PATCH 085/105] refactor(vite.config): simplify configuration and optimize chunking strategy - Removed unnecessary dependency optimizations and SSR configurations for a cleaner setup. - Streamlined the chunking strategy to focus on core dependencies, enhancing loading efficiency. - Adjusted build settings for improved performance and maintainability. --- worklenz-frontend/vite.config.ts | 115 ++----------------------------- 1 file changed, 5 insertions(+), 110 deletions(-) diff --git a/worklenz-frontend/vite.config.ts b/worklenz-frontend/vite.config.ts index 74d3ba778..5e1296d7e 100644 --- a/worklenz-frontend/vite.config.ts +++ b/worklenz-frontend/vite.config.ts @@ -28,44 +28,6 @@ export default defineConfig(({ command }) => { ], }, - // **Optimize Dependencies** - optimizeDeps: { - include: [ - 'react', - 'react-dom', - 'react/jsx-runtime', - 'react/jsx-dev-runtime', - 'antd', - 'react-redux', - '@reduxjs/toolkit', - 'i18next', - 'react-i18next', - 'react-router-dom', - 'moment', - 'date-fns', - 'axios', - 'socket.io-client' - ], - exclude: [ - '@dnd-kit/core', - '@dnd-kit/sortable', - '@dnd-kit/modifiers', - '@dnd-kit/utilities' - ], - force: true, // Force re-optimization on every dev server start - }, - - // **SSR Configuration** - ssr: { - noExternal: ['@dnd-kit/core', '@dnd-kit/utilities', '@dnd-kit/sortable', '@dnd-kit/modifiers'], - }, - - // **Additional Configuration for ES Modules** - define: { - // Ensure global is defined for some packages that expect it - global: 'globalThis', - }, - // **Build** build: { // **Target** @@ -75,9 +37,6 @@ export default defineConfig(({ command }) => { outDir: 'build', assetsDir: 'assets', // Consider a more specific directory for better organization, e.g., 'build/assets' cssCodeSplit: true, - - // **Chunk Size Optimization** - chunkSizeWarningLimit: 1000, // Increase limit for vendor chunks but keep warning for others // **Sourcemaps** sourcemap: command === 'serve' ? 'inline' : true, // Adjust sourcemap strategy based on command @@ -88,86 +47,22 @@ export default defineConfig(({ command }) => { compress: { drop_console: command === 'build', drop_debugger: command === 'build', - // Remove unused code more aggressively - unused: true, - dead_code: true, }, // **Additional Optimization** format: { comments: command === 'serve', // Preserve comments during development }, - mangle: { - // Mangle private properties for smaller bundles - properties: { - regex: /^_/, - }, - }, }, // **Rollup Options** rollupOptions: { output: { - // **Enhanced Chunking Strategy** + // **Chunking Strategy** manualChunks(id) { - // Core React dependencies - include @dnd-kit with React to fix loading order - if (['react', 'react-dom'].includes(id) || id.includes('@dnd-kit')) return 'react-vendor'; - - // Router and navigation - if (id.includes('react-router-dom') || id.includes('react-router')) return 'router'; - - // UI Library - if (id.includes('antd')) return 'antd-ui'; - - // Internationalization - if (id.includes('i18next') || id.includes('react-i18next')) return 'i18n'; - - // Redux and state management - if (id.includes('@reduxjs/toolkit') || id.includes('redux') || id.includes('react-redux')) return 'redux'; - - // Date and time utilities - if (id.includes('moment') || id.includes('dayjs') || id.includes('date-fns')) return 'date-utils'; - - // Charts and visualization - if (id.includes('chart') || id.includes('echarts') || id.includes('highcharts') || id.includes('recharts')) return 'charts'; - - // Text editor - if (id.includes('tinymce') || id.includes('quill') || id.includes('editor')) return 'editors'; - - // Project view components - split into separate chunks for better lazy loading - if (id.includes('/pages/projects/projectView/taskList/')) return 'project-task-list'; - if (id.includes('/pages/projects/projectView/board/')) return 'project-board'; - if (id.includes('/pages/projects/projectView/insights/')) return 'project-insights'; - if (id.includes('/pages/projects/projectView/finance/')) return 'project-finance'; - if (id.includes('/pages/projects/projectView/members/')) return 'project-members'; - if (id.includes('/pages/projects/projectView/files/')) return 'project-files'; - if (id.includes('/pages/projects/projectView/updates/')) return 'project-updates'; - - // Task-related components - if (id.includes('/components/task-') || id.includes('/features/tasks/')) return 'task-components'; - - // Filter components - if (id.includes('/components/project-task-filters/') || id.includes('filter-dropdown')) return 'filter-components'; - - // Other project components - if (id.includes('/pages/projects/') && !id.includes('/projectView/')) return 'project-pages'; - - // Settings and admin - if (id.includes('/pages/settings/') || id.includes('/pages/admin-center/')) return 'settings-admin'; - - // Reporting - if (id.includes('/pages/reporting/') || id.includes('/features/reporting/')) return 'reporting'; - - // Schedule components - if (id.includes('/components/schedule') || id.includes('/features/schedule')) return 'schedule'; - - // Common utilities - if (id.includes('/utils/') || id.includes('/shared/') || id.includes('/hooks/')) return 'common-utils'; - - // API and services - if (id.includes('/api/') || id.includes('/services/')) return 'api-services'; - - // Other vendor libraries - if (id.includes('node_modules')) return 'vendor'; + if (['react', 'react-dom', 'react-router-dom'].includes(id)) return 'vendor'; + if (id.includes('antd')) return 'antd'; + if (id.includes('i18next')) return 'i18n'; + // Add more conditions as needed }, // **File Naming Strategies** chunkFileNames: 'assets/js/[name]-[hash].js', From e82bb23cd58bd7cc53911a013a5fca55942ce1db Mon Sep 17 00:00:00 2001 From: chamiakJ Date: Tue, 3 Jun 2025 14:33:22 +0530 Subject: [PATCH 086/105] feat(members-time-sheet): enhance API response handling and add noCategory filter --- .../reporting-allocation-controller.ts | 105 +++++++++++++++--- .../members-time-sheet/members-time-sheet.tsx | 39 +++++-- 2 files changed, 118 insertions(+), 26 deletions(-) diff --git a/worklenz-backend/src/controllers/reporting/reporting-allocation-controller.ts b/worklenz-backend/src/controllers/reporting/reporting-allocation-controller.ts index 2127d00a2..e948726d2 100644 --- a/worklenz-backend/src/controllers/reporting/reporting-allocation-controller.ts +++ b/worklenz-backend/src/controllers/reporting/reporting-allocation-controller.ts @@ -77,8 +77,8 @@ export default class ReportingAllocationController extends ReportingControllerBa sps.icon AS status_icon, (SELECT COUNT(*) FROM tasks - WHERE CASE WHEN ($1 IS TRUE) THEN project_id IS NOT NULL ELSE archived = FALSE END ${billableQuery} - AND project_id = projects.id) AS all_tasks_count, + WHERE CASE WHEN ($1 IS TRUE) THEN project_id IS NOT NULL ELSE archived = FALSE END + AND project_id = projects.id ${billableQuery}) AS all_tasks_count, (SELECT COUNT(*) FROM tasks WHERE CASE WHEN ($1 IS TRUE) THEN project_id IS NOT NULL ELSE archived = FALSE END @@ -95,9 +95,10 @@ export default class ReportingAllocationController extends ReportingControllerBa (SELECT COALESCE(SUM(time_spent), 0) FROM task_work_log LEFT JOIN tasks ON task_work_log.task_id = tasks.id - WHERE user_id = users.id ${billableQuery} + WHERE user_id = users.id AND CASE WHEN ($1 IS TRUE) THEN tasks.project_id IS NOT NULL ELSE tasks.archived = FALSE END AND tasks.project_id = projects.id + ${billableQuery} ${duration}) AS time_logged FROM users WHERE id IN (${userIds}) @@ -121,10 +122,11 @@ export default class ReportingAllocationController extends ReportingControllerBa const q = `(SELECT id, (SELECT COALESCE(SUM(time_spent), 0) FROM task_work_log - LEFT JOIN tasks ON task_work_log.task_id = tasks.id ${billableQuery} + LEFT JOIN tasks ON task_work_log.task_id = tasks.id WHERE user_id = users.id AND CASE WHEN ($1 IS TRUE) THEN tasks.project_id IS NOT NULL ELSE tasks.archived = FALSE END AND tasks.project_id IN (${projectIds}) + ${billableQuery} ${duration}) AS time_logged FROM users WHERE id IN (${userIds}) @@ -346,6 +348,8 @@ export default class ReportingAllocationController extends ReportingControllerBa const projects = (req.body.projects || []) as string[]; const projectIds = projects.map(p => `'${p}'`).join(","); + const categories = (req.body.categories || []) as string[]; + const noCategory = req.body.noCategory || false; const billable = req.body.billable; if (!teamIds || !projectIds.length) @@ -361,6 +365,33 @@ export default class ReportingAllocationController extends ReportingControllerBa const billableQuery = this.buildBillableQuery(billable); + // Prepare projects filter + let projectsFilter = ""; + if (projectIds.length > 0) { + projectsFilter = `AND p.id IN (${projectIds})`; + } else { + // If no projects are selected, don't show any data + projectsFilter = `AND 1=0`; // This will match no rows + } + + // Prepare categories filter - updated logic + let categoriesFilter = ""; + if (categories.length > 0 && noCategory) { + // Both specific categories and "No Category" are selected + const categoryIds = categories.map(id => `'${id}'`).join(","); + categoriesFilter = `AND (p.category_id IS NULL OR p.category_id IN (${categoryIds}))`; + } else if (categories.length === 0 && noCategory) { + // Only "No Category" is selected + categoriesFilter = `AND p.category_id IS NULL`; + } else if (categories.length > 0 && !noCategory) { + // Only specific categories are selected + const categoryIds = categories.map(id => `'${id}'`).join(","); + categoriesFilter = `AND p.category_id IN (${categoryIds})`; + } else { + // categories.length === 0 && !noCategory - no categories selected, show nothing + categoriesFilter = `AND 1=0`; // This will match no rows + } + const q = ` SELECT p.id, p.name, @@ -368,15 +399,13 @@ export default class ReportingAllocationController extends ReportingControllerBa SUM(total_minutes) AS estimated, color_code FROM projects p - LEFT JOIN tasks ON tasks.project_id = p.id ${billableQuery} + LEFT JOIN tasks ON tasks.project_id = p.id LEFT JOIN task_work_log ON task_work_log.task_id = tasks.id - WHERE p.id IN (${projectIds}) ${durationClause} ${archivedClause} + WHERE p.id IN (${projectIds}) ${durationClause} ${archivedClause} ${categoriesFilter} ${billableQuery} GROUP BY p.id, p.name ORDER BY logged_time DESC;`; - console.log('Query:', q); const result = await db.query(q, []); - console.log('Query result count:', result.rows.length); - console.log('Query results:', result.rows); + const utilization = (req.body.utilization || []) as string[]; const data = []; @@ -406,6 +435,7 @@ export default class ReportingAllocationController extends ReportingControllerBa const projectIds = projects.map(p => `'${p}'`).join(","); const categories = (req.body.categories || []) as string[]; + const noCategory = req.body.noCategory || false; const billable = req.body.billable; if (!teamIds) @@ -529,13 +559,27 @@ export default class ReportingAllocationController extends ReportingControllerBa let projectsFilter = ""; if (projectIds.length > 0) { projectsFilter = `AND p.id IN (${projectIds})`; + } else { + // If no projects are selected, don't show any data + projectsFilter = `AND 1=0`; // This will match no rows } - // Prepare categories filter + // Prepare categories filter - updated logic let categoriesFilter = ""; - if (categories.length > 0) { + if (categories.length > 0 && noCategory) { + // Both specific categories and "No Category" are selected + const categoryIds = categories.map(id => `'${id}'`).join(","); + categoriesFilter = `AND (p.category_id IS NULL OR p.category_id IN (${categoryIds}))`; + } else if (categories.length === 0 && noCategory) { + // Only "No Category" is selected + categoriesFilter = `AND p.category_id IS NULL`; + } else if (categories.length > 0 && !noCategory) { + // Only specific categories are selected const categoryIds = categories.map(id => `'${id}'`).join(","); categoriesFilter = `AND p.category_id IN (${categoryIds})`; + } else { + // categories.length === 0 && !noCategory - no categories selected, show nothing + categoriesFilter = `AND 1=0`; // This will match no rows } // Create custom duration clause for twl table alias @@ -569,13 +613,14 @@ export default class ReportingAllocationController extends ReportingControllerBa COALESCE( (SELECT SUM(twl.time_spent) FROM task_work_log twl - LEFT JOIN tasks t ON t.id = twl.task_id ${billableQuery} + LEFT JOIN tasks t ON t.id = twl.task_id LEFT JOIN projects p ON p.id = t.project_id WHERE twl.user_id = tmiv.user_id ${customDurationClause} ${projectsFilter} ${categoriesFilter} ${archivedClause} + ${billableQuery} AND p.team_id = tmiv.team_id ), 0 ) AS logged_time @@ -585,7 +630,7 @@ export default class ReportingAllocationController extends ReportingControllerBa ${membersFilter} GROUP BY tmiv.email, tmiv.name, tmiv.team_member_id, tmiv.user_id, tmiv.team_id ORDER BY logged_time DESC;`; - + const result = await db.query(q, []); const utilization = (req.body.utilization || []) as string[]; @@ -728,6 +773,9 @@ export default class ReportingAllocationController extends ReportingControllerBa const projects = (req.body.projects || []) as string[]; const projectIds = projects.map(p => `'${p}'`).join(","); + + const categories = (req.body.categories || []) as string[]; + const noCategory = req.body.noCategory || false; const { type, billable } = req.body; if (!teamIds || !projectIds.length) @@ -743,6 +791,33 @@ export default class ReportingAllocationController extends ReportingControllerBa const billableQuery = this.buildBillableQuery(billable); + // Prepare projects filter + let projectsFilter = ""; + if (projectIds.length > 0) { + projectsFilter = `AND p.id IN (${projectIds})`; + } else { + // If no projects are selected, don't show any data + projectsFilter = `AND 1=0`; // This will match no rows + } + + // Prepare categories filter - updated logic + let categoriesFilter = ""; + if (categories.length > 0 && noCategory) { + // Both specific categories and "No Category" are selected + const categoryIds = categories.map(id => `'${id}'`).join(","); + categoriesFilter = `AND (p.category_id IS NULL OR p.category_id IN (${categoryIds}))`; + } else if (categories.length === 0 && noCategory) { + // Only "No Category" is selected + categoriesFilter = `AND p.category_id IS NULL`; + } else if (categories.length > 0 && !noCategory) { + // Only specific categories are selected + const categoryIds = categories.map(id => `'${id}'`).join(","); + categoriesFilter = `AND p.category_id IN (${categoryIds})`; + } else { + // categories.length === 0 && !noCategory - no categories selected, show nothing + categoriesFilter = `AND 1=0`; // This will match no rows + } + const q = ` SELECT p.id, p.name, @@ -756,9 +831,9 @@ export default class ReportingAllocationController extends ReportingControllerBa WHERE project_id = p.id) AS estimated, color_code FROM projects p - LEFT JOIN tasks ON tasks.project_id = p.id ${billableQuery} + LEFT JOIN tasks ON tasks.project_id = p.id LEFT JOIN task_work_log ON task_work_log.task_id = tasks.id - WHERE p.id IN (${projectIds}) ${durationClause} ${archivedClause} + WHERE p.id IN (${projectIds}) ${durationClause} ${archivedClause} ${categoriesFilter} ${billableQuery} GROUP BY p.id, p.name ORDER BY logged_time DESC;`; const result = await db.query(q, []); diff --git a/worklenz-frontend/src/pages/reporting/time-reports/members-time-sheet/members-time-sheet.tsx b/worklenz-frontend/src/pages/reporting/time-reports/members-time-sheet/members-time-sheet.tsx index a7b13090b..ee465bc2f 100644 --- a/worklenz-frontend/src/pages/reporting/time-reports/members-time-sheet/members-time-sheet.tsx +++ b/worklenz-frontend/src/pages/reporting/time-reports/members-time-sheet/members-time-sheet.tsx @@ -15,7 +15,6 @@ import { useTranslation } from 'react-i18next'; import { reportingTimesheetApiService } from '@/api/reporting/reporting.timesheet.api.service'; import { IRPTTimeMember } from '@/types/reporting/reporting.types'; import logger from '@/utils/errorLogger'; -import { useAppDispatch } from '@/hooks/useAppDispatch'; import { format } from 'date-fns'; ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend, ChartDataLabels); @@ -29,7 +28,6 @@ export interface MembersTimeSheetRef { const MembersTimeSheet = forwardRef(({ onTotalsUpdate }, ref) => { const { t } = useTranslation('time-report'); - const dispatch = useAppDispatch(); const chartRef = React.useRef>(null); const { @@ -45,6 +43,7 @@ const MembersTimeSheet = forwardRef( loadingUtilization, billable, archived, + noCategory, } = useAppSelector(state => state.timeReportsOverviewReducer); const { duration, dateRange } = useAppSelector(state => state.reportingReducer); @@ -223,23 +222,41 @@ const MembersTimeSheet = forwardRef( duration, date_range: formattedDateRange, billable, + noCategory, }; const res = await reportingTimesheetApiService.getMemberTimeSheets(body, archived); + if (res.done) { - setJsonData(res.body.filteredRows || []); + // Ensure filteredRows is always an array, even if API returns null/undefined + setJsonData(res.body?.filteredRows || []); - const totalsRaw = res.body.totals || {}; - const totals = { - total_time_logs: totalsRaw.total_time_logs ?? "0", - total_estimated_hours: totalsRaw.total_estimated_hours ?? "0", - total_utilization: totalsRaw.total_utilization ?? "0", - }; - onTotalsUpdate(totals); + const totalsRaw = res.body?.totals || {}; + const totals = { + total_time_logs: totalsRaw.total_time_logs ?? "0", + total_estimated_hours: totalsRaw.total_estimated_hours ?? "0", + total_utilization: totalsRaw.total_utilization ?? "0", + }; + onTotalsUpdate(totals); + } else { + // Handle API error case + setJsonData([]); + onTotalsUpdate({ + total_time_logs: "0", + total_estimated_hours: "0", + total_utilization: "0" + }); } } catch (error) { console.error('Error fetching chart data:', error); logger.error('Error fetching chart data:', error); + // Reset data on error + setJsonData([]); + onTotalsUpdate({ + total_time_logs: "0", + total_estimated_hours: "0", + total_utilization: "0" + }); } finally { setLoading(false); } @@ -247,7 +264,7 @@ const MembersTimeSheet = forwardRef( useEffect(() => { fetchChartData(); - }, [dispatch, duration, dateRange, billable, archived, teams, filterProjects, categories, members, utilization]); + }, [duration, dateRange, billable, archived, teams, filterProjects, categories, members, utilization]); const exportChart = () => { if (chartRef.current) { From 13baf36e3c886a00a1270f77949f97a051921c36 Mon Sep 17 00:00:00 2001 From: chamiakJ Date: Tue, 3 Jun 2025 16:23:07 +0530 Subject: [PATCH 087/105] feat(reporting-allocation): add helper method for billable query with custom alias and enhance logging for debugging - Introduced a new method to build billable queries with customizable table aliases, improving query flexibility. - Enhanced logging throughout the reporting allocation process to aid in debugging and provide clearer insights into query generation and utilization state calculations. --- .../reporting-allocation-controller.ts | 21 +++++++++++++- .../members-time-sheet/members-time-sheet.tsx | 28 ++++++++++++++++++- 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/worklenz-backend/src/controllers/reporting/reporting-allocation-controller.ts b/worklenz-backend/src/controllers/reporting/reporting-allocation-controller.ts index e948726d2..0bef5bb12 100644 --- a/worklenz-backend/src/controllers/reporting/reporting-allocation-controller.ts +++ b/worklenz-backend/src/controllers/reporting/reporting-allocation-controller.ts @@ -15,6 +15,25 @@ enum IToggleOptions { } export default class ReportingAllocationController extends ReportingControllerBase { + // Helper method to build billable query with custom table alias + private static buildBillableQueryWithAlias(selectedStatuses: { billable: boolean; nonBillable: boolean }, tableAlias: string = 'tasks'): string { + const { billable, nonBillable } = selectedStatuses; + + if (billable && nonBillable) { + // Both are enabled, no need to filter + return ""; + } else if (billable && !nonBillable) { + // Only billable is enabled - show only billable tasks + return ` AND ${tableAlias}.billable IS TRUE`; + } else if (!billable && nonBillable) { + // Only non-billable is enabled - show only non-billable tasks + return ` AND ${tableAlias}.billable IS FALSE`; + } else { + // Neither selected - this shouldn't happen in normal UI flow + return ""; + } + } + private static async getTimeLoggedByProjects(projects: string[], users: string[], key: string, dateRange: string[], archived = false, user_id = "", billable: { billable: boolean; nonBillable: boolean }): Promise { try { const projectIds = projects.map(p => `'${p}'`).join(","); @@ -545,7 +564,7 @@ export default class ReportingAllocationController extends ReportingControllerBa ? "" : `AND p.id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = p.id AND user_id = '${req.user?.id}') `; - const billableQuery = this.buildBillableQuery(billable); + const billableQuery = this.buildBillableQueryWithAlias(billable, 't'); const members = (req.body.members || []) as string[]; // Prepare members filter diff --git a/worklenz-frontend/src/pages/reporting/time-reports/members-time-sheet/members-time-sheet.tsx b/worklenz-frontend/src/pages/reporting/time-reports/members-time-sheet/members-time-sheet.tsx index ee465bc2f..72b74b6b8 100644 --- a/worklenz-frontend/src/pages/reporting/time-reports/members-time-sheet/members-time-sheet.tsx +++ b/worklenz-frontend/src/pages/reporting/time-reports/members-time-sheet/members-time-sheet.tsx @@ -262,9 +262,35 @@ const MembersTimeSheet = forwardRef( } }; + // Create stable references for selected items to prevent unnecessary re-renders + const selectedTeamIds = React.useMemo(() => + teams.filter(team => team.selected).map(t => t.id).join(','), + [teams] + ); + + const selectedProjectIds = React.useMemo(() => + filterProjects.filter(project => project.selected).map(p => p.id).join(','), + [filterProjects] + ); + + const selectedCategoryIds = React.useMemo(() => + categories.filter(category => category.selected).map(c => c.id).join(','), + [categories] + ); + + const selectedMemberIds = React.useMemo(() => + members.filter(member => member.selected).map(m => m.id).join(','), + [members] + ); + + const selectedUtilizationIds = React.useMemo(() => + utilization.filter(item => item.selected).map(u => u.id).join(','), + [utilization] + ); + useEffect(() => { fetchChartData(); - }, [duration, dateRange, billable, archived, teams, filterProjects, categories, members, utilization]); + }, [duration, dateRange, billable, archived, noCategory, selectedTeamIds, selectedProjectIds, selectedCategoryIds, selectedMemberIds, selectedUtilizationIds]); const exportChart = () => { if (chartRef.current) { From 1ec97594343c526fe42e51f363b5c0dc37b0a130 Mon Sep 17 00:00:00 2001 From: chamiakJ Date: Tue, 3 Jun 2025 17:45:03 +0530 Subject: [PATCH 088/105] feat(reporting-allocation): update members and utilization filters to handle "Clear All" scenario - Enhanced members filter logic to return no data when no members are selected. - Updated utilization filter to return an empty array when no utilization states are selected, improving clarity in reporting results. --- .../reporting-allocation-controller.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/worklenz-backend/src/controllers/reporting/reporting-allocation-controller.ts b/worklenz-backend/src/controllers/reporting/reporting-allocation-controller.ts index 0bef5bb12..1987d0fb1 100644 --- a/worklenz-backend/src/controllers/reporting/reporting-allocation-controller.ts +++ b/worklenz-backend/src/controllers/reporting/reporting-allocation-controller.ts @@ -567,11 +567,14 @@ export default class ReportingAllocationController extends ReportingControllerBa const billableQuery = this.buildBillableQueryWithAlias(billable, 't'); const members = (req.body.members || []) as string[]; - // Prepare members filter + // Prepare members filter - updated logic to handle Clear All scenario let membersFilter = ""; if (members.length > 0) { const memberIds = members.map(id => `'${id}'`).join(","); membersFilter = `AND tmiv.team_member_id IN (${memberIds})`; + } else { + // No members selected - show no data (Clear All scenario) + membersFilter = `AND 1=0`; // This will match no rows } // Prepare projects filter @@ -655,7 +658,6 @@ export default class ReportingAllocationController extends ReportingControllerBa // Precompute totalWorkingHours * 3600 for efficiency const totalWorkingSeconds = totalWorkingHours * 3600; - const hasUtilizationFilter = utilization.length > 0; // calculate utilization state for (let i = 0, len = result.rows.length; i < len; i++) { @@ -691,9 +693,15 @@ export default class ReportingAllocationController extends ReportingControllerBa } } - const filteredRows = hasUtilizationFilter - ? result.rows.filter(member => utilization.includes(member.utilization_state)) - : result.rows; + // Apply utilization filter + let filteredRows; + if (utilization.length > 0) { + // Filter to only show selected utilization states + filteredRows = result.rows.filter(member => utilization.includes(member.utilization_state)); + } else { + // No utilization states selected - show no data (Clear All scenario) + filteredRows = []; + } // Calculate totals const total_time_logs = filteredRows.reduce((sum, member) => sum + parseFloat(member.logged_time || '0'), 0); From d6686d64bef90c5249e2e16aa74582eb5187e2fb Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Wed, 4 Jun 2025 11:30:51 +0530 Subject: [PATCH 089/105] feat(project-currency): implement project-specific currency support - Added a currency column to the projects table to allow different projects to use different currencies. - Updated existing projects to default to 'USD' if no currency is set. - Enhanced project finance controller to handle currency retrieval and updates. - Introduced API endpoints for updating project currency with validation. - Updated frontend components to display and manage project currency effectively. --- .../20250117000000-add-project-currency.sql | 20 ++++ worklenz-backend/database/sql/1_tables.sql | 5 +- worklenz-backend/database/sql/4_functions.sql | 3 +- .../controllers/project-finance-controller.ts | 106 +++++++++++++++++- .../src/controllers/projects-controller.ts | 1 + .../routes/apis/project-finance-api-router.ts | 1 + .../project-finance.api.service.ts | 11 ++ .../src/api/projects/projects.api.service.ts | 10 ++ .../src/features/project/project.slice.ts | 8 +- .../projects/finance/project-finance.slice.ts | 6 +- .../src/lib/settings/settings-constants.ts | 2 +- .../finance-table/finance-table-wrapper.tsx | 2 +- .../finance/project-view-finance.tsx | 47 ++++++-- .../reatecard-table/ratecard-table.tsx | 8 +- .../rate-card-settings.tsx} | 0 .../src/shared/constants/currencies.ts | 54 +++++++++ .../types/project/project-finance.types.ts | 7 ++ .../types/project/projectViewModel.types.ts | 1 + 18 files changed, 269 insertions(+), 23 deletions(-) create mode 100644 worklenz-backend/database/migrations/20250117000000-add-project-currency.sql rename worklenz-frontend/src/pages/settings/{ratecard/ratecard-settings.tsx => rate-card/rate-card-settings.tsx} (100%) create mode 100644 worklenz-frontend/src/shared/constants/currencies.ts diff --git a/worklenz-backend/database/migrations/20250117000000-add-project-currency.sql b/worklenz-backend/database/migrations/20250117000000-add-project-currency.sql new file mode 100644 index 000000000..f9910f76d --- /dev/null +++ b/worklenz-backend/database/migrations/20250117000000-add-project-currency.sql @@ -0,0 +1,20 @@ +-- Migration: Add currency column to projects table +-- Date: 2025-01-17 +-- Description: Adds project-specific currency support to allow different projects to use different currencies + +-- Add currency column to projects table +ALTER TABLE projects + ADD COLUMN IF NOT EXISTS currency VARCHAR(3) DEFAULT 'USD'; + +-- Add comment for documentation +COMMENT ON COLUMN projects.currency IS 'Project-specific currency code (e.g., USD, EUR, GBP, JPY, etc.)'; + +-- Add constraint to ensure currency codes are uppercase and 3 characters +ALTER TABLE projects + ADD CONSTRAINT projects_currency_format_check + CHECK (currency ~ '^[A-Z]{3}$'); + +-- Update existing projects to have a default currency if they don't have one +UPDATE projects +SET currency = 'USD' +WHERE currency IS NULL; \ No newline at end of file diff --git a/worklenz-backend/database/sql/1_tables.sql b/worklenz-backend/database/sql/1_tables.sql index 670d12faf..27d89b578 100644 --- a/worklenz-backend/database/sql/1_tables.sql +++ b/worklenz-backend/database/sql/1_tables.sql @@ -783,9 +783,12 @@ CREATE TABLE IF NOT EXISTS projects ( estimated_working_days INTEGER DEFAULT 0, use_manual_progress BOOLEAN DEFAULT FALSE, use_weighted_progress BOOLEAN DEFAULT FALSE, - use_time_progress BOOLEAN DEFAULT FALSE + use_time_progress BOOLEAN DEFAULT FALSE, + currency VARCHAR(3) DEFAULT 'USD' ); +COMMENT ON COLUMN projects.currency IS 'Project-specific currency code (e.g., USD, EUR, GBP, JPY, etc.)'; + ALTER TABLE projects ADD CONSTRAINT projects_pk PRIMARY KEY (id); diff --git a/worklenz-backend/database/sql/4_functions.sql b/worklenz-backend/database/sql/4_functions.sql index 88ac6a0b6..f422ca478 100644 --- a/worklenz-backend/database/sql/4_functions.sql +++ b/worklenz-backend/database/sql/4_functions.sql @@ -5401,7 +5401,8 @@ BEGIN updated_at = CURRENT_TIMESTAMP, estimated_working_days = (_body ->> 'working_days')::INTEGER, estimated_man_days = (_body ->> 'man_days')::INTEGER, - hours_per_day = (_body ->> 'hours_per_day')::INTEGER + hours_per_day = (_body ->> 'hours_per_day')::INTEGER, + currency = COALESCE(UPPER((_body ->> 'currency')::TEXT), currency) WHERE id = (_body ->> 'id')::UUID AND team_id = _team_id RETURNING id INTO _project_id; diff --git a/worklenz-backend/src/controllers/project-finance-controller.ts b/worklenz-backend/src/controllers/project-finance-controller.ts index cb82c4323..b76aa0b69 100644 --- a/worklenz-backend/src/controllers/project-finance-controller.ts +++ b/worklenz-backend/src/controllers/project-finance-controller.ts @@ -51,6 +51,20 @@ export default class ProjectfinanceController extends WorklenzControllerBase { const projectId = req.params.project_id; const groupBy = req.query.group_by || "status"; + // Get project information including currency + const projectQuery = ` + SELECT id, name, currency + FROM projects + WHERE id = $1 + `; + const projectResult = await db.query(projectQuery, [projectId]); + + if (projectResult.rows.length === 0) { + return res.status(404).send(new ServerResponse(false, null, "Project not found")); + } + + const project = projectResult.rows[0]; + // First, get the project rate cards for this project const rateCardQuery = ` SELECT @@ -339,10 +353,15 @@ export default class ProjectfinanceController extends WorklenzControllerBase { }; }); - // Include project rate cards in the response for reference + // Include project rate cards and currency in the response for reference const responseData = { groups: groupedTasks, project_rate_cards: projectRateCards, + project: { + id: project.id, + name: project.name, + currency: project.currency || "USD" + } }; return res.status(200).send(new ServerResponse(true, responseData)); @@ -703,10 +722,12 @@ export default class ProjectfinanceController extends WorklenzControllerBase { const projectId = req.params.project_id; const groupBy = (req.query.groupBy as string) || "status"; - // Get project name for filename - const projectNameQuery = `SELECT name FROM projects WHERE id = $1`; - const projectNameResult = await db.query(projectNameQuery, [projectId]); - const projectName = projectNameResult.rows[0]?.name || "Unknown Project"; + // Get project name and currency for filename and export + const projectQuery = `SELECT name, currency FROM projects WHERE id = $1`; + const projectResult = await db.query(projectQuery, [projectId]); + const project = projectResult.rows[0]; + const projectName = project?.name || "Unknown Project"; + const projectCurrency = project?.currency || "USD"; // First, get the project rate cards for this project const rateCardQuery = ` @@ -1025,7 +1046,7 @@ export default class ProjectfinanceController extends WorklenzControllerBase { // Add title row worksheet.getCell( "A1" - ).value = `Finance Data Export - ${projectName} - ${moment().format( + ).value = `Finance Data Export - ${projectName} (${projectCurrency}) - ${moment().format( "MMM DD, YYYY" )}`; worksheet.mergeCells("A1:L1"); @@ -1096,4 +1117,77 @@ export default class ProjectfinanceController extends WorklenzControllerBase { // Send the Excel file as a response res.end(buffer); } + + @HandleExceptions() + public static async updateProjectCurrency( + req: IWorkLenzRequest, + res: IWorkLenzResponse + ): Promise { + const projectId = req.params.project_id; + const { currency } = req.body; + + // Validate currency format (3-character uppercase code) + if (!currency || typeof currency !== "string" || !/^[A-Z]{3}$/.test(currency)) { + return res + .status(400) + .send(new ServerResponse(false, null, "Invalid currency format. Currency must be a 3-character uppercase code (e.g., USD, EUR, GBP)")); + } + + // Check if project exists and user has access + const projectCheckQuery = ` + SELECT p.id, p.name, p.currency as current_currency + FROM projects p + WHERE p.id = $1 AND p.team_id = $2 + `; + + const projectCheckResult = await db.query(projectCheckQuery, [projectId, req.user?.team_id]); + + if (projectCheckResult.rows.length === 0) { + return res + .status(404) + .send(new ServerResponse(false, null, "Project not found or access denied")); + } + + const project = projectCheckResult.rows[0]; + + // Update project currency + const updateQuery = ` + UPDATE projects + SET currency = $1, updated_at = NOW() + WHERE id = $2 AND team_id = $3 + RETURNING id, name, currency; + `; + + const result = await db.query(updateQuery, [currency, projectId, req.user?.team_id]); + + if (result.rows.length === 0) { + return res + .status(500) + .send(new ServerResponse(false, null, "Failed to update project currency")); + } + + const updatedProject = result.rows[0]; + + // Log the currency change for audit purposes + const logQuery = ` + INSERT INTO project_logs (team_id, project_id, description) + VALUES ($1, $2, $3) + `; + + const logDescription = `Project currency changed from ${project.current_currency || "USD"} to ${currency}`; + + try { + await db.query(logQuery, [req.user?.team_id, projectId, logDescription]); + } catch (error) { + console.error("Failed to log currency change:", error); + // Don't fail the request if logging fails + } + + return res.status(200).send(new ServerResponse(true, { + id: updatedProject.id, + name: updatedProject.name, + currency: updatedProject.currency, + message: `Project currency updated to ${currency}` + })); + } } diff --git a/worklenz-backend/src/controllers/projects-controller.ts b/worklenz-backend/src/controllers/projects-controller.ts index 9a2f2d749..1f0c4efc3 100644 --- a/worklenz-backend/src/controllers/projects-controller.ts +++ b/worklenz-backend/src/controllers/projects-controller.ts @@ -395,6 +395,7 @@ export default class ProjectsController extends WorklenzControllerBase { projects.folder_id, projects.phase_label, projects.category_id, + projects.currency, (projects.estimated_man_days) AS man_days, (projects.estimated_working_days) AS working_days, (projects.hours_per_day) AS hours_per_day, diff --git a/worklenz-backend/src/routes/apis/project-finance-api-router.ts b/worklenz-backend/src/routes/apis/project-finance-api-router.ts index aae00f22c..7ae0dccac 100644 --- a/worklenz-backend/src/routes/apis/project-finance-api-router.ts +++ b/worklenz-backend/src/routes/apis/project-finance-api-router.ts @@ -14,6 +14,7 @@ projectFinanceApiRouter.get( safeControllerFunction(ProjectfinanceController.getTaskBreakdown) ); projectFinanceApiRouter.put("/task/:task_id/fixed-cost", ProjectfinanceController.updateTaskFixedCost); +projectFinanceApiRouter.put("/project/:project_id/currency", ProjectfinanceController.updateProjectCurrency); projectFinanceApiRouter.get("/project/:project_id/export", ProjectfinanceController.exportFinanceData); export default projectFinanceApiRouter; \ No newline at end of file diff --git a/worklenz-frontend/src/api/project-finance-ratecard/project-finance.api.service.ts b/worklenz-frontend/src/api/project-finance-ratecard/project-finance.api.service.ts index 2292ade85..be199572e 100644 --- a/worklenz-frontend/src/api/project-finance-ratecard/project-finance.api.service.ts +++ b/worklenz-frontend/src/api/project-finance-ratecard/project-finance.api.service.ts @@ -50,6 +50,17 @@ export const projectFinanceApiService = { return response.data; }, + updateProjectCurrency: async ( + projectId: string, + currency: string + ): Promise> => { + const response = await apiClient.put>( + `${rootUrl}/project/${projectId}/currency`, + { currency } + ); + return response.data; + }, + exportFinanceData: async ( projectId: string, groupBy: 'status' | 'priority' | 'phases' = 'status' diff --git a/worklenz-frontend/src/api/projects/projects.api.service.ts b/worklenz-frontend/src/api/projects/projects.api.service.ts index 0297dd225..27cf992d7 100644 --- a/worklenz-frontend/src/api/projects/projects.api.service.ts +++ b/worklenz-frontend/src/api/projects/projects.api.service.ts @@ -7,6 +7,7 @@ import { IProjectViewModel } from '@/types/project/projectViewModel.types'; import { ITeamMemberOverviewGetResponse } from '@/types/project/project-insights.types'; import { IProjectMembersViewModel } from '@/types/projectMember.types'; import { IProjectManager } from '@/types/project/projectManager.types'; +import { ITaskPhase } from '@/types/tasks/taskPhase.types'; const rootUrl = `${API_BASE_URL}/projects`; @@ -120,5 +121,14 @@ export const projectsApiService = { const response = await apiClient.get>(`${url}`); return response.data; }, + + updateProjectPhaseLabel: async (projectId: string, phaseLabel: string) => { + const q = toQueryString({ id: projectId, current_project_id: projectId }); + const response = await apiClient.put>( + `${rootUrl}/label/${projectId}${q}`, + { name: phaseLabel } + ); + return response.data; + }, }; diff --git a/worklenz-frontend/src/features/project/project.slice.ts b/worklenz-frontend/src/features/project/project.slice.ts index b1a333ab9..99d05dad8 100644 --- a/worklenz-frontend/src/features/project/project.slice.ts +++ b/worklenz-frontend/src/features/project/project.slice.ts @@ -116,6 +116,11 @@ const projectSlice = createSlice({ state.project.phase_label = action.payload; } }, + updateProjectCurrency: (state, action: PayloadAction) => { + if (state.project) { + state.project.currency = action.payload; + } + }, addTask: ( state, action: PayloadAction<{ task: IProjectTask; groupId: string; insert?: boolean }> @@ -214,7 +219,8 @@ export const { setCreateTaskTemplateDrawerOpen, setProjectView, updatePhaseLabel, - setRefreshTimestamp + setRefreshTimestamp, + updateProjectCurrency } = projectSlice.actions; export default projectSlice.reducer; diff --git a/worklenz-frontend/src/features/projects/finance/project-finance.slice.ts b/worklenz-frontend/src/features/projects/finance/project-finance.slice.ts index 0f47b7556..2c1776060 100644 --- a/worklenz-frontend/src/features/projects/finance/project-finance.slice.ts +++ b/worklenz-frontend/src/features/projects/finance/project-finance.slice.ts @@ -1,6 +1,6 @@ import { projectFinanceApiService } from '@/api/project-finance-ratecard/project-finance.api.service'; import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit'; -import { IProjectFinanceGroup, IProjectFinanceTask, IProjectRateCard } from '@/types/project/project-finance.types'; +import { IProjectFinanceGroup, IProjectFinanceTask, IProjectRateCard, IProjectFinanceProject } from '@/types/project/project-finance.types'; import { parseTimeToSeconds } from '@/utils/timeUtils'; type FinanceTabType = 'finance' | 'ratecard'; @@ -12,6 +12,7 @@ interface ProjectFinanceState { loading: boolean; taskGroups: IProjectFinanceGroup[]; projectRateCards: IProjectRateCard[]; + project: IProjectFinanceProject | null; } // Utility functions for frontend calculations @@ -67,6 +68,7 @@ const initialState: ProjectFinanceState = { loading: false, taskGroups: [], projectRateCards: [], + project: null, }; export const fetchProjectFinances = createAsyncThunk( @@ -173,6 +175,7 @@ export const projectFinancesSlice = createSlice({ state.loading = false; state.taskGroups = action.payload.groups; state.projectRateCards = action.payload.project_rate_cards; + state.project = action.payload.project; }) .addCase(fetchProjectFinances.rejected, (state) => { state.loading = false; @@ -181,6 +184,7 @@ export const projectFinancesSlice = createSlice({ // Update data without changing loading state for silent refresh state.taskGroups = action.payload.groups; state.projectRateCards = action.payload.project_rate_cards; + state.project = action.payload.project; }) .addCase(updateTaskFixedCostAsync.fulfilled, (state, action) => { const { taskId, groupId, fixedCost } = action.payload; diff --git a/worklenz-frontend/src/lib/settings/settings-constants.ts b/worklenz-frontend/src/lib/settings/settings-constants.ts index f37174920..b26b4b883 100644 --- a/worklenz-frontend/src/lib/settings/settings-constants.ts +++ b/worklenz-frontend/src/lib/settings/settings-constants.ts @@ -27,7 +27,7 @@ import TeamMembersSettings from '@/pages/settings/team-members/team-members-sett import TeamsSettings from '../../pages/settings/teams/teams-settings'; import ChangePassword from '@/pages/settings/change-password/change-password'; import LanguageAndRegionSettings from '@/pages/settings/language-and-region/language-and-region-settings'; -import RatecardSettings from '@/pages/settings/ratecard/ratecard-settings'; +import RatecardSettings from '@/pages/settings/rate-card/rate-card-settings'; import AppearanceSettings from '@/pages/settings/appearance/appearance-settings'; // type of menu item in settings sidebar 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 index 62d4a38ce..2da99b068 100644 --- 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 @@ -76,7 +76,7 @@ const FinanceTableWrapper: React.FC = ({ activeTablesL }, [editingFixedCost]); const themeMode = useAppSelector(state => state.themeReducer.mode); - const { currency } = useAppSelector(state => state.financeReducer); + const currency = useAppSelector(state => state.projectFinances.project?.currency || "").toUpperCase(); const taskGroups = useAppSelector(state => state.projectFinances.taskGroups); // Use Redux store data for totals calculation to ensure reactivity 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 index 2872d1ac1..4bd41192b 100644 --- a/worklenz-frontend/src/pages/projects/projectView/finance/project-view-finance.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/finance/project-view-finance.tsx @@ -7,6 +7,7 @@ import { useAppDispatch } from '@/hooks/useAppDispatch'; import { useAppSelector } from '@/hooks/useAppSelector'; import { fetchProjectFinances, setActiveTab, setActiveGroup } from '@/features/projects/finance/project-finance.slice'; import { changeCurrency, toggleImportRatecardsDrawer } from '@/features/finance/finance-slice'; +import { updateProjectCurrency } from '@/features/project/project.slice'; import { projectFinanceApiService } from '@/api/project-finance-ratecard/project-finance.api.service'; import { RootState } from '@/app/store'; import FinanceTableWrapper from './finance-tab/finance-table/finance-table-wrapper'; @@ -14,14 +15,16 @@ import RatecardTable from './ratecard-tab/reatecard-table/ratecard-table'; import ImportRatecardsDrawer from '@/features/finance/ratecard-drawer/import-ratecards-drawer'; import { useAuthService } from '@/hooks/useAuth'; import { hasFinanceEditPermission } from '@/utils/finance-permissions'; +import { CURRENCY_OPTIONS, DEFAULT_CURRENCY } from '@/shared/constants/currencies'; const ProjectViewFinance = () => { const { projectId } = useParams<{ projectId: string }>(); const dispatch = useAppDispatch(); const { t } = useTranslation('project-view-finance'); const [exporting, setExporting] = useState(false); + const [updatingCurrency, setUpdatingCurrency] = useState(false); - const { activeTab, activeGroup, loading, taskGroups } = useAppSelector((state: RootState) => state.projectFinances); + const { activeTab, activeGroup, loading, taskGroups, project: financeProject } = useAppSelector((state: RootState) => state.projectFinances); const { refreshTimestamp, project } = useAppSelector((state: RootState) => state.projectReducer); const phaseList = useAppSelector((state) => state.phaseReducer.phaseList); @@ -30,6 +33,12 @@ const ProjectViewFinance = () => { const currentSession = auth.getCurrentSession(); const hasEditPermission = hasFinanceEditPermission(currentSession, project); + // Get project-specific currency from finance API response, fallback to project reducer, then default + const projectCurrency = (financeProject?.currency || project?.currency || DEFAULT_CURRENCY).toLowerCase(); + + // Show loading state for currency selector until finance data is loaded + const currencyLoading = loading || updatingCurrency || !financeProject; + useEffect(() => { if (projectId) { dispatch(fetchProjectFinances({ projectId, groupBy: activeGroup })); @@ -71,6 +80,30 @@ const ProjectViewFinance = () => { } }; + const handleCurrencyChange = async (currency: string) => { + if (!projectId || !hasEditPermission) { + message.error('You do not have permission to change the project currency'); + return; + } + + try { + setUpdatingCurrency(true); + const upperCaseCurrency = currency.toUpperCase(); + await projectFinanceApiService.updateProjectCurrency(projectId, upperCaseCurrency); + + // Update both global currency state and project-specific currency + dispatch(changeCurrency(currency)); + dispatch(updateProjectCurrency(upperCaseCurrency)); + + message.success('Project currency updated successfully'); + } catch (error) { + console.error('Currency update failed:', error); + message.error('Failed to update project currency'); + } finally { + setUpdatingCurrency(false); + } + }; + const groupDropdownMenuItems = [ { key: 'status', value: 'status', label: t('statusText') }, { key: 'priority', value: 'priority', label: t('priorityText') }, @@ -130,13 +163,11 @@ const ProjectViewFinance = () => { {t('currencyText')} rateInputRefs.current[index] = el} + ref={(el: InputRef | null) => { + if (el) rateInputRefs.current[index] = el as unknown as HTMLInputElement; + }} type="number" value={roles[index]?.rate ?? 0} min={0} diff --git a/worklenz-frontend/src/pages/settings/ratecard/ratecard-settings.tsx b/worklenz-frontend/src/pages/settings/rate-card/rate-card-settings.tsx similarity index 100% rename from worklenz-frontend/src/pages/settings/ratecard/ratecard-settings.tsx rename to worklenz-frontend/src/pages/settings/rate-card/rate-card-settings.tsx diff --git a/worklenz-frontend/src/shared/constants/currencies.ts b/worklenz-frontend/src/shared/constants/currencies.ts new file mode 100644 index 000000000..748e6b992 --- /dev/null +++ b/worklenz-frontend/src/shared/constants/currencies.ts @@ -0,0 +1,54 @@ +export interface CurrencyOption { + value: string; + label: string; + symbol?: string; +} + +export const CURRENCY_OPTIONS: CurrencyOption[] = [ + { value: 'usd', label: 'USD - US Dollar', symbol: '$' }, + { value: 'eur', label: 'EUR - Euro', symbol: '€' }, + { value: 'gbp', label: 'GBP - British Pound', symbol: '£' }, + { value: 'jpy', label: 'JPY - Japanese Yen', symbol: '¥' }, + { value: 'cad', label: 'CAD - Canadian Dollar', symbol: 'C$' }, + { value: 'aud', label: 'AUD - Australian Dollar', symbol: 'A$' }, + { value: 'chf', label: 'CHF - Swiss Franc', symbol: 'CHF' }, + { value: 'cny', label: 'CNY - Chinese Yuan', symbol: '¥' }, + { value: 'inr', label: 'INR - Indian Rupee', symbol: '₹' }, + { value: 'lkr', label: 'LKR - Sri Lankan Rupee', symbol: 'Rs' }, + { value: 'sgd', label: 'SGD - Singapore Dollar', symbol: 'S$' }, + { value: 'hkd', label: 'HKD - Hong Kong Dollar', symbol: 'HK$' }, + { value: 'nzd', label: 'NZD - New Zealand Dollar', symbol: 'NZ$' }, + { value: 'sek', label: 'SEK - Swedish Krona', symbol: 'kr' }, + { value: 'nok', label: 'NOK - Norwegian Krone', symbol: 'kr' }, + { value: 'dkk', label: 'DKK - Danish Krone', symbol: 'kr' }, + { value: 'pln', label: 'PLN - Polish Zloty', symbol: 'zł' }, + { value: 'czk', label: 'CZK - Czech Koruna', symbol: 'Kč' }, + { value: 'huf', label: 'HUF - Hungarian Forint', symbol: 'Ft' }, + { value: 'rub', label: 'RUB - Russian Ruble', symbol: '₽' }, + { value: 'brl', label: 'BRL - Brazilian Real', symbol: 'R$' }, + { value: 'mxn', label: 'MXN - Mexican Peso', symbol: '$' }, + { value: 'zar', label: 'ZAR - South African Rand', symbol: 'R' }, + { value: 'krw', label: 'KRW - South Korean Won', symbol: '₩' }, + { value: 'thb', label: 'THB - Thai Baht', symbol: '฿' }, + { value: 'myr', label: 'MYR - Malaysian Ringgit', symbol: 'RM' }, + { value: 'idr', label: 'IDR - Indonesian Rupiah', symbol: 'Rp' }, + { value: 'php', label: 'PHP - Philippine Peso', symbol: '₱' }, + { value: 'vnd', label: 'VND - Vietnamese Dong', symbol: '₫' }, + { value: 'aed', label: 'AED - UAE Dirham', symbol: 'د.إ' }, + { value: 'sar', label: 'SAR - Saudi Riyal', symbol: '﷼' }, + { value: 'egp', label: 'EGP - Egyptian Pound', symbol: '£' }, + { value: 'try', label: 'TRY - Turkish Lira', symbol: '₺' }, + { value: 'ils', label: 'ILS - Israeli Shekel', symbol: '₪' }, +]; + +export const DEFAULT_CURRENCY = 'usd'; + +export const getCurrencySymbol = (currencyCode: string): string => { + const currency = CURRENCY_OPTIONS.find(c => c.value === currencyCode.toLowerCase()); + return currency?.symbol || currencyCode.toUpperCase(); +}; + +export const getCurrencyLabel = (currencyCode: string): string => { + const currency = CURRENCY_OPTIONS.find(c => c.value === currencyCode.toLowerCase()); + return currency?.label || currencyCode.toUpperCase(); +}; \ No newline at end of file diff --git a/worklenz-frontend/src/types/project/project-finance.types.ts b/worklenz-frontend/src/types/project/project-finance.types.ts index ac3ab56de..f52fea18d 100644 --- a/worklenz-frontend/src/types/project/project-finance.types.ts +++ b/worklenz-frontend/src/types/project/project-finance.types.ts @@ -63,9 +63,16 @@ export interface IProjectRateCard { job_title_name: string; } +export interface IProjectFinanceProject { + id: string; + name: string; + currency: string; +} + export interface IProjectFinanceResponse { groups: IProjectFinanceGroup[]; project_rate_cards: IProjectRateCard[]; + project: IProjectFinanceProject; } export interface ITaskBreakdownMember { diff --git a/worklenz-frontend/src/types/project/projectViewModel.types.ts b/worklenz-frontend/src/types/project/projectViewModel.types.ts index a35d59b3d..2c3706dc7 100644 --- a/worklenz-frontend/src/types/project/projectViewModel.types.ts +++ b/worklenz-frontend/src/types/project/projectViewModel.types.ts @@ -65,4 +65,5 @@ export interface IProjectViewModel extends IProject { use_manual_progress?: boolean; use_weighted_progress?: boolean; use_time_progress?: boolean; + currency?: string; } From dcdb651dd18a7c0906744a550127393c4a695f8d Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Wed, 4 Jun 2025 11:40:23 +0530 Subject: [PATCH 090/105] feat(project-finance): add action to update project-specific currency - Introduced a new action `updateProjectFinanceCurrency` in the project finance slice to allow updating the currency for individual projects. - Updated the ProjectViewFinance component to dispatch the new action when the project currency is changed, ensuring the state reflects the selected currency. --- .../features/projects/finance/project-finance.slice.ts | 8 +++++++- .../projects/projectView/finance/project-view-finance.tsx | 3 ++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/worklenz-frontend/src/features/projects/finance/project-finance.slice.ts b/worklenz-frontend/src/features/projects/finance/project-finance.slice.ts index 2c1776060..788ffb170 100644 --- a/worklenz-frontend/src/features/projects/finance/project-finance.slice.ts +++ b/worklenz-frontend/src/features/projects/finance/project-finance.slice.ts @@ -164,6 +164,11 @@ export const projectFinancesSlice = createSlice({ task.show_sub_tasks = !task.show_sub_tasks; } } + }, + updateProjectFinanceCurrency: (state, action: PayloadAction) => { + if (state.project) { + state.project.currency = action.payload; + } } }, extraReducers: (builder) => { @@ -222,7 +227,8 @@ export const { updateTaskFixedCost, updateTaskEstimatedCost, updateTaskTimeLogged, - toggleTaskExpansion + toggleTaskExpansion, + updateProjectFinanceCurrency } = projectFinancesSlice.actions; export default projectFinancesSlice.reducer; 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 index 4bd41192b..f499692ff 100644 --- a/worklenz-frontend/src/pages/projects/projectView/finance/project-view-finance.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/finance/project-view-finance.tsx @@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next'; import { CaretDownFilled, DownOutlined } from '@ant-design/icons'; import { useAppDispatch } from '@/hooks/useAppDispatch'; import { useAppSelector } from '@/hooks/useAppSelector'; -import { fetchProjectFinances, setActiveTab, setActiveGroup } from '@/features/projects/finance/project-finance.slice'; +import { fetchProjectFinances, setActiveTab, setActiveGroup, updateProjectFinanceCurrency } from '@/features/projects/finance/project-finance.slice'; import { changeCurrency, toggleImportRatecardsDrawer } from '@/features/finance/finance-slice'; import { updateProjectCurrency } from '@/features/project/project.slice'; import { projectFinanceApiService } from '@/api/project-finance-ratecard/project-finance.api.service'; @@ -94,6 +94,7 @@ const ProjectViewFinance = () => { // Update both global currency state and project-specific currency dispatch(changeCurrency(currency)); dispatch(updateProjectCurrency(upperCaseCurrency)); + dispatch(updateProjectFinanceCurrency(upperCaseCurrency)); message.success('Project currency updated successfully'); } catch (error) { From 915980dcdf5f30cef343baf4963a4f6c0bbe813f Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Wed, 4 Jun 2025 11:53:33 +0530 Subject: [PATCH 091/105] feat(project-view): implement finance tab visibility based on user permissions - Added permission checks to conditionally display the finance tab in the project view based on user roles. - Introduced `hasFinanceViewPermission` utility to determine access rights for the finance tab. - Updated tab management logic to handle redirection and default tab selection when permissions change. --- .../projects/projectView/project-view.tsx | 56 ++++++++++++++++++- .../src/utils/finance-permissions.ts | 23 ++++++++ 2 files changed, 76 insertions(+), 3 deletions(-) diff --git a/worklenz-frontend/src/pages/projects/projectView/project-view.tsx b/worklenz-frontend/src/pages/projects/projectView/project-view.tsx index a625fcf7b..9edebbb1f 100644 --- a/worklenz-frontend/src/pages/projects/projectView/project-view.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/project-view.tsx @@ -19,6 +19,8 @@ import { fetchLabels } from '@/features/taskAttributes/taskLabelSlice'; import { deselectAll } from '@/features/projects/bulkActions/bulkActionSlice'; import { tabItems } from '@/lib/project/project-view-constants'; import { setSelectedTaskId, setShowTaskDrawer } from '@/features/task-drawer/task-drawer.slice'; +import { useAuthService } from '@/hooks/useAuth'; +import { hasFinanceViewPermission } from '@/utils/finance-permissions'; const DeleteStatusDrawer = React.lazy(() => import('@/components/project-task-filters/delete-status-drawer/delete-status-drawer')); const PhaseDrawer = React.lazy(() => import('@features/projects/singleProject/phase/PhaseDrawer')); @@ -51,7 +53,33 @@ const ProjectView = () => { const selectedProject = useAppSelector(state => state.projectReducer.project); useDocumentTitle(selectedProject?.name || 'Project View'); - const [activeTab, setActiveTab] = useState(searchParams.get('tab') || tabItems[0].key); + + // Auth and permissions + const auth = useAuthService(); + const currentSession = auth.getCurrentSession(); + const hasFinancePermission = hasFinanceViewPermission(currentSession, selectedProject); + + // Filter tab items based on permissions + const filteredTabItems = useMemo(() => { + return tabItems.filter(item => { + // Hide finance tab if user doesn't have permission + if (item.key === 'finance' && !hasFinancePermission) { + return false; + } + return true; + }); + }, [hasFinancePermission]); + + // Get the default tab from filtered items + const defaultTab = useMemo(() => { + const requestedTab = searchParams.get('tab'); + if (requestedTab && filteredTabItems.some(item => item.key === requestedTab)) { + return requestedTab; + } + return filteredTabItems[0]?.key || 'tasks-list'; + }, [searchParams, filteredTabItems]); + + const [activeTab, setActiveTab] = useState(defaultTab); const [pinnedTab, setPinnedTab] = useState(searchParams.get('pinned_tab') || ''); const [taskid, setTaskId] = useState(searchParams.get('task') || ''); @@ -85,6 +113,28 @@ const ProjectView = () => { }; }, [dispatch, navigate, projectId, taskid, resetProjectData]); + // Redirect if user is on finance tab but doesn't have permission + useEffect(() => { + if (activeTab === 'finance' && !hasFinancePermission) { + const fallbackTab = filteredTabItems[0]?.key || 'tasks-list'; + setActiveTab(fallbackTab); + navigate({ + pathname: location.pathname, + search: new URLSearchParams({ + tab: fallbackTab, + pinned_tab: pinnedTab, + }).toString(), + }, { replace: true }); + } + }, [activeTab, hasFinancePermission, filteredTabItems, navigate, location.pathname, pinnedTab]); + + // Update active tab if default tab changes due to permission changes + useEffect(() => { + if (activeTab !== defaultTab && !filteredTabItems.some(item => item.key === activeTab)) { + setActiveTab(defaultTab); + } + }, [defaultTab, activeTab, filteredTabItems]); + const pinToDefaultTab = useCallback(async (itemKey: string) => { if (!itemKey || !projectId) return; @@ -136,7 +186,7 @@ const ProjectView = () => { }); }, [dispatch, location.pathname, navigate, pinnedTab]); - const tabMenuItems = useMemo(() => tabItems.map(item => ({ + const tabMenuItems = useMemo(() => filteredTabItems.map(item => ({ key: item.key, label: ( @@ -179,7 +229,7 @@ const ProjectView = () => { {item.element} ), - })), [pinnedTab, pinToDefaultTab]); + })), [pinnedTab, pinToDefaultTab, filteredTabItems]); const portalElements = useMemo(() => ( <> diff --git a/worklenz-frontend/src/utils/finance-permissions.ts b/worklenz-frontend/src/utils/finance-permissions.ts index 9214f3e89..19d73f7f3 100644 --- a/worklenz-frontend/src/utils/finance-permissions.ts +++ b/worklenz-frontend/src/utils/finance-permissions.ts @@ -27,6 +27,29 @@ export const hasFinanceEditPermission = ( return false; }; +/** + * Checks if the current user has permission to view finance data + * Only project managers, admins, and owners should be able to view the finance tab + */ +export const hasFinanceViewPermission = ( + currentSession: ILocalSession | null, + currentProject?: IProjectViewModel | null +): boolean => { + if (!currentSession) return false; + + // Team owner or admin always have permission + if (currentSession.owner || currentSession.is_admin) { + return true; + } + + // Project manager has permission + if (currentProject?.project_manager?.id === currentSession.team_member_id) { + return true; + } + + return false; +}; + /** * Checks if the current user can edit fixed costs */ From 59880bfd59c6f5851a03081f3b0df15a69b4f0d0 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Fri, 6 Jun 2025 09:10:50 +0530 Subject: [PATCH 092/105] feat(project-view): implement finance tab visibility based on user permissions - Added permission checks to conditionally display the finance tab in the project view based on user roles. - Introduced `hasFinanceViewPermission` utility to determine access rights for the finance tab. - Updated tab management logic to handle redirection and default tab selection when permissions change. --- .../projects/projectView/project-view.tsx | 56 ++++++++++++++++++- .../src/utils/finance-permissions.ts | 23 ++++++++ 2 files changed, 76 insertions(+), 3 deletions(-) diff --git a/worklenz-frontend/src/pages/projects/projectView/project-view.tsx b/worklenz-frontend/src/pages/projects/projectView/project-view.tsx index a625fcf7b..9edebbb1f 100644 --- a/worklenz-frontend/src/pages/projects/projectView/project-view.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/project-view.tsx @@ -19,6 +19,8 @@ import { fetchLabels } from '@/features/taskAttributes/taskLabelSlice'; import { deselectAll } from '@/features/projects/bulkActions/bulkActionSlice'; import { tabItems } from '@/lib/project/project-view-constants'; import { setSelectedTaskId, setShowTaskDrawer } from '@/features/task-drawer/task-drawer.slice'; +import { useAuthService } from '@/hooks/useAuth'; +import { hasFinanceViewPermission } from '@/utils/finance-permissions'; const DeleteStatusDrawer = React.lazy(() => import('@/components/project-task-filters/delete-status-drawer/delete-status-drawer')); const PhaseDrawer = React.lazy(() => import('@features/projects/singleProject/phase/PhaseDrawer')); @@ -51,7 +53,33 @@ const ProjectView = () => { const selectedProject = useAppSelector(state => state.projectReducer.project); useDocumentTitle(selectedProject?.name || 'Project View'); - const [activeTab, setActiveTab] = useState(searchParams.get('tab') || tabItems[0].key); + + // Auth and permissions + const auth = useAuthService(); + const currentSession = auth.getCurrentSession(); + const hasFinancePermission = hasFinanceViewPermission(currentSession, selectedProject); + + // Filter tab items based on permissions + const filteredTabItems = useMemo(() => { + return tabItems.filter(item => { + // Hide finance tab if user doesn't have permission + if (item.key === 'finance' && !hasFinancePermission) { + return false; + } + return true; + }); + }, [hasFinancePermission]); + + // Get the default tab from filtered items + const defaultTab = useMemo(() => { + const requestedTab = searchParams.get('tab'); + if (requestedTab && filteredTabItems.some(item => item.key === requestedTab)) { + return requestedTab; + } + return filteredTabItems[0]?.key || 'tasks-list'; + }, [searchParams, filteredTabItems]); + + const [activeTab, setActiveTab] = useState(defaultTab); const [pinnedTab, setPinnedTab] = useState(searchParams.get('pinned_tab') || ''); const [taskid, setTaskId] = useState(searchParams.get('task') || ''); @@ -85,6 +113,28 @@ const ProjectView = () => { }; }, [dispatch, navigate, projectId, taskid, resetProjectData]); + // Redirect if user is on finance tab but doesn't have permission + useEffect(() => { + if (activeTab === 'finance' && !hasFinancePermission) { + const fallbackTab = filteredTabItems[0]?.key || 'tasks-list'; + setActiveTab(fallbackTab); + navigate({ + pathname: location.pathname, + search: new URLSearchParams({ + tab: fallbackTab, + pinned_tab: pinnedTab, + }).toString(), + }, { replace: true }); + } + }, [activeTab, hasFinancePermission, filteredTabItems, navigate, location.pathname, pinnedTab]); + + // Update active tab if default tab changes due to permission changes + useEffect(() => { + if (activeTab !== defaultTab && !filteredTabItems.some(item => item.key === activeTab)) { + setActiveTab(defaultTab); + } + }, [defaultTab, activeTab, filteredTabItems]); + const pinToDefaultTab = useCallback(async (itemKey: string) => { if (!itemKey || !projectId) return; @@ -136,7 +186,7 @@ const ProjectView = () => { }); }, [dispatch, location.pathname, navigate, pinnedTab]); - const tabMenuItems = useMemo(() => tabItems.map(item => ({ + const tabMenuItems = useMemo(() => filteredTabItems.map(item => ({ key: item.key, label: ( @@ -179,7 +229,7 @@ const ProjectView = () => { {item.element} ), - })), [pinnedTab, pinToDefaultTab]); + })), [pinnedTab, pinToDefaultTab, filteredTabItems]); const portalElements = useMemo(() => ( <> diff --git a/worklenz-frontend/src/utils/finance-permissions.ts b/worklenz-frontend/src/utils/finance-permissions.ts index 9214f3e89..19d73f7f3 100644 --- a/worklenz-frontend/src/utils/finance-permissions.ts +++ b/worklenz-frontend/src/utils/finance-permissions.ts @@ -27,6 +27,29 @@ export const hasFinanceEditPermission = ( return false; }; +/** + * Checks if the current user has permission to view finance data + * Only project managers, admins, and owners should be able to view the finance tab + */ +export const hasFinanceViewPermission = ( + currentSession: ILocalSession | null, + currentProject?: IProjectViewModel | null +): boolean => { + if (!currentSession) return false; + + // Team owner or admin always have permission + if (currentSession.owner || currentSession.is_admin) { + return true; + } + + // Project manager has permission + if (currentProject?.project_manager?.id === currentSession.team_member_id) { + return true; + } + + return false; +}; + /** * Checks if the current user can edit fixed costs */ From 791cbe22dfcabf93484ea568c92597d474ea7ee1 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Fri, 6 Jun 2025 12:02:53 +0530 Subject: [PATCH 093/105] feat(project-finance): add billable filter functionality to project finance queries - Introduced a `billable_filter` query parameter to filter tasks based on their billable status (billable, non-billable, or all). - Updated the project finance controller to construct SQL queries with billable conditions based on the filter. - Enhanced the frontend components to support billable filtering in project finance views and exports. - Added corresponding translations for filter options in multiple languages. - Refactored related API services to accommodate the new filtering logic. --- .../controllers/project-finance-controller.ts | 30 +++ .../locales/en/project-view-finance.json | 6 +- .../en/settings/ratecard-settings.json | 46 +++- .../locales/es/project-view-finance.json | 21 +- .../es/settings/ratecard-settings.json | 52 +++- .../locales/pt/project-view-finance.json | 29 ++- .../pt/settings/ratecard-settings.json | 48 +++- .../project-finance.api.service.ts | 29 ++- .../shared/time-log/time-log-form.tsx | 6 + .../shared/time-log/time-log-item.tsx | 4 + .../ratecard-drawer/ratecard-drawer.tsx | 157 +++++++++--- .../projects/finance/project-finance.slice.ts | 19 +- .../src/lib/project/finance-table-wrapper.tsx | 61 ----- .../finance-tab/finance-table-wrapper.tsx | 43 ---- .../finance-table/finance-table-wrapper.tsx | 28 +- .../finance-table/finance-table.tsx | 237 +++++++++-------- .../finance/project-view-finance.tsx | 241 ++++++++++++++++-- .../projectView/gantt/project-view-gantt.tsx | 211 ++++++++++----- .../settings/rate-card/rate-card-settings.tsx | 180 +++++++------ 19 files changed, 957 insertions(+), 491 deletions(-) delete mode 100644 worklenz-frontend/src/lib/project/finance-table-wrapper.tsx delete mode 100644 worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-table-wrapper.tsx diff --git a/worklenz-backend/src/controllers/project-finance-controller.ts b/worklenz-backend/src/controllers/project-finance-controller.ts index b76aa0b69..bd78153e8 100644 --- a/worklenz-backend/src/controllers/project-finance-controller.ts +++ b/worklenz-backend/src/controllers/project-finance-controller.ts @@ -50,6 +50,7 @@ export default class ProjectfinanceController extends WorklenzControllerBase { ): Promise { const projectId = req.params.project_id; const groupBy = req.query.group_by || "status"; + const billableFilter = req.query.billable_filter || "billable"; // Get project information including currency const projectQuery = ` @@ -82,6 +83,14 @@ export default class ProjectfinanceController extends WorklenzControllerBase { const rateCardResult = await db.query(rateCardQuery, [projectId]); const projectRateCards = rateCardResult.rows; + // Build billable filter condition + let billableCondition = ""; + if (billableFilter === "billable") { + billableCondition = "AND t.billable = true"; + } else if (billableFilter === "non-billable") { + billableCondition = "AND t.billable = false"; + } + // Get tasks with their financial data - support hierarchical loading const q = ` WITH RECURSIVE task_tree AS ( @@ -106,6 +115,7 @@ export default class ProjectfinanceController extends WorklenzControllerBase { WHERE t.project_id = $1 AND t.archived = false AND t.parent_task_id IS NULL -- Only load parent tasks initially + ${billableCondition} UNION ALL @@ -579,6 +589,7 @@ export default class ProjectfinanceController extends WorklenzControllerBase { ): Promise { const projectId = req.params.project_id; const parentTaskId = req.params.parent_task_id; + const billableFilter = req.query.billable_filter || "billable"; if (!parentTaskId) { return res @@ -586,6 +597,14 @@ export default class ProjectfinanceController extends WorklenzControllerBase { .send(new ServerResponse(false, null, "Parent task ID is required")); } + // Build billable filter condition for subtasks + let billableCondition = ""; + if (billableFilter === "billable") { + billableCondition = "AND t.billable = true"; + } else if (billableFilter === "non-billable") { + billableCondition = "AND t.billable = false"; + } + // Get subtasks with their financial data const q = ` WITH task_costs AS ( @@ -607,6 +626,7 @@ export default class ProjectfinanceController extends WorklenzControllerBase { WHERE t.project_id = $1 AND t.archived = false AND t.parent_task_id = $2 + ${billableCondition} ), task_estimated_costs AS ( SELECT @@ -721,6 +741,7 @@ export default class ProjectfinanceController extends WorklenzControllerBase { ): Promise { const projectId = req.params.project_id; const groupBy = (req.query.groupBy as string) || "status"; + const billableFilter = req.query.billable_filter || "billable"; // Get project name and currency for filename and export const projectQuery = `SELECT name, currency FROM projects WHERE id = $1`; @@ -746,6 +767,14 @@ export default class ProjectfinanceController extends WorklenzControllerBase { const rateCardResult = await db.query(rateCardQuery, [projectId]); const projectRateCards = rateCardResult.rows; + // Build billable filter condition for export + let billableCondition = ""; + if (billableFilter === "billable") { + billableCondition = "AND t.billable = true"; + } else if (billableFilter === "non-billable") { + billableCondition = "AND t.billable = false"; + } + // Get tasks with their financial data - support hierarchical loading const q = ` WITH RECURSIVE task_tree AS ( @@ -770,6 +799,7 @@ export default class ProjectfinanceController extends WorklenzControllerBase { WHERE t.project_id = $1 AND t.archived = false AND t.parent_task_id IS NULL -- Only load parent tasks initially + ${billableCondition} UNION ALL diff --git a/worklenz-frontend/public/locales/en/project-view-finance.json b/worklenz-frontend/public/locales/en/project-view-finance.json index 642422b21..de496e363 100644 --- a/worklenz-frontend/public/locales/en/project-view-finance.json +++ b/worklenz-frontend/public/locales/en/project-view-finance.json @@ -8,12 +8,16 @@ "exportButton": "Export", "currencyText": "Currency", "importButton": "Import", + "filterText": "Filter", + "billableOnlyText": "Billable Only", + "nonBillableOnlyText": "Non-Billable Only", + "allTasksText": "All Tasks", "taskColumn": "Task", "membersColumn": "Members", "hoursColumn": "Estimated Hours", "totalTimeLoggedColumn": "Total Time Logged", - "costColumn": "Cost", + "costColumn": "Actual Cost", "estimatedCostColumn": "Estimated Cost", "fixedCostColumn": "Fixed Cost", "totalBudgetedCostColumn": "Total Budgeted Cost", diff --git a/worklenz-frontend/public/locales/en/settings/ratecard-settings.json b/worklenz-frontend/public/locales/en/settings/ratecard-settings.json index 5e2c984b0..3607b7ee6 100644 --- a/worklenz-frontend/public/locales/en/settings/ratecard-settings.json +++ b/worklenz-frontend/public/locales/en/settings/ratecard-settings.json @@ -2,27 +2,49 @@ "nameColumn": "Name", "createdColumn": "Created", "noProjectsAvailable": "No projects available", - "deleteConfirmationTitle": "Are you sure?", - "deleteConfirmationOk": "Yes", + "deleteConfirmationTitle": "Are you sure you want to delete this rate card?", + "deleteConfirmationOk": "Yes, delete", "deleteConfirmationCancel": "Cancel", - "searchPlaceholder": "Search by name", + "searchPlaceholder": "Search rate cards by name", "createRatecard": "Create Rate Card", + "editTooltip": "Edit rate card", + "deleteTooltip": "Delete rate card", + "fetchError": "Failed to fetch rate cards", + "createError": "Failed to create rate card", + "deleteSuccess": "Rate card deleted successfully", + "deleteError": "Failed to delete rate card", "jobTitleColumn": "Job title", "ratePerHourColumn": "Rate per hour", "saveButton": "Save", - "addRoleButton": "+ Add Role", - "createRatecardSuccessMessage": "Create Rate Card success!", - "createRatecardErrorMessage": "Create Rate Card failed!", - "updateRatecardSuccessMessage": "Update Rate Card success!", - "updateRatecardErrorMessage": "Update Rate Card failed!", + "addRoleButton": "Add Role", + "createRatecardSuccessMessage": "Rate card created successfully", + "createRatecardErrorMessage": "Failed to create rate card", + "updateRatecardSuccessMessage": "Rate card updated successfully", + "updateRatecardErrorMessage": "Failed to update rate card", "currency": "Currency", "actionsColumn": "Actions", "addAllButton": "Add All", "removeAllButton": "Remove All", "selectJobTitle": "Select job title", - "unsavedChangesTitle": "Unsaved changes", - "ratecardNameRequired": "Rate card name is required" - - + "unsavedChangesTitle": "You have unsaved changes", + "unsavedChangesMessage": "Do you want to save your changes before leaving?", + "unsavedChangesSave": "Save", + "unsavedChangesDiscard": "Discard", + "ratecardNameRequired": "Rate card name is required", + "ratecardNamePlaceholder": "Enter rate card name", + "noRatecardsFound": "No rate cards found", + "loadingRateCards": "Loading rate cards...", + "noJobTitlesAvailable": "No job titles available", + "noRolesAdded": "No roles added yet", + "createFirstJobTitle": "Create First Job Title", + "jobRolesTitle": "Job Roles", + "noJobTitlesMessage": "Please create job titles first in the Job Titles settings before adding roles to rate cards.", + "createNewJobTitle": "Create New Job Title", + "jobTitleNamePlaceholder": "Enter job title name", + "jobTitleNameRequired": "Job title name is required", + "jobTitleCreatedSuccess": "Job title created successfully", + "jobTitleCreateError": "Failed to create job title", + "createButton": "Create", + "cancelButton": "Cancel" } diff --git a/worklenz-frontend/public/locales/es/project-view-finance.json b/worklenz-frontend/public/locales/es/project-view-finance.json index bd2fa0248..ad3ed6622 100644 --- a/worklenz-frontend/public/locales/es/project-view-finance.json +++ b/worklenz-frontend/public/locales/es/project-view-finance.json @@ -8,27 +8,38 @@ "exportButton": "Exportar", "currencyText": "Moneda", "importButton": "Importar", + "filterText": "Filtro", + "billableOnlyText": "Solo Facturable", + "nonBillableOnlyText": "Solo No Facturable", + "allTasksText": "Todas las Tareas", "taskColumn": "Tarea", "membersColumn": "Miembros", "hoursColumn": "Horas Estimadas", "totalTimeLoggedColumn": "Tiempo Total Registrado", - "costColumn": "Costo", + "costColumn": "Costo Real", "estimatedCostColumn": "Costo Estimado", "fixedCostColumn": "Costo Fijo", "totalBudgetedCostColumn": "Costo Total Presupuestado", - "totalActualCostColumn": "Costo Total Real", - "varianceColumn": "Diferencia", + "totalActualCostColumn": "Costo Real Total", + "varianceColumn": "Varianza", "totalText": "Total", "noTasksFound": "No se encontraron tareas", "addRoleButton": "+ Agregar Rol", - "ratecardImportantNotice": "* Esta tarifa se genera en función de los títulos de trabajo y tarifas estándar de la empresa. Sin embargo, tienes la flexibilidad de modificarla según el proyecto. Estos cambios no afectarán los títulos de trabajo y tarifas estándar de la organización.", + "ratecardImportantNotice": "* Esta tarifa se genera en base a 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" + "labourHoursColumn": "Horas de Trabajo", + "actions": "Acciones", + "selectJobTitle": "Seleccionar Título del Trabajo", + "ratecardsPluralText": "Plantillas de Tarifas", + "deleteConfirm": "¿Estás seguro?", + "yes": "Sí", + "no": "No", + "alreadyImportedRateCardMessage": "Ya se ha importado una tarifa. Borra todas las tarifas importadas para agregar una nueva." } \ No newline at end of file diff --git a/worklenz-frontend/public/locales/es/settings/ratecard-settings.json b/worklenz-frontend/public/locales/es/settings/ratecard-settings.json index 825eabd53..2008d1f67 100644 --- a/worklenz-frontend/public/locales/es/settings/ratecard-settings.json +++ b/worklenz-frontend/public/locales/es/settings/ratecard-settings.json @@ -2,19 +2,49 @@ "nameColumn": "Nombre", "createdColumn": "Creado", "noProjectsAvailable": "No hay proyectos disponibles", - "deleteConfirmationTitle": "¿Estás seguro?", - "deleteConfirmationOk": "Sí", + "deleteConfirmationTitle": "¿Está seguro de que desea eliminar esta tarjeta de tarifas?", + "deleteConfirmationOk": "Sí, eliminar", "deleteConfirmationCancel": "Cancelar", - "searchPlaceholder": "Buscar por nombre", - "createRatecard": "Crear Tarifa", + "searchPlaceholder": "Buscar tarjetas de tarifas por nombre", + "createRatecard": "Crear Tarjeta de Tarifas", + "editTooltip": "Editar tarjeta de tarifas", + "deleteTooltip": "Eliminar tarjeta de tarifas", + "fetchError": "Error al cargar las tarjetas de tarifas", + "createError": "Error al crear la tarjeta de tarifas", + "deleteSuccess": "Tarjeta de tarifas eliminada con éxito", + "deleteError": "Error al eliminar la tarjeta de tarifas", - "jobTitleColumn": "Puesto de trabajo", + "jobTitleColumn": "Título del trabajo", "ratePerHourColumn": "Tarifa por hora", "saveButton": "Guardar", - "addRoleButton": "+ Agregar Rol", - "createRatecardSuccessMessage": "¡Tarifa creada con éxito!", - "createRatecardErrorMessage": "¡Error al crear la tarifa!", - "updateRatecardSuccessMessage": "¡Tarifa actualizada con éxito!", - "updateRatecardErrorMessage": "¡Error al actualizar la tarifa!", - "currency": "Moneda" + "addRoleButton": "Añadir Rol", + "createRatecardSuccessMessage": "Tarjeta de tarifas creada con éxito", + "createRatecardErrorMessage": "Error al crear la tarjeta de tarifas", + "updateRatecardSuccessMessage": "Tarjeta de tarifas actualizada con éxito", + "updateRatecardErrorMessage": "Error al actualizar la tarjeta de tarifas", + "currency": "Moneda", + "actionsColumn": "Acciones", + "addAllButton": "Añadir Todo", + "removeAllButton": "Eliminar Todo", + "selectJobTitle": "Seleccionar título del trabajo", + "unsavedChangesTitle": "Tiene cambios sin guardar", + "unsavedChangesMessage": "¿Desea guardar los cambios antes de salir?", + "unsavedChangesSave": "Guardar", + "unsavedChangesDiscard": "Descartar", + "ratecardNameRequired": "El nombre de la tarjeta de tarifas es obligatorio", + "ratecardNamePlaceholder": "Ingrese el nombre de la tarjeta de tarifas", + "noRatecardsFound": "No se encontraron tarjetas de tarifas", + "loadingRateCards": "Cargando tarjetas de tarifas...", + "noJobTitlesAvailable": "No hay títulos de trabajo disponibles", + "noRolesAdded": "Aún no se han añadido roles", + "createFirstJobTitle": "Crear Primer Título de Trabajo", + "jobRolesTitle": "Roles de Trabajo", + "noJobTitlesMessage": "Por favor, cree títulos de trabajo primero en la configuración de Títulos de Trabajo antes de añadir roles a las tarjetas de tarifas.", + "createNewJobTitle": "Crear Nuevo Título de Trabajo", + "jobTitleNamePlaceholder": "Ingrese el nombre del título de trabajo", + "jobTitleNameRequired": "El nombre del título de trabajo es obligatorio", + "jobTitleCreatedSuccess": "Título de trabajo creado con éxito", + "jobTitleCreateError": "Error al crear el título de trabajo", + "createButton": "Crear", + "cancelButton": "Cancelar" } diff --git a/worklenz-frontend/public/locales/pt/project-view-finance.json b/worklenz-frontend/public/locales/pt/project-view-finance.json index be3d31f20..7634b6661 100644 --- a/worklenz-frontend/public/locales/pt/project-view-finance.json +++ b/worklenz-frontend/public/locales/pt/project-view-finance.json @@ -1,6 +1,6 @@ { "financeText": "Finanças", - "ratecardSingularText": "Tabela de Taxas", + "ratecardSingularText": "Cartão de Taxa", "groupByText": "Agrupar por", "statusText": "Status", "phaseText": "Fase", @@ -8,27 +8,38 @@ "exportButton": "Exportar", "currencyText": "Moeda", "importButton": "Importar", + "filterText": "Filtro", + "billableOnlyText": "Apenas Faturável", + "nonBillableOnlyText": "Apenas Não Faturável", + "allTasksText": "Todas as Tarefas", "taskColumn": "Tarefa", "membersColumn": "Membros", "hoursColumn": "Horas Estimadas", "totalTimeLoggedColumn": "Tempo Total Registrado", - "costColumn": "Custo", + "costColumn": "Custo Real", "estimatedCostColumn": "Custo Estimado", "fixedCostColumn": "Custo Fixo", "totalBudgetedCostColumn": "Custo Total Orçado", - "totalActualCostColumn": "Custo Total Real", - "varianceColumn": "Variação", + "totalActualCostColumn": "Custo Real Total", + "varianceColumn": "Variância", "totalText": "Total", "noTasksFound": "Nenhuma tarefa encontrada", "addRoleButton": "+ Adicionar Função", - "ratecardImportantNotice": "* Esta tabela de taxas é gerada com base nos cargos e taxas padrão da empresa. No entanto, você tem a flexibilidade de modificá-la de acordo com o projeto. Essas alterações não impactarão os cargos e taxas padrão da organização.", + "ratecardImportantNotice": "* Este cartão de taxa é gerado com base nos títulos de trabalho e taxas padrão da empresa. No entanto, você tem a flexibilidade de modificá-lo de acordo com o projeto. Essas alterações não afetarão os títulos de trabalho 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" + "jobTitleColumn": "Título do Trabalho", + "ratePerHourColumn": "Taxa por hora", + "ratecardPluralText": "Cartões de Taxa", + "labourHoursColumn": "Horas de Trabalho", + "actions": "Ações", + "selectJobTitle": "Selecionar Título do Trabalho", + "ratecardsPluralText": "Modelos de Cartão de Taxa", + "deleteConfirm": "Tem certeza?", + "yes": "Sim", + "no": "Não", + "alreadyImportedRateCardMessage": "Um cartão de taxa já foi importado. Limpe todos os cartões de taxa importados para adicionar um novo." } \ No newline at end of file diff --git a/worklenz-frontend/public/locales/pt/settings/ratecard-settings.json b/worklenz-frontend/public/locales/pt/settings/ratecard-settings.json index c7d1e8090..9fe817603 100644 --- a/worklenz-frontend/public/locales/pt/settings/ratecard-settings.json +++ b/worklenz-frontend/public/locales/pt/settings/ratecard-settings.json @@ -2,19 +2,49 @@ "nameColumn": "Nome", "createdColumn": "Criado", "noProjectsAvailable": "Nenhum projeto disponível", - "deleteConfirmationTitle": "Tem certeza?", - "deleteConfirmationOk": "Sim", + "deleteConfirmationTitle": "Tem certeza que deseja excluir esta tabela de preços?", + "deleteConfirmationOk": "Sim, excluir", "deleteConfirmationCancel": "Cancelar", - "searchPlaceholder": "Pesquisar por nome", + "searchPlaceholder": "Pesquisar tabelas de preços por nome", "createRatecard": "Criar Tabela de Preços", + "editTooltip": "Editar tabela de preços", + "deleteTooltip": "Excluir tabela de preços", + "fetchError": "Falha ao carregar tabelas de preços", + "createError": "Falha ao criar tabela de preços", + "deleteSuccess": "Tabela de preços excluída com sucesso", + "deleteError": "Falha ao excluir tabela de preços", "jobTitleColumn": "Cargo", "ratePerHourColumn": "Taxa por hora", "saveButton": "Salvar", - "addRoleButton": "+ Adicionar Função", - "createRatecardSuccessMessage": "Tabela de Preços criada com sucesso!", - "createRatecardErrorMessage": "Falha ao criar Tabela de Preços!", - "updateRatecardSuccessMessage": "Tabela de Preços atualizada com sucesso!", - "updateRatecardErrorMessage": "Falha ao atualizar Tabela de Preços!", - "currency": "Moeda" + "addRoleButton": "Adicionar Cargo", + "createRatecardSuccessMessage": "Tabela de preços criada com sucesso", + "createRatecardErrorMessage": "Falha ao criar tabela de preços", + "updateRatecardSuccessMessage": "Tabela de preços atualizada com sucesso", + "updateRatecardErrorMessage": "Falha ao atualizar tabela de preços", + "currency": "Moeda", + "actionsColumn": "Ações", + "addAllButton": "Adicionar Todos", + "removeAllButton": "Remover Todos", + "selectJobTitle": "Selecionar cargo", + "unsavedChangesTitle": "Você tem alterações não salvas", + "unsavedChangesMessage": "Deseja salvar suas alterações antes de sair?", + "unsavedChangesSave": "Salvar", + "unsavedChangesDiscard": "Descartar", + "ratecardNameRequired": "O nome da tabela de preços é obrigatório", + "ratecardNamePlaceholder": "Digite o nome da tabela de preços", + "noRatecardsFound": "Nenhuma tabela de preços encontrada", + "loadingRateCards": "Carregando tabelas de preços...", + "noJobTitlesAvailable": "Nenhum cargo disponível", + "noRolesAdded": "Nenhum cargo adicionado ainda", + "createFirstJobTitle": "Criar Primeiro Cargo", + "jobRolesTitle": "Cargos", + "noJobTitlesMessage": "Por favor, crie cargos primeiro nas configurações de Cargos antes de adicionar funções às tabelas de preços.", + "createNewJobTitle": "Criar Novo Cargo", + "jobTitleNamePlaceholder": "Digite o nome do cargo", + "jobTitleNameRequired": "O nome do cargo é obrigatório", + "jobTitleCreatedSuccess": "Cargo criado com sucesso", + "jobTitleCreateError": "Falha ao criar cargo", + "createButton": "Criar", + "cancelButton": "Cancelar" } diff --git a/worklenz-frontend/src/api/project-finance-ratecard/project-finance.api.service.ts b/worklenz-frontend/src/api/project-finance-ratecard/project-finance.api.service.ts index be199572e..187269308 100644 --- a/worklenz-frontend/src/api/project-finance-ratecard/project-finance.api.service.ts +++ b/worklenz-frontend/src/api/project-finance-ratecard/project-finance.api.service.ts @@ -5,27 +5,38 @@ import { IProjectFinanceResponse, ITaskBreakdownResponse, IProjectFinanceTask } const rootUrl = `${API_BASE_URL}/project-finance`; +type BillableFilterType = 'all' | 'billable' | 'non-billable'; + export const projectFinanceApiService = { getProjectTasks: async ( projectId: string, - groupBy: 'status' | 'priority' | 'phases' = 'status' + groupBy: 'status' | 'priority' | 'phases' = 'status', + billableFilter: BillableFilterType = 'billable' ): Promise> => { const response = await apiClient.get>( `${rootUrl}/project/${projectId}/tasks`, { - params: { group_by: groupBy } + params: { + group_by: groupBy, + billable_filter: billableFilter + } } ); - console.log(response.data); return response.data; }, getSubTasks: async ( projectId: string, - parentTaskId: string + parentTaskId: string, + billableFilter: BillableFilterType = 'billable' ): Promise> => { const response = await apiClient.get>( - `${rootUrl}/project/${projectId}/tasks/${parentTaskId}/subtasks` + `${rootUrl}/project/${projectId}/tasks/${parentTaskId}/subtasks`, + { + params: { + billable_filter: billableFilter + } + } ); return response.data; }, @@ -63,12 +74,16 @@ export const projectFinanceApiService = { exportFinanceData: async ( projectId: string, - groupBy: 'status' | 'priority' | 'phases' = 'status' + groupBy: 'status' | 'priority' | 'phases' = 'status', + billableFilter: BillableFilterType = 'billable' ): Promise => { const response = await apiClient.get( `${rootUrl}/project/${projectId}/export`, { - params: { groupBy }, + params: { + groupBy, + billable_filter: billableFilter + }, responseType: 'blob' } ); diff --git a/worklenz-frontend/src/components/task-drawer/shared/time-log/time-log-form.tsx b/worklenz-frontend/src/components/task-drawer/shared/time-log/time-log-form.tsx index f460231a4..e124144fa 100644 --- a/worklenz-frontend/src/components/task-drawer/shared/time-log/time-log-form.tsx +++ b/worklenz-frontend/src/components/task-drawer/shared/time-log/time-log-form.tsx @@ -5,6 +5,7 @@ import dayjs from 'dayjs'; import { useTranslation } from 'react-i18next'; import { useAppSelector } from '@/hooks/useAppSelector'; +import { useAppDispatch } from '@/hooks/useAppDispatch'; import { themeWiseColor } from '@/utils/themeWiseColor'; import { useAuthService } from '@/hooks/useAuth'; import { useSocket } from '@/socket/socketContext'; @@ -12,6 +13,7 @@ import { SocketEvents } from '@/shared/socket-events'; import { ITaskAssigneesUpdateResponse } from '@/types/tasks/task-assignee-update-response'; import { ITaskLogViewModel } from '@/types/tasks/task-log-view.types'; import { taskTimeLogsApiService } from '@/api/tasks/task-time-logs.api.service'; +import { setRefreshTimestamp } from '@/features/project/project.slice'; interface TimeLogFormProps { onCancel: () => void; @@ -29,6 +31,7 @@ const TimeLogForm = ({ const { t } = useTranslation('task-drawer/task-drawer'); const currentSession = useAuthService().getCurrentSession(); const { socket, connected } = useSocket(); + const dispatch = useAppDispatch(); const [form] = Form.useForm(); const [formValues, setFormValues] = React.useState<{ date: any; @@ -170,6 +173,9 @@ const TimeLogForm = ({ await taskTimeLogsApiService.create(requestBody); } + // Trigger refresh of finance data + dispatch(setRefreshTimestamp()); + // Call onSubmitSuccess if provided, otherwise just cancel if (onSubmitSuccess) { onSubmitSuccess(); diff --git a/worklenz-frontend/src/components/task-drawer/shared/time-log/time-log-item.tsx b/worklenz-frontend/src/components/task-drawer/shared/time-log/time-log-item.tsx index d1bcfdafe..81f84085d 100644 --- a/worklenz-frontend/src/components/task-drawer/shared/time-log/time-log-item.tsx +++ b/worklenz-frontend/src/components/task-drawer/shared/time-log/time-log-item.tsx @@ -13,6 +13,7 @@ import { useAppDispatch } from '@/hooks/useAppDispatch'; import { setTimeLogEditing } from '@/features/task-drawer/task-drawer.slice'; import TimeLogForm from './time-log-form'; import { useAuthService } from '@/hooks/useAuth'; +import { setRefreshTimestamp } from '@/features/project/project.slice'; type TimeLogItemProps = { log: ITaskLogViewModel; @@ -41,6 +42,9 @@ const TimeLogItem = ({ log, onDelete }: TimeLogItemProps) => { if (!logId || !selectedTaskId) return; const res = await taskTimeLogsApiService.delete(logId, selectedTaskId); if (res.done) { + // Trigger refresh of finance data + dispatch(setRefreshTimestamp()); + if (onDelete) onDelete(); } }; diff --git a/worklenz-frontend/src/features/finance/ratecard-drawer/ratecard-drawer.tsx b/worklenz-frontend/src/features/finance/ratecard-drawer/ratecard-drawer.tsx index b968f3e3c..e4163e0d9 100644 --- a/worklenz-frontend/src/features/finance/ratecard-drawer/ratecard-drawer.tsx +++ b/worklenz-frontend/src/features/finance/ratecard-drawer/ratecard-drawer.tsx @@ -7,8 +7,11 @@ import { deleteRateCard, fetchRateCardById, fetchRateCards, toggleRatecardDrawer import { RatecardType, IJobType } from '@/types/project/ratecard.types'; import { IJobTitlesViewModel } from '@/types/job.types'; import { jobTitlesApiService } from '@/api/settings/job-titles/job-titles.api.service'; -import { DeleteOutlined, ExclamationCircleFilled } from '@ant-design/icons'; +import { DeleteOutlined, ExclamationCircleFilled, PlusOutlined } from '@ant-design/icons'; import { colors } from '@/styles/colors'; +import CreateJobTitlesDrawer from '@/features/settings/job/CreateJobTitlesDrawer'; +import { toggleCreateJobTitleDrawer } from '@/features/settings/job/jobSlice'; +import { CURRENCY_OPTIONS, DEFAULT_CURRENCY } from '@/shared/constants/currencies'; interface PaginationType { current: number; @@ -33,7 +36,7 @@ const RatecardDrawer = ({ const [roles, setRoles] = useState([]); const [initialRoles, setInitialRoles] = useState([]); const [initialName, setInitialName] = useState('Untitled Rate Card'); - const [initialCurrency, setInitialCurrency] = useState('USD'); + const [initialCurrency, setInitialCurrency] = useState(DEFAULT_CURRENCY); const [addingRowIndex, setAddingRowIndex] = useState(null); const { t } = useTranslation('settings/ratecard-settings'); const drawerLoading = useAppSelector(state => state.financeReducer.isFinanceDrawerloading); @@ -46,7 +49,7 @@ const RatecardDrawer = ({ const [isAddingRole, setIsAddingRole] = useState(false); const [selectedJobTitleId, setSelectedJobTitleId] = useState(undefined); const [searchQuery, setSearchQuery] = useState(''); - const [currency, setCurrency] = useState('USD'); + const [currency, setCurrency] = useState(DEFAULT_CURRENCY); const [name, setName] = useState('Untitled Rate Card'); const [jobTitles, setJobTitles] = useState({}); const [pagination, setPagination] = useState({ @@ -61,6 +64,8 @@ const RatecardDrawer = ({ const [editingRowIndex, setEditingRowIndex] = useState(null); const [showUnsavedAlert, setShowUnsavedAlert] = useState(false); const [messageApi, contextHolder] = message.useMessage(); + const [isCreatingJobTitle, setIsCreatingJobTitle] = useState(false); + const [newJobTitleName, setNewJobTitleName] = useState(''); // Detect changes const hasChanges = useMemo(() => { const rolesChanged = JSON.stringify(roles) !== JSON.stringify(initialRoles); @@ -105,8 +110,8 @@ const RatecardDrawer = ({ setInitialRoles(drawerRatecard.jobRolesList || []); setName(drawerRatecard.name || ''); setInitialName(drawerRatecard.name || ''); - setCurrency(drawerRatecard.currency || 'USD'); - setInitialCurrency(drawerRatecard.currency || 'USD'); + setCurrency(drawerRatecard.currency || DEFAULT_CURRENCY); + setInitialCurrency(drawerRatecard.currency || DEFAULT_CURRENCY); } }, [drawerRatecard, type]); @@ -129,15 +134,67 @@ const RatecardDrawer = ({ }; const handleAddRole = () => { - const existingIds = new Set(roles.map(r => r.job_title_id)); - const availableJobTitles = jobTitles.data?.filter(jt => !existingIds.has(jt.id!)); - if (availableJobTitles && availableJobTitles.length > 0) { - setRoles([...roles, { job_title_id: '', rate: 0 }]); + if (Object.keys(jobTitles).length === 0) { + // Allow inline job title creation + setIsCreatingJobTitle(true); + } else { + // Add a new empty role to the table + const newRole = { + jobtitle: '', + rate_card_id: ratecardId, + job_title_id: '', + rate: 0, + }; + setRoles([...roles, newRole]); setAddingRowIndex(roles.length); setIsAddingRole(true); } }; + const handleCreateJobTitle = async () => { + if (!newJobTitleName.trim()) { + messageApi.warning(t('jobTitleNameRequired') || 'Job title name is required'); + return; + } + + try { + // Create the job title using the API + const response = await jobTitlesApiService.createJobTitle({ + name: newJobTitleName.trim() + }); + + if (response.done) { + // Refresh job titles + await getJobTitles(); + + // Create a new role with the newly created job title + const newRole = { + jobtitle: newJobTitleName.trim(), + rate_card_id: ratecardId, + job_title_id: response.body.id, + rate: 0, + }; + setRoles([...roles, newRole]); + + // Reset creation state + setIsCreatingJobTitle(false); + setNewJobTitleName(''); + + messageApi.success(t('jobTitleCreatedSuccess') || 'Job title created successfully'); + } else { + messageApi.error(t('jobTitleCreateError') || 'Failed to create job title'); + } + } catch (error) { + console.error('Failed to create job title:', error); + messageApi.error(t('jobTitleCreateError') || 'Failed to create job title'); + } + }; + + const handleCancelJobTitleCreation = () => { + setIsCreatingJobTitle(false); + setNewJobTitleName(''); + }; + const handleDeleteRole = (index: number) => { const updatedRoles = [...roles]; updatedRoles.splice(index, 1); @@ -195,10 +252,10 @@ const RatecardDrawer = ({ } finally { setRoles([]); setName('Untitled Rate Card'); - setCurrency('USD'); + setCurrency(DEFAULT_CURRENCY); setInitialRoles([]); setInitialName('Untitled Rate Card'); - setInitialCurrency('USD'); + setInitialCurrency(DEFAULT_CURRENCY); } } }; @@ -335,10 +392,10 @@ const RatecardDrawer = ({ dispatch(toggleRatecardDrawer()); setRoles([]); setName('Untitled Rate Card'); - setCurrency('USD'); + setCurrency(DEFAULT_CURRENCY); setInitialRoles([]); setInitialName('Untitled Rate Card'); - setInitialCurrency('USD'); + setInitialCurrency(DEFAULT_CURRENCY); setShowUnsavedAlert(false); }; @@ -353,7 +410,7 @@ const RatecardDrawer = ({ {t('currency')} setNewJobTitleName(e.target.value)} + onPressEnter={handleCreateJobTitle} + autoFocus + style={{ width: 200 }} + /> + + + + + ) : ( + + + {Object.keys(jobTitles).length === 0 + ? t('noJobTitlesAvailable') + : t('noRolesAdded')} + + + ), + }} + /> + + - ); }; diff --git a/worklenz-frontend/src/features/projects/finance/project-finance.slice.ts b/worklenz-frontend/src/features/projects/finance/project-finance.slice.ts index 788ffb170..c74a6d4bf 100644 --- a/worklenz-frontend/src/features/projects/finance/project-finance.slice.ts +++ b/worklenz-frontend/src/features/projects/finance/project-finance.slice.ts @@ -5,10 +5,12 @@ import { parseTimeToSeconds } from '@/utils/timeUtils'; type FinanceTabType = 'finance' | 'ratecard'; type GroupTypes = 'status' | 'priority' | 'phases'; +type BillableFilterType = 'all' | 'billable' | 'non-billable'; interface ProjectFinanceState { activeTab: FinanceTabType; activeGroup: GroupTypes; + billableFilter: BillableFilterType; loading: boolean; taskGroups: IProjectFinanceGroup[]; projectRateCards: IProjectRateCard[]; @@ -65,6 +67,7 @@ const calculateGroupTotals = (tasks: IProjectFinanceTask[]) => { const initialState: ProjectFinanceState = { activeTab: 'finance', activeGroup: 'status', + billableFilter: 'billable', loading: false, taskGroups: [], projectRateCards: [], @@ -73,24 +76,24 @@ const initialState: ProjectFinanceState = { export const fetchProjectFinances = createAsyncThunk( 'projectFinances/fetchProjectFinances', - async ({ projectId, groupBy }: { projectId: string; groupBy: GroupTypes }) => { - const response = await projectFinanceApiService.getProjectTasks(projectId, groupBy); + async ({ projectId, groupBy, billableFilter }: { projectId: string; groupBy: GroupTypes; billableFilter?: BillableFilterType }) => { + const response = await projectFinanceApiService.getProjectTasks(projectId, groupBy, billableFilter); return response.body; } ); export const fetchProjectFinancesSilent = createAsyncThunk( 'projectFinances/fetchProjectFinancesSilent', - async ({ projectId, groupBy }: { projectId: string; groupBy: GroupTypes }) => { - const response = await projectFinanceApiService.getProjectTasks(projectId, groupBy); + async ({ projectId, groupBy, billableFilter }: { projectId: string; groupBy: GroupTypes; billableFilter?: BillableFilterType }) => { + const response = await projectFinanceApiService.getProjectTasks(projectId, groupBy, billableFilter); return response.body; } ); export const fetchSubTasks = createAsyncThunk( 'projectFinances/fetchSubTasks', - async ({ projectId, parentTaskId }: { projectId: string; parentTaskId: string }) => { - const response = await projectFinanceApiService.getSubTasks(projectId, parentTaskId); + async ({ projectId, parentTaskId, billableFilter }: { projectId: string; parentTaskId: string; billableFilter?: BillableFilterType }) => { + const response = await projectFinanceApiService.getSubTasks(projectId, parentTaskId, billableFilter); return { parentTaskId, subTasks: response.body }; } ); @@ -113,6 +116,9 @@ export const projectFinancesSlice = createSlice({ setActiveGroup: (state, action: PayloadAction) => { state.activeGroup = action.payload; }, + setBillableFilter: (state, action: PayloadAction) => { + state.billableFilter = action.payload; + }, updateTaskFixedCost: (state, action: PayloadAction<{ taskId: string; groupId: string; fixedCost: number }>) => { const { taskId, groupId, fixedCost } = action.payload; const group = state.taskGroups.find(g => g.group_id === groupId); @@ -224,6 +230,7 @@ export const projectFinancesSlice = createSlice({ export const { setActiveTab, setActiveGroup, + setBillableFilter, updateTaskFixedCost, updateTaskEstimatedCost, updateTaskTimeLogged, diff --git a/worklenz-frontend/src/lib/project/finance-table-wrapper.tsx b/worklenz-frontend/src/lib/project/finance-table-wrapper.tsx deleted file mode 100644 index 36dc4053b..000000000 --- a/worklenz-frontend/src/lib/project/finance-table-wrapper.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import React from 'react'; -import { Table } from 'antd'; -import { useTranslation } from 'react-i18next'; -import { financeTableColumns } from './project-view-finance-table-columns'; - -interface IFinanceTableData { - id: string; - name: string; - estimated_hours: number; - estimated_cost: number; - fixed_cost: number; - total_budgeted_cost: number; - total_actual_cost: number; - variance: number; - total_time_logged: number; - assignees: Array<{ - team_member_id: string; - project_member_id: string; - name: string; - avatar_url: string; - }>; -} - -interface FinanceTableWrapperProps { - data: IFinanceTableData[]; - loading?: boolean; -} - -const FinanceTableWrapper: React.FC = ({ data, loading }) => { - const { t } = useTranslation(); - - const columns = financeTableColumns.map(col => ({ - ...col, - title: t(`projectViewFinance.${col.name}`), - dataIndex: col.key, - key: col.key, - width: col.width, - render: col.render || ((value: any) => { - if (col.type === 'hours') { - return value ? value.toFixed(2) : '0.00'; - } - if (col.type === 'currency') { - return value ? `$${value.toFixed(2)}` : '$0.00'; - } - return value; - }) - })); - - return ( - - ); -}; - -export default FinanceTableWrapper; \ No newline at end of file diff --git a/worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-table-wrapper.tsx b/worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-table-wrapper.tsx deleted file mode 100644 index ba663bce6..000000000 --- a/worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-table-wrapper.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import React from "react"; -import { Card, Col, Row } from "antd"; - -import { IProjectFinanceGroup } from "../../../../../types/project/project-finance.types"; -import FinanceTable from "./finance-table/finance-table"; - -interface Props { - activeTablesList: IProjectFinanceGroup[]; - loading: boolean; -} - -export const FinanceTableWrapper: React.FC = ({ activeTablesList, loading }) => { - const { isDarkMode } = useThemeContext(); - - const getTableColor = (table: IProjectFinanceGroup) => { - return isDarkMode ? table.color_code_dark : table.color_code; - }; - - return ( -
- - {activeTablesList.map((table) => ( -
- -
-

{table.group_name}

-
- -
- - ))} - - - ); -}; \ No newline at end of file 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 index 2da99b068..1c4419aa6 100644 --- 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 @@ -1,5 +1,5 @@ import React, { useEffect, useState, useMemo } from 'react'; -import { Flex, InputNumber, Tooltip, Typography, Empty } from 'antd'; +import { Flex, Typography, Empty } from 'antd'; import { themeWiseColor } from '@/utils/themeWiseColor'; import { useAppSelector } from '@/hooks/useAppSelector'; import { useTranslation } from 'react-i18next'; @@ -8,9 +8,7 @@ import { openFinanceDrawer } from '@/features/finance/finance-slice'; import { financeTableColumns, FinanceTableColumnKeys } from '@/lib/project/project-view-finance-table-columns'; import FinanceTable from './finance-table'; import FinanceDrawer from '@/features/finance/finance-drawer/finance-drawer'; -import { convertToHoursMinutes, formatHoursToReadable } from '@/utils/format-hours-to-readable'; import { IProjectFinanceGroup } from '@/types/project/project-finance.types'; -import { updateTaskFixedCostAsync } from '@/features/projects/finance/project-finance.slice'; interface FinanceTableWrapperProps { activeTablesList: IProjectFinanceGroup[]; @@ -35,14 +33,10 @@ const formatSecondsToTimeString = (totalSeconds: number): string => { const FinanceTableWrapper: React.FC = ({ activeTablesList, loading }) => { const [isScrolling, setIsScrolling] = useState(false); - const [editingFixedCost, setEditingFixedCost] = useState<{ taskId: string; groupId: string } | null>(null); const { t } = useTranslation('project-view-finance'); const dispatch = useAppDispatch(); - // Get selected task from Redux store - const selectedTask = useAppSelector(state => state.financeReducer.selectedTask); - const onTaskClick = (task: any) => { dispatch(openFinanceDrawer(task)); }; @@ -61,19 +55,7 @@ const FinanceTableWrapper: React.FC = ({ activeTablesL }; }, []); - // Handle click outside to close editing - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (editingFixedCost && !(event.target as Element)?.closest('.fixed-cost-input')) { - setEditingFixedCost(null); - } - }; - document.addEventListener('mousedown', handleClickOutside); - return () => { - document.removeEventListener('mousedown', handleClickOutside); - }; - }, [editingFixedCost]); const themeMode = useAppSelector(state => state.themeReducer.mode); const currency = useAppSelector(state => state.projectFinances.project?.currency || "").toUpperCase(); @@ -97,7 +79,7 @@ const FinanceTableWrapper: React.FC = ({ activeTablesL ) => { table.tasks.forEach((task) => { acc.hours += (task.estimated_seconds) || 0; - acc.cost += task.estimated_cost || 0; + acc.cost += ((task.total_actual || 0) - (task.fixed_cost || 0)); acc.fixedCost += task.fixed_cost || 0; acc.totalBudget += task.total_budget || 0; acc.totalActual += task.total_actual || 0; @@ -120,10 +102,7 @@ const FinanceTableWrapper: React.FC = ({ activeTablesL ); }, [taskGroups]); - const handleFixedCostChange = (value: number | null, taskId: string, groupId: string) => { - dispatch(updateTaskFixedCostAsync({ taskId, groupId, fixedCost: value || 0 })); - setEditingFixedCost(null); - }; + const renderFinancialTableHeaderContent = (columnKey: FinanceTableColumnKeys) => { switch (columnKey) { @@ -242,7 +221,6 @@ const FinanceTableWrapper: React.FC = ({ activeTablesL 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 index c5f7ee8b8..96809d802 100644 --- 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 @@ -1,4 +1,4 @@ -import { Checkbox, Flex, Input, InputNumber, Skeleton, Tooltip, Typography } from 'antd'; +import { Flex, InputNumber, Skeleton, Tooltip, Typography } from 'antd'; import { useEffect, useMemo, useState, useRef } from 'react'; import { useAppSelector } from '@/hooks/useAppSelector'; import { @@ -13,7 +13,6 @@ import Avatars from '@/components/avatars/avatars'; import { IProjectFinanceGroup, IProjectFinanceTask } from '@/types/project/project-finance.types'; import { updateTaskFixedCostAsync, - updateTaskFixedCost, fetchProjectFinancesSilent, toggleTaskExpansion, fetchSubTasks @@ -21,7 +20,7 @@ import { import { useAppDispatch } from '@/hooks/useAppDispatch'; import { setSelectedTaskId, setShowTaskDrawer, fetchTask } from '@/features/task-drawer/task-drawer.slice'; import { useParams } from 'react-router-dom'; -import { parseTimeToSeconds } from '@/utils/timeUtils'; + import { useAuthService } from '@/hooks/useAuth'; import { canEditFixedCost } from '@/utils/finance-permissions'; import './finance-table.css'; @@ -29,17 +28,16 @@ import './finance-table.css'; type FinanceTableProps = { table: IProjectFinanceGroup; loading: boolean; - isScrolling: boolean; onTaskClick: (task: any) => void; }; const FinanceTable = ({ table, loading, - isScrolling, onTaskClick, }: FinanceTableProps) => { const [isCollapse, setIsCollapse] = useState(false); + const [isScrolling, setIsScrolling] = useState(false); const [selectedTask, setSelectedTask] = useState(null); const [editingFixedCostValue, setEditingFixedCostValue] = useState(null); const [tasks, setTasks] = useState(table.tasks); @@ -357,19 +355,43 @@ const FinanceTable = ({ return parts.join(' '); }; - // Calculate totals for the current table + // Flatten tasks to include subtasks for rendering + const flattenedTasks = useMemo(() => { + const flattened: IProjectFinanceTask[] = []; + + tasks.forEach(task => { + // Add the parent task + flattened.push(task); + + // Add subtasks if they are expanded and loaded + if (task.show_sub_tasks && task.sub_tasks) { + task.sub_tasks.forEach(subTask => { + flattened.push(subTask); + }); + } + }); + + return flattened; + }, [tasks]); + + // Calculate totals for the current table (only count parent tasks to avoid double counting) const totals = useMemo(() => { return tasks.reduce( - (acc, task) => ({ - hours: acc.hours + (task.estimated_seconds || 0), - total_time_logged: acc.total_time_logged + (task.total_time_logged_seconds || 0), - estimated_cost: acc.estimated_cost + (task.estimated_cost || 0), - actual_cost_from_logs: acc.actual_cost_from_logs + ((task.total_actual || 0) - (task.fixed_cost || 0)), - fixed_cost: acc.fixed_cost + (task.fixed_cost || 0), - total_budget: acc.total_budget + (task.total_budget || 0), - total_actual: acc.total_actual + (task.total_actual || 0), - variance: acc.variance + (task.variance || 0) - }), + (acc, task) => { + // Calculate actual cost from logs (total_actual - fixed_cost) + const actualCostFromLogs = (task.total_actual || 0) - (task.fixed_cost || 0); + + return { + hours: acc.hours + (task.estimated_seconds || 0), + total_time_logged: acc.total_time_logged + (task.total_time_logged_seconds || 0), + estimated_cost: acc.estimated_cost + (task.estimated_cost || 0), + actual_cost_from_logs: acc.actual_cost_from_logs + actualCostFromLogs, + fixed_cost: acc.fixed_cost + (task.fixed_cost || 0), + total_budget: acc.total_budget + (task.total_budget || 0), + total_actual: acc.total_actual + (task.total_actual || 0), + variance: acc.variance + (task.variance || 0) + }; + }, { hours: 0, total_time_logged: 0, @@ -382,7 +404,7 @@ const FinanceTable = ({ } ); }, [tasks]); - + // Format the totals for display const formattedTotals = useMemo(() => ({ hours: formatSecondsToTimeString(totals.hours), @@ -395,110 +417,99 @@ const FinanceTable = ({ variance: totals.variance }), [totals]); - // Flatten tasks to include subtasks for rendering - const flattenedTasks = useMemo(() => { - const flattened: IProjectFinanceTask[] = []; - - tasks.forEach(task => { - // Add the parent task - flattened.push(task); - - // Add subtasks if they are expanded and loaded - if (task.show_sub_tasks && task.sub_tasks) { - task.sub_tasks.forEach(subTask => { - flattened.push(subTask); - }); - } - }); - - return flattened; - }, [tasks]); + if (loading) { + return ( + + + + ); + } return ( - - <> - {/* header row */} + <> + {/* header row */} + + {financeTableColumns.map( + (col, index) => ( + + ) + )} + + + {/* task rows */} + {!isCollapse && flattenedTasks.map((task, idx) => ( e.currentTarget.style.background = themeWiseColor('#f0f0f0', '#333', themeMode)} + onMouseLeave={e => e.currentTarget.style.background = idx % 2 === 0 ? themeWiseColor('#fafafa', '#232323', themeMode) : themeWiseColor('#ffffff', '#181818', themeMode)} > - {financeTableColumns.map( - (col, index) => ( - - ) - )} + {financeTableColumns.map((col) => ( + + ))} - - {/* task rows */} - {!isCollapse && flattenedTasks.map((task, idx) => ( - e.currentTarget.style.background = themeWiseColor('#f0f0f0', '#333', themeMode)} - onMouseLeave={e => e.currentTarget.style.background = idx % 2 === 0 ? themeWiseColor('#fafafa', '#232323', themeMode) : themeWiseColor('#ffffff', '#181818', themeMode)} - > - {financeTableColumns.map((col) => ( - - ))} - - ))} - - + ))} + ); }; 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 index f499692ff..6d0d9355c 100644 --- a/worklenz-frontend/src/pages/projects/projectView/finance/project-view-finance.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/finance/project-view-finance.tsx @@ -1,11 +1,11 @@ -import { Button, ConfigProvider, Flex, Select, Typography, message, Alert } from 'antd'; -import { useEffect, useState } from 'react'; +import { Button, ConfigProvider, Flex, Select, Typography, message, Alert, Card, Row, Col, Statistic } from 'antd'; +import { useEffect, useState, useMemo, useCallback } from 'react'; import { useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { CaretDownFilled, DownOutlined } from '@ant-design/icons'; +import { CaretDownFilled, DownOutlined, CalculatorOutlined } from '@ant-design/icons'; import { useAppDispatch } from '@/hooks/useAppDispatch'; import { useAppSelector } from '@/hooks/useAppSelector'; -import { fetchProjectFinances, setActiveTab, setActiveGroup, updateProjectFinanceCurrency } from '@/features/projects/finance/project-finance.slice'; +import { fetchProjectFinances, setActiveTab, setActiveGroup, updateProjectFinanceCurrency, fetchProjectFinancesSilent, setBillableFilter } from '@/features/projects/finance/project-finance.slice'; import { changeCurrency, toggleImportRatecardsDrawer } from '@/features/finance/finance-slice'; import { updateProjectCurrency } from '@/features/project/project.slice'; import { projectFinanceApiService } from '@/api/project-finance-ratecard/project-finance.api.service'; @@ -16,6 +16,8 @@ import ImportRatecardsDrawer from '@/features/finance/ratecard-drawer/import-rat import { useAuthService } from '@/hooks/useAuth'; import { hasFinanceEditPermission } from '@/utils/finance-permissions'; import { CURRENCY_OPTIONS, DEFAULT_CURRENCY } from '@/shared/constants/currencies'; +import { useSocket } from '@/socket/socketContext'; +import { SocketEvents } from '@/shared/socket-events'; const ProjectViewFinance = () => { const { projectId } = useParams<{ projectId: string }>(); @@ -23,8 +25,9 @@ const ProjectViewFinance = () => { const { t } = useTranslation('project-view-finance'); const [exporting, setExporting] = useState(false); const [updatingCurrency, setUpdatingCurrency] = useState(false); + const { socket } = useSocket(); - const { activeTab, activeGroup, loading, taskGroups, project: financeProject } = useAppSelector((state: RootState) => state.projectFinances); + const { activeTab, activeGroup, billableFilter, loading, taskGroups, project: financeProject } = useAppSelector((state: RootState) => state.projectFinances); const { refreshTimestamp, project } = useAppSelector((state: RootState) => state.projectReducer); const phaseList = useAppSelector((state) => state.phaseReducer.phaseList); @@ -39,11 +42,99 @@ const ProjectViewFinance = () => { // Show loading state for currency selector until finance data is loaded const currencyLoading = loading || updatingCurrency || !financeProject; + // Calculate project budget statistics + const budgetStatistics = useMemo(() => { + if (!taskGroups || taskGroups.length === 0) { + return { + totalEstimatedCost: 0, + totalFixedCost: 0, + totalBudget: 0, + totalActualCost: 0, + totalVariance: 0, + budgetUtilization: 0 + }; + } + + const totals = taskGroups.reduce((acc, group) => { + group.tasks.forEach(task => { + acc.totalEstimatedCost += task.estimated_cost || 0; + acc.totalFixedCost += task.fixed_cost || 0; + acc.totalBudget += task.total_budget || 0; + acc.totalActualCost += task.total_actual || 0; + acc.totalVariance += task.variance || 0; + }); + return acc; + }, { + totalEstimatedCost: 0, + totalFixedCost: 0, + totalBudget: 0, + totalActualCost: 0, + totalVariance: 0 + }); + + const budgetUtilization = totals.totalBudget > 0 + ? (totals.totalActualCost / totals.totalBudget) * 100 + : 0; + + return { + ...totals, + budgetUtilization + }; + }, [taskGroups]); + + // Silent refresh function for socket events + const refreshFinanceData = useCallback(() => { + if (projectId) { + dispatch(fetchProjectFinancesSilent({ projectId, groupBy: activeGroup, billableFilter })); + } + }, [projectId, activeGroup, billableFilter, dispatch]); + + // Socket event handlers + const handleTaskEstimationChange = useCallback(() => { + refreshFinanceData(); + }, [refreshFinanceData]); + + const handleTaskTimerStop = useCallback(() => { + refreshFinanceData(); + }, [refreshFinanceData]); + + const handleTaskProgressUpdate = useCallback(() => { + refreshFinanceData(); + }, [refreshFinanceData]); + + const handleTaskBillableChange = useCallback(() => { + refreshFinanceData(); + }, [refreshFinanceData]); + useEffect(() => { if (projectId) { - dispatch(fetchProjectFinances({ projectId, groupBy: activeGroup })); + dispatch(fetchProjectFinances({ projectId, groupBy: activeGroup, billableFilter })); } - }, [projectId, activeGroup, dispatch, refreshTimestamp]); + }, [projectId, activeGroup, billableFilter, dispatch, refreshTimestamp]); + + // Socket event listeners for finance data refresh + useEffect(() => { + if (!socket) return; + + const eventHandlers = [ + { event: SocketEvents.TASK_TIME_ESTIMATION_CHANGE.toString(), handler: handleTaskEstimationChange }, + { event: SocketEvents.TASK_TIMER_STOP.toString(), handler: handleTaskTimerStop }, + { event: SocketEvents.TASK_PROGRESS_UPDATED.toString(), handler: handleTaskProgressUpdate }, + { event: SocketEvents.TASK_BILLABLE_CHANGE.toString(), handler: handleTaskBillableChange }, + ]; + + // Register all event listeners + eventHandlers.forEach(({ event, handler }) => { + socket.on(event, handler); + }); + + // Cleanup function + return () => { + eventHandlers.forEach(({ event, handler }) => { + socket.off(event, handler); + }); + }; + }, [socket, handleTaskEstimationChange, handleTaskTimerStop, handleTaskProgressUpdate, handleTaskBillableChange]); const handleExport = async () => { if (!projectId) { @@ -53,7 +144,7 @@ const ProjectViewFinance = () => { try { setExporting(true); - const blob = await projectFinanceApiService.exportFinanceData(projectId, activeGroup); + const blob = await projectFinanceApiService.exportFinanceData(projectId, activeGroup, billableFilter); const projectName = project?.name || 'Unknown_Project'; const sanitizedProjectName = projectName.replace(/[^a-zA-Z0-9\s]/g, '').replace(/\s+/g, '_'); @@ -115,6 +206,12 @@ const ProjectViewFinance = () => { }, ]; + const billableFilterOptions = [ + { key: 'billable', value: 'billable', label: t('billableOnlyText') }, + { key: 'non-billable', value: 'non-billable', label: t('nonBillableOnlyText') }, + { key: 'all', value: 'all', label: t('allTasksText') }, + ]; + return ( {/* Finance Header */} @@ -137,14 +234,26 @@ const ProjectViewFinance = () => { {activeTab === 'finance' && ( - - {t('groupByText')}: - dispatch(setActiveGroup(value as 'status' | 'priority' | 'phases'))} + suffixIcon={} + /> + + + {t('filterText')}: + setSearchQuery(e.target.value)} - placeholder={t('searchPlaceholder')} - style={{ maxWidth: 232 }} - suffix={} - /> - - - } - > -
+ +
setIsCollapse((prev) => !prev) : undefined} + > + {col.key === FinanceTableColumnKeys.TASK ? ( + + {isCollapse ? : } + {table.group_name} ({tasks.length}) + + ) : col.key === FinanceTableColumnKeys.MEMBERS ? null : renderFinancialTableHeaderContent(col.key)} +
setIsCollapse((prev) => !prev) : undefined} - > - {col.key === FinanceTableColumnKeys.TASK ? ( - - {isCollapse ? : } - {table.group_name} ({tasks.length}) - - ) : col.key === FinanceTableColumnKeys.MEMBERS ? null : renderFinancialTableHeaderContent(col.key)} - e.stopPropagation() + : undefined + } + > + {renderFinancialTableColumnContent(col.key, task)} +
e.stopPropagation() - : undefined - } - > - {renderFinancialTableColumnContent(col.key, task)} -
setPagination(prev => ({ ...prev, current: page, pageSize })), - }} - onChange={handleTableChange} - rowClassName="group" - /> - - + <> + {contextHolder} + + setSearchQuery(e.target.value)} + placeholder={t('searchPlaceholder')} + style={{ maxWidth: 232 }} + suffix={} + /> + + + } + > +
setPagination(prev => ({ ...prev, current: page, pageSize })), + }} + onChange={handleTableChange} + rowClassName="group" + locale={{ + emptyText: , + }} + /> + + + ); }; From c031a49a2993ba18c7e4a29026faa42b832dc933 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Fri, 6 Jun 2025 14:43:30 +0530 Subject: [PATCH 094/105] refactor(project-finance): enhance financial statistics display and formatting - Updated the layout of financial statistics in the ProjectViewFinance component for improved responsiveness and visual clarity. - Adjusted the formatting of variance and budget utilization values to ensure consistent presentation, including prefix and suffix adjustments. - Refactored the FinanceTable component to display variance values with appropriate signs and formatting. - Implemented the use of createPortal for rendering the FinanceDrawer, improving modal management. --- .../finance-table/finance-table-wrapper.tsx | 7 +- .../finance-table/finance-table.tsx | 12 +++- .../finance/project-view-finance.tsx | 65 +++++++++++-------- 3 files changed, 52 insertions(+), 32 deletions(-) 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 index 1c4419aa6..0e5b6dd0d 100644 --- 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 @@ -9,6 +9,7 @@ import { financeTableColumns, FinanceTableColumnKeys } from '@/lib/project/proje import FinanceTable from './finance-table'; import FinanceDrawer from '@/features/finance/finance-drawer/finance-drawer'; import { IProjectFinanceGroup } from '@/types/project/project-finance.types'; +import { createPortal } from 'react-dom'; interface FinanceTableWrapperProps { activeTablesList: IProjectFinanceGroup[]; @@ -128,7 +129,9 @@ const FinanceTableWrapper: React.FC = ({ activeTablesL fontSize: 18, }} > - {`${totals.variance?.toFixed(2)}`} + {totals.variance < 0 ? `+${Math.abs(totals.variance).toFixed(2)}` : + totals.variance > 0 ? `-${totals.variance.toFixed(2)}` : + `${totals.variance?.toFixed(2)}`} ); case FinanceTableColumnKeys.TOTAL_TIME_LOGGED: @@ -243,7 +246,7 @@ const FinanceTableWrapper: React.FC = ({ activeTablesL
- + {createPortal(, document.body)} ); }; 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 index 96809d802..8c8b74b64 100644 --- 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 @@ -125,7 +125,13 @@ const FinanceTable = ({ case FinanceTableColumnKeys.TOTAL_ACTUAL: return {formatNumber(formattedTotals.total_actual)}; case FinanceTableColumnKeys.VARIANCE: - return 0 ? '#FF0000' : '#6DC376' }}>{formatNumber(formattedTotals.variance)}; + return ( + 0 ? '#FF0000' : '#6DC376' }}> + {formattedTotals.variance < 0 ? '+' + formatNumber(Math.abs(formattedTotals.variance)) : + formattedTotals.variance > 0 ? '-' + formatNumber(formattedTotals.variance) : + formatNumber(formattedTotals.variance)} + + ); default: return null; } @@ -325,7 +331,9 @@ const FinanceTable = ({ color: task.variance > 0 ? '#FF0000' : '#6DC376' }} > - {formatNumber(task.variance)} + {task.variance < 0 ? '+' + formatNumber(Math.abs(task.variance)) : + task.variance > 0 ? '-' + formatNumber(task.variance) : + formatNumber(task.variance)} ); case FinanceTableColumnKeys.TOTAL_BUDGET: 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 index 6d0d9355c..4b75d2641 100644 --- a/worklenz-frontend/src/pages/projects/projectView/finance/project-view-finance.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/finance/project-view-finance.tsx @@ -314,90 +314,99 @@ const ProjectViewFinance = () => { } style={{ marginBottom: 16 }} loading={loading} + size="small" > - - + + - + - + = 0 ? '+' : ''} - suffix={projectCurrency.toUpperCase()} + prefix={projectCurrency.toUpperCase()} + suffix={budgetStatistics.totalVariance < 0 ? ' under' : budgetStatistics.totalVariance > 0 ? ' over' : ''} valueStyle={{ - color: budgetStatistics.totalVariance > 0 ? '#ff4d4f' : '#52c41a' + color: budgetStatistics.totalVariance > 0 ? '#ff4d4f' : '#52c41a', + fontSize: '16px' }} + style={{ textAlign: 'center' }} /> - + 100 ? '#ff4d4f' : - budgetStatistics.budgetUtilization > 80 ? '#faad14' : '#52c41a' + budgetStatistics.budgetUtilization > 80 ? '#faad14' : '#52c41a', + fontSize: '16px' }} + style={{ textAlign: 'center' }} /> - - - - + - + - + - + = 0 ? '+' : ''} - suffix={`${projectCurrency.toUpperCase()} remaining`} + suffix={projectCurrency.toUpperCase()} valueStyle={{ - color: budgetStatistics.totalBudget - budgetStatistics.totalActualCost >= 0 ? '#52c41a' : '#ff4d4f' + color: budgetStatistics.totalBudget - budgetStatistics.totalActualCost >= 0 ? '#52c41a' : '#ff4d4f', + fontSize: '16px' }} + style={{ textAlign: 'center' }} /> From 49196aac2ec766811551e4f7ecf229021fef06d9 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Fri, 6 Jun 2025 16:18:31 +0530 Subject: [PATCH 095/105] feat(finance-drawer): enhance task summary and member breakdown display - Updated the FinanceDrawer component to include a detailed task summary section, displaying estimated and logged hours, labor costs, and fixed costs. - Improved the member breakdown table by adding columns for logged hours and actual costs, enhancing clarity and usability. - Adjusted the drawer width for better layout and user experience. --- .../finance/finance-drawer/finance-drawer.tsx | 117 ++++++++++++++++-- 1 file changed, 106 insertions(+), 11 deletions(-) diff --git a/worklenz-frontend/src/features/finance/finance-drawer/finance-drawer.tsx b/worklenz-frontend/src/features/finance/finance-drawer/finance-drawer.tsx index 395cc99cb..382c8c668 100644 --- a/worklenz-frontend/src/features/finance/finance-drawer/finance-drawer.tsx +++ b/worklenz-frontend/src/features/finance/finance-drawer/finance-drawer.tsx @@ -62,8 +62,8 @@ const FinanceDrawer = () => { } open={isDrawerOpen} onClose={handleClose} - destroyOnClose={true} - width={480} + destroyOnHidden={true} + width={640} >
{loading ? ( @@ -71,7 +71,71 @@ const FinanceDrawer = () => {
) : ( - + {/* Task Summary */} + {taskBreakdown?.task && ( +
+ + Task Overview + +
+
+ + Estimated Hours + + + {taskBreakdown.task.estimated_hours?.toFixed(2) || '0.00'} + +
+
+ + Total Logged Hours + + + {taskBreakdown.task.logged_hours?.toFixed(2) || '0.00'} + +
+
+ + Estimated Labor Cost ({currency}) + + + {taskBreakdown.task.estimated_labor_cost?.toFixed(2) || '0.00'} + +
+
+ + Actual Labor Cost ({currency}) + + + {taskBreakdown.task.actual_labor_cost?.toFixed(2) || '0.00'} + +
+
+ + Fixed Cost ({currency}) + + + {taskBreakdown.task.fixed_cost?.toFixed(2) || '0.00'} + +
+
+ + Total Actual Cost ({currency}) + + + {taskBreakdown.task.total_actual_cost?.toFixed(2) || '0.00'} + +
+
+
+ )} + + {/* Member Breakdown Table */} + + Member Time Logs & Costs + +
{ textAlign: 'left', padding: 8, }} - > + > + Role / Member + + @@ -129,22 +203,34 @@ const FinanceDrawer = () => { }} className="border-b-[1px] font-semibold" > - + + {/* Member Rows */} @@ -168,7 +254,15 @@ const FinanceDrawer = () => { padding: 8, }} > - {member.estimated_hours?.toFixed(2) || '0.00'} + {member.logged_hours?.toFixed(2) || '0.00'} + + ))} @@ -184,6 +278,7 @@ const FinanceDrawer = () => { ))}
+ Logged Hours + - {t('labourHoursColumn')} + Hourly Rate ({currency}) { padding: 8, }} > - {t('costColumn')} ({currency}) + Actual Cost ({currency})
{group.jobRole}{group.jobRole} + {group.logged_hours?.toFixed(2) || '0.00'} + - {group.estimated_hours?.toFixed(2) || '0.00'} + - - {group.estimated_cost?.toFixed(2) || '0.00'} + {group.actual_cost?.toFixed(2) || '0.00'}
+ {member.hourly_rate?.toFixed(2) || '0.00'} { padding: 8, }} > - {member.estimated_cost?.toFixed(2) || '0.00'} + {member.actual_cost?.toFixed(2) || '0.00'}
+ )}
From 509fcc8f64b052af551ad3ea84f7eabca3bb4741 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Mon, 9 Jun 2025 11:24:49 +0530 Subject: [PATCH 096/105] refactor(project-finance): improve task cost calculations and UI hierarchy - Updated SQL queries in the ProjectFinanceController to exclude parent tasks from descendant cost calculations, ensuring accurate financial data aggregation. - Refactored the project finance slice to implement recursive task updates for fixed costs, estimated costs, and time logged, enhancing task management efficiency. - Enhanced the FinanceTable component to visually represent task hierarchy with improved indentation and hover effects, improving user experience and clarity. - Added responsive styles for nested tasks and adjusted task name styling for better readability across different levels. --- .../controllers/project-finance-controller.ts | 20 +- .../projects/finance/project-finance.slice.ts | 168 ++++++++++++---- .../finance-table/finance-table.css | 64 +++++- .../finance-table/finance-table.tsx | 186 +++++++++++------- 4 files changed, 316 insertions(+), 122 deletions(-) diff --git a/worklenz-backend/src/controllers/project-finance-controller.ts b/worklenz-backend/src/controllers/project-finance-controller.ts index bd78153e8..a5bb5e39e 100644 --- a/worklenz-backend/src/controllers/project-finance-controller.ts +++ b/worklenz-backend/src/controllers/project-finance-controller.ts @@ -176,12 +176,12 @@ export default class ProjectfinanceController extends WorklenzControllerBase { tc.billable, tc.fixed_cost, tc.sub_tasks_count, - -- For parent tasks, sum values from all descendants including self + -- For parent tasks, sum values from descendants only (exclude parent task itself) CASE WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN ( SELECT SUM(sub_tc.estimated_seconds) FROM task_costs sub_tc - WHERE sub_tc.root_id = tc.id + WHERE sub_tc.root_id = tc.id AND sub_tc.id != tc.id ) ELSE tc.estimated_seconds END as estimated_seconds, @@ -189,7 +189,7 @@ export default class ProjectfinanceController extends WorklenzControllerBase { WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN ( SELECT SUM(sub_tc.total_time_logged_seconds) FROM task_costs sub_tc - WHERE sub_tc.root_id = tc.id + WHERE sub_tc.root_id = tc.id AND sub_tc.id != tc.id ) ELSE tc.total_time_logged_seconds END as total_time_logged_seconds, @@ -197,7 +197,7 @@ export default class ProjectfinanceController extends WorklenzControllerBase { WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN ( SELECT SUM(sub_tc.estimated_cost) FROM task_costs sub_tc - WHERE sub_tc.root_id = tc.id + WHERE sub_tc.root_id = tc.id AND sub_tc.id != tc.id ) ELSE tc.estimated_cost END as estimated_cost, @@ -205,7 +205,7 @@ export default class ProjectfinanceController extends WorklenzControllerBase { WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN ( SELECT SUM(sub_tc.actual_cost_from_logs) FROM task_costs sub_tc - WHERE sub_tc.root_id = tc.id + WHERE sub_tc.root_id = tc.id AND sub_tc.id != tc.id ) ELSE tc.actual_cost_from_logs END as actual_cost_from_logs @@ -860,12 +860,12 @@ export default class ProjectfinanceController extends WorklenzControllerBase { tc.billable, tc.fixed_cost, tc.sub_tasks_count, - -- For parent tasks, sum values from all descendants including self + -- For parent tasks, sum values from descendants only (exclude parent task itself) CASE WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN ( SELECT SUM(sub_tc.estimated_seconds) FROM task_costs sub_tc - WHERE sub_tc.root_id = tc.id + WHERE sub_tc.root_id = tc.id AND sub_tc.id != tc.id ) ELSE tc.estimated_seconds END as estimated_seconds, @@ -873,7 +873,7 @@ export default class ProjectfinanceController extends WorklenzControllerBase { WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN ( SELECT SUM(sub_tc.total_time_logged_seconds) FROM task_costs sub_tc - WHERE sub_tc.root_id = tc.id + WHERE sub_tc.root_id = tc.id AND sub_tc.id != tc.id ) ELSE tc.total_time_logged_seconds END as total_time_logged_seconds, @@ -881,7 +881,7 @@ export default class ProjectfinanceController extends WorklenzControllerBase { WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN ( SELECT SUM(sub_tc.estimated_cost) FROM task_costs sub_tc - WHERE sub_tc.root_id = tc.id + WHERE sub_tc.root_id = tc.id AND sub_tc.id != tc.id ) ELSE tc.estimated_cost END as estimated_cost, @@ -889,7 +889,7 @@ export default class ProjectfinanceController extends WorklenzControllerBase { WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN ( SELECT SUM(sub_tc.actual_cost_from_logs) FROM task_costs sub_tc - WHERE sub_tc.root_id = tc.id + WHERE sub_tc.root_id = tc.id AND sub_tc.id != tc.id ) ELSE tc.actual_cost_from_logs END as actual_cost_from_logs diff --git a/worklenz-frontend/src/features/projects/finance/project-finance.slice.ts b/worklenz-frontend/src/features/projects/finance/project-finance.slice.ts index c74a6d4bf..42581a935 100644 --- a/worklenz-frontend/src/features/projects/finance/project-finance.slice.ts +++ b/worklenz-frontend/src/features/projects/finance/project-finance.slice.ts @@ -122,53 +122,109 @@ export const projectFinancesSlice = createSlice({ updateTaskFixedCost: (state, action: PayloadAction<{ taskId: string; groupId: string; fixedCost: number }>) => { const { taskId, groupId, fixedCost } = action.payload; const group = state.taskGroups.find(g => g.group_id === groupId); + if (group) { - const task = group.tasks.find(t => t.id === taskId); - if (task) { - task.fixed_cost = fixedCost; - // Don't recalculate here - let the backend handle it and we'll refresh - } + // Recursive function to find and update a task in the hierarchy + const findAndUpdateTask = (tasks: IProjectFinanceTask[], targetId: string): boolean => { + for (const task of tasks) { + if (task.id === targetId) { + task.fixed_cost = fixedCost; + // Don't recalculate here - let the backend handle it and we'll refresh + return true; + } + + // Search in subtasks recursively + if (task.sub_tasks && findAndUpdateTask(task.sub_tasks, targetId)) { + return true; + } + } + return false; + }; + + findAndUpdateTask(group.tasks, taskId); } }, updateTaskEstimatedCost: (state, action: PayloadAction<{ taskId: string; groupId: string; estimatedCost: number }>) => { const { taskId, groupId, estimatedCost } = action.payload; const group = state.taskGroups.find(g => g.group_id === groupId); + if (group) { - const task = group.tasks.find(t => t.id === taskId); - if (task) { - task.estimated_cost = estimatedCost; - // Recalculate task costs after updating estimated cost - const { totalBudget, totalActual, variance } = calculateTaskCosts(task); - task.total_budget = totalBudget; - task.total_actual = totalActual; - task.variance = variance; - } + // Recursive function to find and update a task in the hierarchy + const findAndUpdateTask = (tasks: IProjectFinanceTask[], targetId: string): boolean => { + for (const task of tasks) { + if (task.id === targetId) { + task.estimated_cost = estimatedCost; + // Recalculate task costs after updating estimated cost + const { totalBudget, totalActual, variance } = calculateTaskCosts(task); + task.total_budget = totalBudget; + task.total_actual = totalActual; + task.variance = variance; + return true; + } + + // Search in subtasks recursively + if (task.sub_tasks && findAndUpdateTask(task.sub_tasks, targetId)) { + return true; + } + } + return false; + }; + + findAndUpdateTask(group.tasks, taskId); } }, updateTaskTimeLogged: (state, action: PayloadAction<{ taskId: string; groupId: string; timeLoggedSeconds: number; timeLoggedString: string }>) => { const { taskId, groupId, timeLoggedSeconds, timeLoggedString } = action.payload; const group = state.taskGroups.find(g => g.group_id === groupId); + if (group) { - const task = group.tasks.find(t => t.id === taskId); - if (task) { - task.total_time_logged_seconds = timeLoggedSeconds; - task.total_time_logged = timeLoggedString; - // Recalculate task costs after updating time logged - const { totalBudget, totalActual, variance } = calculateTaskCosts(task); - task.total_budget = totalBudget; - task.total_actual = totalActual; - task.variance = variance; - } + // Recursive function to find and update a task in the hierarchy + const findAndUpdateTask = (tasks: IProjectFinanceTask[], targetId: string): boolean => { + for (const task of tasks) { + if (task.id === targetId) { + task.total_time_logged_seconds = timeLoggedSeconds; + task.total_time_logged = timeLoggedString; + // Recalculate task costs after updating time logged + const { totalBudget, totalActual, variance } = calculateTaskCosts(task); + task.total_budget = totalBudget; + task.total_actual = totalActual; + task.variance = variance; + return true; + } + + // Search in subtasks recursively + if (task.sub_tasks && findAndUpdateTask(task.sub_tasks, targetId)) { + return true; + } + } + return false; + }; + + findAndUpdateTask(group.tasks, taskId); } }, toggleTaskExpansion: (state, action: PayloadAction<{ taskId: string; groupId: string }>) => { const { taskId, groupId } = 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.show_sub_tasks = !task.show_sub_tasks; - } + // Recursive function to find and toggle a task in the hierarchy + const findAndToggleTask = (tasks: IProjectFinanceTask[], targetId: string): boolean => { + for (const task of tasks) { + if (task.id === targetId) { + task.show_sub_tasks = !task.show_sub_tasks; + return true; + } + + // Search in subtasks recursively + if (task.sub_tasks && findAndToggleTask(task.sub_tasks, targetId)) { + return true; + } + } + return false; + }; + + findAndToggleTask(group.tasks, taskId); } }, updateProjectFinanceCurrency: (state, action: PayloadAction) => { @@ -200,26 +256,56 @@ export const projectFinancesSlice = createSlice({ .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; - // Don't recalculate here - trigger a refresh instead for accuracy - } + // Recursive function to find and update a task in the hierarchy + const findAndUpdateTask = (tasks: IProjectFinanceTask[], targetId: string): boolean => { + for (const task of tasks) { + if (task.id === targetId) { + task.fixed_cost = fixedCost; + // Don't recalculate here - trigger a refresh instead for accuracy + return true; + } + + // Search in subtasks recursively + if (task.sub_tasks && findAndUpdateTask(task.sub_tasks, targetId)) { + return true; + } + } + return false; + }; + + findAndUpdateTask(group.tasks, taskId); } }) .addCase(fetchSubTasks.fulfilled, (state, action) => { const { parentTaskId, subTasks } = action.payload; + + // Recursive function to find and update a task in the hierarchy + const findAndUpdateTask = (tasks: IProjectFinanceTask[], targetId: string): boolean => { + for (const task of tasks) { + if (task.id === targetId) { + // Found the parent task, add subtasks + task.sub_tasks = subTasks.map(subTask => ({ + ...subTask, + is_sub_task: true, + parent_task_id: targetId + })); + task.show_sub_tasks = true; + return true; + } + + // Search in subtasks recursively + if (task.sub_tasks && findAndUpdateTask(task.sub_tasks, targetId)) { + return true; + } + } + return false; + }; + // Find the parent task in any group and add the subtasks for (const group of state.taskGroups) { - const parentTask = group.tasks.find(t => t.id === parentTaskId); - if (parentTask) { - parentTask.sub_tasks = subTasks.map(subTask => ({ - ...subTask, - is_sub_task: true, - parent_task_id: parentTaskId - })); - parentTask.show_sub_tasks = true; + if (findAndUpdateTask(group.tasks, parentTaskId)) { break; } } diff --git a/worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-table/finance-table.css b/worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-table/finance-table.css index 093890582..e6f6b5442 100644 --- a/worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-table/finance-table.css +++ b/worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-table/finance-table.css @@ -1 +1,63 @@ -/* Finance Table Styles */ \ No newline at end of file +/* Finance Table Styles */ + +/* Enhanced hierarchy visual indicators */ +.finance-table-task-row { + transition: all 0.2s ease-in-out; + border-bottom: 1px solid rgba(0, 0, 0, 0.06); +} + +.dark .finance-table-task-row { + border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} + +/* Hover effect is now handled by inline styles in the component for consistency */ + +/* Nested task styling */ +.finance-table-nested-task { + /* No visual connectors, just clean indentation */ +} + +/* Expand/collapse button styling */ +.finance-table-expand-btn { + transition: all 0.2s ease-in-out; + border-radius: 2px; + padding: 2px; +} + +.finance-table-expand-btn:hover { + background: rgba(0, 0, 0, 0.05); + transform: scale(1.1); +} + +.dark .finance-table-expand-btn:hover { + background: rgba(255, 255, 255, 0.05); +} + +/* Task name styling for different levels */ +.finance-table-task-name { + transition: all 0.2s ease-in-out; +} + +.finance-table-task-name:hover { + color: #40a9ff !important; +} + +/* Fixed cost input styling */ +.fixed-cost-input { + border-radius: 4px; +} + +.fixed-cost-input:focus { + box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2); +} + +/* Responsive adjustments for nested content */ +@media (max-width: 768px) { + .finance-table-nested-task { + padding-left: 12px; + } + + .finance-table-task-name { + font-size: 12px !important; + } +} \ No newline at end of file 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 index 8c8b74b64..968956a05 100644 --- 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 @@ -1,5 +1,5 @@ import { Flex, InputNumber, Skeleton, Tooltip, Typography } from 'antd'; -import { useEffect, useMemo, useState, useRef } from 'react'; +import React, { useEffect, useMemo, useState, useRef } from 'react'; import { useAppSelector } from '@/hooks/useAppSelector'; import { DollarCircleOutlined, @@ -24,6 +24,8 @@ import { useParams } from 'react-router-dom'; import { useAuthService } from '@/hooks/useAuth'; import { canEditFixedCost } from '@/utils/finance-permissions'; import './finance-table.css'; +import { fetchPhasesByProjectId } from '@/features/projects/singleProject/phase/phases.slice'; +import { fetchPriorities } from '@/features/taskAttributes/taskPrioritySlice'; type FinanceTableProps = { table: IProjectFinanceGroup; @@ -41,6 +43,7 @@ const FinanceTable = ({ const [selectedTask, setSelectedTask] = useState(null); const [editingFixedCostValue, setEditingFixedCostValue] = useState(null); const [tasks, setTasks] = useState(table.tasks); + const [hoveredTaskId, setHoveredTaskId] = useState(null); const saveTimeoutRef = useRef(null); const dispatch = useAppDispatch(); @@ -159,8 +162,10 @@ const FinanceTable = ({ if (!taskId || !projectId) return; dispatch(setSelectedTaskId(taskId)); - dispatch(setShowTaskDrawer(true)); + dispatch(fetchPhasesByProjectId(projectId)); + dispatch(fetchPriorities()); dispatch(fetchTask({ taskId, projectId })); + dispatch(setShowTaskDrawer(true)); }; // Handle task expansion/collapse @@ -208,35 +213,112 @@ const FinanceTable = ({ setEditingFixedCostValue(null); }; - const renderFinancialTableColumnContent = (columnKey: FinanceTableColumnKeys, task: IProjectFinanceTask) => { + // Calculate indentation based on nesting level + const getTaskIndentation = (level: number) => level * 32; // 32px per level for better visibility + + // Recursive function to render task hierarchy + const renderTaskHierarchy = (task: IProjectFinanceTask, level: number = 0): React.ReactElement[] => { + const elements: React.ReactElement[] = []; + + // Add the current task + const isHovered = hoveredTaskId === task.id; + const rowIndex = elements.length; + const defaultBg = rowIndex % 2 === 0 ? themeWiseColor('#fafafa', '#232323', themeMode) : themeWiseColor('#ffffff', '#181818', themeMode); + const hoverBg = themeMode === 'dark' ? 'rgba(64, 169, 255, 0.08)' : 'rgba(24, 144, 255, 0.04)'; + + elements.push( + 0 ? 'finance-table-nested-task' : ''} ${themeMode === 'dark' ? 'dark' : ''}`} + onMouseEnter={() => setHoveredTaskId(task.id)} + onMouseLeave={() => setHoveredTaskId(null)} + > + {financeTableColumns.map((col) => ( + e.stopPropagation() + : undefined + } + > + {renderFinancialTableColumnContent(col.key, task, level)} + + ))} + + ); + + // Add subtasks recursively if they are expanded and loaded + if (task.show_sub_tasks && task.sub_tasks) { + task.sub_tasks.forEach(subTask => { + elements.push(...renderTaskHierarchy(subTask, level + 1)); + }); + } + + return elements; + }; + + const renderFinancialTableColumnContent = (columnKey: FinanceTableColumnKeys, task: IProjectFinanceTask, level: number = 0) => { switch (columnKey) { case FinanceTableColumnKeys.TASK: return ( - - {/* Indentation for subtasks */} - {task.is_sub_task &&
} + {/* Expand/collapse icon for parent tasks */} {task.sub_tasks_count > 0 && (
{ e.stopPropagation(); handleTaskExpansion(task); }} > - {task.show_sub_tasks ? : } + {task.show_sub_tasks ? : }
)} + {/* Spacer for tasks without subtasks to align with those that have expand icons */} + {task.sub_tasks_count === 0 && level > 0 && ( +
+ )} + {/* Task name */} 0 ? 140 : 160), + maxWidth: Math.max(100, 200 - getTaskIndentation(level) - (task.sub_tasks_count > 0 ? 26 : 18)), cursor: 'pointer', - color: '#1890ff' + color: '#1890ff', + fontSize: Math.max(12, 14 - level * 0.3), // Slightly smaller font for deeper levels + opacity: Math.max(0.85, 1 - level * 0.03), // Slightly faded for deeper levels + fontWeight: level > 0 ? 400 : 500 // Slightly lighter weight for nested tasks }} onClick={(e) => { e.stopPropagation(); @@ -251,7 +333,7 @@ const FinanceTable = ({ > {task.name} - {task.billable && } + {task.billable && } ); @@ -277,11 +359,11 @@ const FinanceTable = ({
); case FinanceTableColumnKeys.HOURS: - return {task.estimated_hours}; + return {task.estimated_hours}; case FinanceTableColumnKeys.TOTAL_TIME_LOGGED: - return {task.total_time_logged}; + return {task.total_time_logged}; case FinanceTableColumnKeys.ESTIMATED_COST: - return {formatNumber(task.estimated_cost)}; + return {formatNumber(task.estimated_cost)}; case FinanceTableColumnKeys.FIXED_COST: return selectedTask?.id === task.id && hasEditPermission ? ( `${value}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',')} parser={(value) => Number(value!.replace(/\$\s?|(,*)/g, ''))} min={0} @@ -313,7 +395,8 @@ const FinanceTable = ({ cursor: hasEditPermission ? 'pointer' : 'default', width: '100%', display: 'block', - opacity: hasEditPermission ? 1 : 0.7 + opacity: hasEditPermission ? 1 : 0.7, + fontSize: Math.max(12, 14 - level * 0.5) }} onClick={hasEditPermission ? (e) => { e.stopPropagation(); @@ -328,7 +411,8 @@ const FinanceTable = ({ return ( 0 ? '#FF0000' : '#6DC376' + color: task.variance > 0 ? '#FF0000' : '#6DC376', + fontSize: Math.max(12, 14 - level * 0.5) }} > {task.variance < 0 ? '+' + formatNumber(Math.abs(task.variance)) : @@ -337,11 +421,11 @@ const FinanceTable = ({ ); case FinanceTableColumnKeys.TOTAL_BUDGET: - return {formatNumber(task.total_budget)}; + return {formatNumber(task.total_budget)}; case FinanceTableColumnKeys.TOTAL_ACTUAL: - return {formatNumber(task.total_actual)}; + return {formatNumber(task.total_actual)}; case FinanceTableColumnKeys.COST: - return {formatNumber((task.total_actual || 0) - (task.fixed_cost || 0))}; + return {formatNumber((task.total_actual || 0) - (task.fixed_cost || 0))}; default: return null; } @@ -363,32 +447,29 @@ const FinanceTable = ({ return parts.join(' '); }; - // Flatten tasks to include subtasks for rendering + // Generate flattened task list with all nested levels const flattenedTasks = useMemo(() => { - const flattened: IProjectFinanceTask[] = []; + const flattened: React.ReactElement[] = []; tasks.forEach(task => { - // Add the parent task - flattened.push(task); - - // Add subtasks if they are expanded and loaded - if (task.show_sub_tasks && task.sub_tasks) { - task.sub_tasks.forEach(subTask => { - flattened.push(subTask); - }); - } + flattened.push(...renderTaskHierarchy(task, 0)); }); return flattened; - }, [tasks]); + }, [tasks, selectedTask, editingFixedCostValue, hasEditPermission, themeMode, hoveredTaskId]); - // Calculate totals for the current table (only count parent tasks to avoid double counting) + // Calculate totals for the current table + // Since the backend already aggregates subtask values into parent tasks, + // we only need to sum the parent tasks (tasks without is_sub_task flag) const totals = useMemo(() => { return tasks.reduce( (acc, task) => { // Calculate actual cost from logs (total_actual - fixed_cost) const actualCostFromLogs = (task.total_actual || 0) - (task.fixed_cost || 0); + // The backend already handles aggregation for parent tasks with subtasks + // Parent tasks contain the sum of their subtasks' values + // So we can safely sum all parent tasks (which are the tasks in this array) return { hours: acc.hours + (task.estimated_seconds || 0), total_time_logged: acc.total_time_logged + (task.total_time_logged_seconds || 0), @@ -480,43 +561,8 @@ const FinanceTable = ({ )} - {/* task rows */} - {!isCollapse && flattenedTasks.map((task, idx) => ( - e.currentTarget.style.background = themeWiseColor('#f0f0f0', '#333', themeMode)} - onMouseLeave={e => e.currentTarget.style.background = idx % 2 === 0 ? themeWiseColor('#fafafa', '#232323', themeMode) : themeWiseColor('#ffffff', '#181818', themeMode)} - > - {financeTableColumns.map((col) => ( - e.stopPropagation() - : undefined - } - > - {renderFinancialTableColumnContent(col.key, task)} - - ))} - - ))} + {/* task rows with recursive hierarchy */} + {!isCollapse && flattenedTasks} ); }; From 6e188899edef79cf55d118d5fb9798a2d91474fc Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Mon, 9 Jun 2025 12:33:23 +0530 Subject: [PATCH 097/105] feat(task-hierarchy): implement recursive task estimation and reset functionality - Added SQL scripts to fix task hierarchy and reset parent task estimations to zero, ensuring accurate estimation calculations. - Introduced a migration for a recursive task estimation function that aggregates estimations from subtasks, enhancing task management. - Updated controllers to utilize recursive estimations for displaying task data, improving accuracy in task progress representation. - Implemented a new API route to reset parent task estimations, allowing for better task management and data integrity. --- ...15000000-add-recursive-task-estimation.sql | 228 ++++++++++++++++++ ...20250423000000-subtask-manual-progress.sql | 133 +++++++++- worklenz-backend/fix-task-hierarchy.sql | 77 ++++++ .../reset-existing-parent-estimations.sql | 29 +++ .../controllers/project-finance-controller.ts | 104 ++++++-- .../src/controllers/tasks-controller-base.ts | 43 +++- .../src/controllers/tasks-controller-v2.ts | 1 + .../src/controllers/tasks-controller.ts | 33 ++- .../src/routes/apis/tasks-api-router.ts | 3 + .../shared/info-tab/task-details-form.tsx | 11 +- .../finance/finance-drawer/finance-drawer.tsx | 8 +- .../import-ratecards-drawer.tsx | 7 +- 12 files changed, 636 insertions(+), 41 deletions(-) create mode 100644 worklenz-backend/database/migrations/20250115000000-add-recursive-task-estimation.sql create mode 100644 worklenz-backend/fix-task-hierarchy.sql create mode 100644 worklenz-backend/reset-existing-parent-estimations.sql diff --git a/worklenz-backend/database/migrations/20250115000000-add-recursive-task-estimation.sql b/worklenz-backend/database/migrations/20250115000000-add-recursive-task-estimation.sql new file mode 100644 index 000000000..1243cdf26 --- /dev/null +++ b/worklenz-backend/database/migrations/20250115000000-add-recursive-task-estimation.sql @@ -0,0 +1,228 @@ +-- Migration: Add recursive task estimation functionality +-- This migration adds a function to calculate recursive task estimation including all subtasks +-- and modifies the get_task_form_view_model function to include this data + +BEGIN; + +-- Function to calculate recursive task estimation (including all subtasks) +CREATE OR REPLACE FUNCTION get_task_recursive_estimation(_task_id UUID) RETURNS JSON + LANGUAGE plpgsql +AS +$$ +DECLARE + _result JSON; + _has_subtasks BOOLEAN; +BEGIN + -- First check if this task has any subtasks + SELECT EXISTS( + SELECT 1 FROM tasks + WHERE parent_task_id = _task_id + AND archived = false + ) INTO _has_subtasks; + + -- If task has subtasks, calculate recursive estimation excluding parent's own estimation + IF _has_subtasks THEN + WITH RECURSIVE task_tree AS ( + -- Start with direct subtasks only (exclude the parent task itself) + SELECT + id, + parent_task_id, + COALESCE(total_minutes, 0) as total_minutes, + 1 as level -- Start at level 1 (subtasks) + FROM tasks + WHERE parent_task_id = _task_id + AND archived = false + + UNION ALL + + -- Recursive case: Get all descendant tasks + SELECT + t.id, + t.parent_task_id, + COALESCE(t.total_minutes, 0) as total_minutes, + tt.level + 1 as level + FROM tasks t + INNER JOIN task_tree tt ON t.parent_task_id = tt.id + WHERE t.archived = false + ), + task_counts AS ( + SELECT + COUNT(*) as sub_tasks_count, + SUM(total_minutes) as subtasks_total_minutes -- Sum all subtask estimations + FROM task_tree + ) + SELECT JSON_BUILD_OBJECT( + 'sub_tasks_count', COALESCE(tc.sub_tasks_count, 0), + 'own_total_minutes', 0, -- Always 0 for parent tasks + 'subtasks_total_minutes', COALESCE(tc.subtasks_total_minutes, 0), + 'recursive_total_minutes', COALESCE(tc.subtasks_total_minutes, 0), -- Only subtasks total + 'recursive_total_hours', FLOOR(COALESCE(tc.subtasks_total_minutes, 0) / 60), + 'recursive_remaining_minutes', COALESCE(tc.subtasks_total_minutes, 0) % 60 + ) + INTO _result + FROM task_counts tc; + ELSE + -- If task has no subtasks, use its own estimation + SELECT JSON_BUILD_OBJECT( + 'sub_tasks_count', 0, + 'own_total_minutes', COALESCE(total_minutes, 0), + 'subtasks_total_minutes', 0, + 'recursive_total_minutes', COALESCE(total_minutes, 0), -- Use own estimation + 'recursive_total_hours', FLOOR(COALESCE(total_minutes, 0) / 60), + 'recursive_remaining_minutes', COALESCE(total_minutes, 0) % 60 + ) + INTO _result + FROM tasks + WHERE id = _task_id; + END IF; + + RETURN COALESCE(_result, JSON_BUILD_OBJECT( + 'sub_tasks_count', 0, + 'own_total_minutes', 0, + 'subtasks_total_minutes', 0, + 'recursive_total_minutes', 0, + 'recursive_total_hours', 0, + 'recursive_remaining_minutes', 0 + )); +END; +$$; + +-- Update the get_task_form_view_model function to include recursive estimation +CREATE OR REPLACE FUNCTION public.get_task_form_view_model(_user_id UUID, _team_id UUID, _task_id UUID, _project_id UUID) RETURNS JSON + LANGUAGE plpgsql +AS +$$ +DECLARE + _task JSON; + _priorities JSON; + _projects JSON; + _statuses JSON; + _team_members JSON; + _assignees JSON; + _phases JSON; +BEGIN + + -- Select task info + SELECT COALESCE(ROW_TO_JSON(rec), '{}'::JSON) + INTO _task + FROM (WITH RECURSIVE task_hierarchy AS ( + -- Base case: Start with the given task + SELECT id, + parent_task_id, + 0 AS level + FROM tasks + WHERE id = _task_id + + UNION ALL + + -- Recursive case: Traverse up to parent tasks + SELECT t.id, + t.parent_task_id, + th.level + 1 AS level + FROM tasks t + INNER JOIN task_hierarchy th ON t.id = th.parent_task_id + WHERE th.parent_task_id IS NOT NULL) + SELECT id, + name, + description, + start_date, + end_date, + done, + total_minutes, + priority_id, + project_id, + created_at, + updated_at, + status_id, + parent_task_id, + sort_order, + (SELECT phase_id FROM task_phase WHERE task_id = tasks.id) AS phase_id, + CONCAT((SELECT key FROM projects WHERE id = tasks.project_id), '-', task_no) AS task_key, + (SELECT start_time + FROM task_timers + WHERE task_id = tasks.id + AND user_id = _user_id) AS timer_start_time, + parent_task_id IS NOT NULL AS is_sub_task, + (SELECT COUNT('*') + FROM tasks + WHERE parent_task_id = tasks.id + AND archived IS FALSE) AS sub_tasks_count, + (SELECT COUNT(*) + FROM tasks_with_status_view tt + WHERE (tt.parent_task_id = tasks.id OR tt.task_id = tasks.id) + AND tt.is_done IS TRUE) + AS completed_count, + (SELECT COUNT(*) FROM task_attachments WHERE task_id = tasks.id) AS attachments_count, + (SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(r))), '[]'::JSON) + FROM (SELECT task_labels.label_id AS id, + (SELECT name FROM team_labels WHERE id = task_labels.label_id), + (SELECT color_code FROM team_labels WHERE id = task_labels.label_id) + FROM task_labels + WHERE task_id = tasks.id + ORDER BY name) r) AS labels, + (SELECT color_code + FROM sys_task_status_categories + WHERE id = (SELECT category_id FROM task_statuses WHERE id = tasks.status_id)) AS status_color, + (SELECT COUNT(*) FROM tasks WHERE parent_task_id = _task_id) AS sub_tasks_count, + (SELECT name FROM users WHERE id = tasks.reporter_id) AS reporter, + (SELECT get_task_assignees(tasks.id)) AS assignees, + (SELECT id FROM team_members WHERE user_id = _user_id AND team_id = _team_id) AS team_member_id, + billable, + schedule_id, + progress_value, + weight, + (SELECT MAX(level) FROM task_hierarchy) AS task_level, + (SELECT get_task_recursive_estimation(tasks.id)) AS recursive_estimation + FROM tasks + WHERE id = _task_id) rec; + + SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON) + INTO _priorities + FROM (SELECT id, name FROM task_priorities ORDER BY value) rec; + + SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON) + INTO _phases + FROM (SELECT id, name FROM project_phases WHERE project_id = _project_id ORDER BY name) rec; + + SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON) + INTO _projects + FROM (SELECT id, name + FROM projects + WHERE team_id = _team_id + AND (CASE + WHEN (is_owner(_user_id, _team_id) OR is_admin(_user_id, _team_id) IS TRUE) THEN TRUE + ELSE is_member_of_project(projects.id, _user_id, _team_id) END) + ORDER BY name) rec; + + SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON) + INTO _statuses + FROM (SELECT id, name FROM task_statuses WHERE project_id = _project_id) rec; + + SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON) + INTO _team_members + FROM (SELECT team_members.id, + (SELECT name FROM team_member_info_view WHERE team_member_info_view.team_member_id = team_members.id), + (SELECT email FROM team_member_info_view WHERE team_member_info_view.team_member_id = team_members.id), + (SELECT avatar_url + FROM team_member_info_view + WHERE team_member_info_view.team_member_id = team_members.id) + FROM team_members + LEFT JOIN users u ON team_members.user_id = u.id + WHERE team_id = _team_id + AND team_members.active IS TRUE) rec; + + SELECT get_task_assignees(_task_id) INTO _assignees; + + RETURN JSON_BUILD_OBJECT( + 'task', _task, + 'priorities', _priorities, + 'projects', _projects, + 'statuses', _statuses, + 'team_members', _team_members, + 'assignees', _assignees, + 'phases', _phases + ); +END; +$$; + +COMMIT; \ No newline at end of file diff --git a/worklenz-backend/database/migrations/20250423000000-subtask-manual-progress.sql b/worklenz-backend/database/migrations/20250423000000-subtask-manual-progress.sql index b4650dc76..4bd9d7bea 100644 --- a/worklenz-backend/database/migrations/20250423000000-subtask-manual-progress.sql +++ b/worklenz-backend/database/migrations/20250423000000-subtask-manual-progress.sql @@ -603,7 +603,8 @@ BEGIN schedule_id, progress_value, weight, - (SELECT MAX(level) FROM task_hierarchy) AS task_level + (SELECT MAX(level) FROM task_hierarchy) AS task_level, + (SELECT get_task_recursive_estimation(tasks.id)) AS recursive_estimation FROM tasks WHERE id = _task_id) rec; @@ -662,6 +663,89 @@ ADD COLUMN IF NOT EXISTS use_manual_progress BOOLEAN DEFAULT FALSE, ADD COLUMN IF NOT EXISTS use_weighted_progress BOOLEAN DEFAULT FALSE, ADD COLUMN IF NOT EXISTS use_time_progress BOOLEAN DEFAULT FALSE; +-- Function to calculate recursive task estimation (including all subtasks) +CREATE OR REPLACE FUNCTION get_task_recursive_estimation(_task_id UUID) RETURNS JSON + LANGUAGE plpgsql +AS +$$ +DECLARE + _result JSON; + _has_subtasks BOOLEAN; +BEGIN + -- First check if this task has any subtasks + SELECT EXISTS( + SELECT 1 FROM tasks + WHERE parent_task_id = _task_id + AND archived = false + ) INTO _has_subtasks; + + -- If task has subtasks, calculate recursive estimation excluding parent's own estimation + IF _has_subtasks THEN + WITH RECURSIVE task_tree AS ( + -- Start with direct subtasks only (exclude the parent task itself) + SELECT + id, + parent_task_id, + COALESCE(total_minutes, 0) as total_minutes, + 1 as level -- Start at level 1 (subtasks) + FROM tasks + WHERE parent_task_id = _task_id + AND archived = false + + UNION ALL + + -- Recursive case: Get all descendant tasks + SELECT + t.id, + t.parent_task_id, + COALESCE(t.total_minutes, 0) as total_minutes, + tt.level + 1 as level + FROM tasks t + INNER JOIN task_tree tt ON t.parent_task_id = tt.id + WHERE t.archived = false + ), + task_counts AS ( + SELECT + COUNT(*) as sub_tasks_count, + SUM(total_minutes) as subtasks_total_minutes -- Sum all subtask estimations + FROM task_tree + ) + SELECT JSON_BUILD_OBJECT( + 'sub_tasks_count', COALESCE(tc.sub_tasks_count, 0), + 'own_total_minutes', 0, -- Always 0 for parent tasks + 'subtasks_total_minutes', COALESCE(tc.subtasks_total_minutes, 0), + 'recursive_total_minutes', COALESCE(tc.subtasks_total_minutes, 0), -- Only subtasks total + 'recursive_total_hours', FLOOR(COALESCE(tc.subtasks_total_minutes, 0) / 60), + 'recursive_remaining_minutes', COALESCE(tc.subtasks_total_minutes, 0) % 60 + ) + INTO _result + FROM task_counts tc; + ELSE + -- If task has no subtasks, use its own estimation + SELECT JSON_BUILD_OBJECT( + 'sub_tasks_count', 0, + 'own_total_minutes', COALESCE(total_minutes, 0), + 'subtasks_total_minutes', 0, + 'recursive_total_minutes', COALESCE(total_minutes, 0), -- Use own estimation + 'recursive_total_hours', FLOOR(COALESCE(total_minutes, 0) / 60), + 'recursive_remaining_minutes', COALESCE(total_minutes, 0) % 60 + ) + INTO _result + FROM tasks + WHERE id = _task_id; + END IF; + + RETURN COALESCE(_result, JSON_BUILD_OBJECT( + 'sub_tasks_count', 0, + 'own_total_minutes', 0, + 'subtasks_total_minutes', 0, + 'recursive_total_minutes', 0, + 'recursive_total_hours', 0, + 'recursive_remaining_minutes', 0 + )); +END; +$$; + -- Add a trigger to reset manual progress when a task gets a new subtask CREATE OR REPLACE FUNCTION reset_parent_task_manual_progress() RETURNS TRIGGER AS $$ @@ -677,6 +761,22 @@ BEGIN END; $$ LANGUAGE plpgsql; +-- Add a trigger to reset parent task estimation when it gets subtasks +CREATE OR REPLACE FUNCTION reset_parent_task_estimation() RETURNS TRIGGER AS +$$ +BEGIN + -- When a task gets a new subtask (parent_task_id is set), reset the parent's total_minutes to 0 + -- This ensures parent tasks don't have their own estimation when they have subtasks + IF NEW.parent_task_id IS NOT NULL THEN + UPDATE tasks + SET total_minutes = 0 + WHERE id = NEW.parent_task_id + AND total_minutes > 0; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + -- Create the trigger on the tasks table DROP TRIGGER IF EXISTS reset_parent_manual_progress_trigger ON tasks; CREATE TRIGGER reset_parent_manual_progress_trigger @@ -684,4 +784,35 @@ AFTER INSERT OR UPDATE OF parent_task_id ON tasks FOR EACH ROW EXECUTE FUNCTION reset_parent_task_manual_progress(); +-- Create the trigger to reset parent task estimation +DROP TRIGGER IF EXISTS reset_parent_estimation_trigger ON tasks; +CREATE TRIGGER reset_parent_estimation_trigger +AFTER INSERT OR UPDATE OF parent_task_id ON tasks +FOR EACH ROW +EXECUTE FUNCTION reset_parent_task_estimation(); + +-- Function to reset all existing parent tasks' estimations to 0 +CREATE OR REPLACE FUNCTION reset_all_parent_task_estimations() RETURNS INTEGER AS +$$ +DECLARE + _updated_count INTEGER; +BEGIN + -- Update all tasks that have subtasks to have 0 estimation + UPDATE tasks + SET total_minutes = 0 + WHERE id IN ( + SELECT DISTINCT parent_task_id + FROM tasks + WHERE parent_task_id IS NOT NULL + AND archived = false + ) + AND total_minutes > 0 + AND archived = false; + + GET DIAGNOSTICS _updated_count = ROW_COUNT; + + RETURN _updated_count; +END; +$$ LANGUAGE plpgsql; + COMMIT; \ No newline at end of file diff --git a/worklenz-backend/fix-task-hierarchy.sql b/worklenz-backend/fix-task-hierarchy.sql new file mode 100644 index 000000000..602feb378 --- /dev/null +++ b/worklenz-backend/fix-task-hierarchy.sql @@ -0,0 +1,77 @@ +-- Fix task hierarchy and reset parent estimations +-- This script ensures proper parent-child relationships and resets parent estimations + +-- First, let's see the current task hierarchy +SELECT + t.id, + t.name, + t.parent_task_id, + t.total_minutes, + (SELECT name FROM tasks WHERE id = t.parent_task_id) as parent_name, + (SELECT COUNT(*) FROM tasks WHERE parent_task_id = t.id AND archived = false) as actual_subtask_count, + t.archived +FROM tasks t +WHERE (t.name LIKE '%sub%' OR t.name LIKE '%test task%') +ORDER BY t.name, t.created_at; + +-- Reset all parent task estimations to 0 +-- This ensures parent tasks don't have their own estimation when they have subtasks +UPDATE tasks +SET total_minutes = 0 +WHERE id IN ( + SELECT DISTINCT parent_task_id + FROM tasks + WHERE parent_task_id IS NOT NULL + AND archived = false +) +AND archived = false; + +-- Verify the results after the update +SELECT + t.id, + t.name, + t.parent_task_id, + t.total_minutes as current_estimation, + (SELECT name FROM tasks WHERE id = t.parent_task_id) as parent_name, + (SELECT COUNT(*) FROM tasks WHERE parent_task_id = t.id AND archived = false) as subtask_count, + get_task_recursive_estimation(t.id) as recursive_estimation +FROM tasks t +WHERE (t.name LIKE '%sub%' OR t.name LIKE '%test task%') +AND t.archived = false +ORDER BY t.name; + +-- Show the hierarchy in tree format +WITH RECURSIVE task_hierarchy AS ( + -- Top level tasks (no parent) + SELECT + id, + name, + parent_task_id, + total_minutes, + 0 as level, + name as path + FROM tasks + WHERE parent_task_id IS NULL + AND (name LIKE '%sub%' OR name LIKE '%test task%') + AND archived = false + + UNION ALL + + -- Child tasks + SELECT + t.id, + t.name, + t.parent_task_id, + t.total_minutes, + th.level + 1, + th.path || ' > ' || t.name + FROM tasks t + INNER JOIN task_hierarchy th ON t.parent_task_id = th.id + WHERE t.archived = false +) +SELECT + REPEAT(' ', level) || name as indented_name, + total_minutes, + get_task_recursive_estimation(id) as recursive_estimation +FROM task_hierarchy +ORDER BY path; \ No newline at end of file diff --git a/worklenz-backend/reset-existing-parent-estimations.sql b/worklenz-backend/reset-existing-parent-estimations.sql new file mode 100644 index 000000000..1e82acc67 --- /dev/null +++ b/worklenz-backend/reset-existing-parent-estimations.sql @@ -0,0 +1,29 @@ +-- Reset all existing parent task estimations to 0 +-- This script updates all tasks that have subtasks to have 0 estimation + +UPDATE tasks +SET total_minutes = 0 +WHERE id IN ( + SELECT DISTINCT parent_task_id + FROM tasks + WHERE parent_task_id IS NOT NULL + AND archived = false +) +AND total_minutes > 0 +AND archived = false; + +-- Show the results +SELECT + t.id, + t.name, + t.total_minutes as current_estimation, + (SELECT COUNT(*) FROM tasks WHERE parent_task_id = t.id AND archived = false) as subtask_count +FROM tasks t +WHERE id IN ( + SELECT DISTINCT parent_task_id + FROM tasks + WHERE parent_task_id IS NOT NULL + AND archived = false +) +AND archived = false +ORDER BY t.name; \ No newline at end of file diff --git a/worklenz-backend/src/controllers/project-finance-controller.ts b/worklenz-backend/src/controllers/project-finance-controller.ts index a5bb5e39e..773b5ae72 100644 --- a/worklenz-backend/src/controllers/project-finance-controller.ts +++ b/worklenz-backend/src/controllers/project-finance-controller.ts @@ -605,9 +605,10 @@ export default class ProjectfinanceController extends WorklenzControllerBase { billableCondition = "AND t.billable = false"; } - // Get subtasks with their financial data + // Get subtasks with their financial data, including recursive aggregation for sub-subtasks const q = ` - WITH task_costs AS ( + WITH RECURSIVE task_tree AS ( + -- Get the requested subtasks SELECT t.id, t.name, @@ -621,22 +622,47 @@ export default class ProjectfinanceController extends WorklenzControllerBase { COALESCE(t.fixed_cost, 0) as fixed_cost, COALESCE(t.total_minutes * 60, 0) as estimated_seconds, COALESCE((SELECT SUM(time_spent) FROM task_work_log WHERE task_id = t.id), 0) as total_time_logged_seconds, - (SELECT COUNT(*) FROM tasks WHERE parent_task_id = t.id AND archived = false) as sub_tasks_count + (SELECT COUNT(*) FROM tasks WHERE parent_task_id = t.id AND archived = false) as sub_tasks_count, + 0 as level, + t.id as root_id FROM tasks t WHERE t.project_id = $1 AND t.archived = false AND t.parent_task_id = $2 ${billableCondition} + + UNION ALL + + -- Get all descendant tasks for aggregation + SELECT + t.id, + t.name, + t.parent_task_id, + t.project_id, + t.status_id, + t.priority_id, + (SELECT phase_id FROM task_phase WHERE task_id = t.id) as phase_id, + (SELECT get_task_assignees(t.id)) as assignees, + t.billable, + COALESCE(t.fixed_cost, 0) as fixed_cost, + COALESCE(t.total_minutes * 60, 0) as estimated_seconds, + COALESCE((SELECT SUM(time_spent) FROM task_work_log WHERE task_id = t.id), 0) as total_time_logged_seconds, + 0 as sub_tasks_count, + tt.level + 1 as level, + tt.root_id + FROM tasks t + INNER JOIN task_tree tt ON t.parent_task_id = tt.id + WHERE t.archived = false ), - task_estimated_costs AS ( + task_costs AS ( SELECT - tc.*, + tt.*, -- Calculate estimated cost based on estimated hours and assignee rates COALESCE(( - SELECT SUM((tc.estimated_seconds / 3600.0) * COALESCE(fprr.rate, 0)) - FROM json_array_elements(tc.assignees) AS assignee_json + SELECT SUM((tt.estimated_seconds / 3600.0) * COALESCE(fprr.rate, 0)) + FROM json_array_elements(tt.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 + AND pm.project_id = tt.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, @@ -646,18 +672,66 @@ export default class ProjectfinanceController extends WorklenzControllerBase { 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 project_members pm ON pm.team_member_id = tm.id AND pm.project_id = tt.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 + WHERE twl.task_id = tt.id ), 0) as actual_cost_from_logs + FROM task_tree tt + ), + aggregated_tasks AS ( + SELECT + tc.id, + tc.name, + tc.parent_task_id, + tc.status_id, + tc.priority_id, + tc.phase_id, + tc.assignees, + tc.billable, + tc.fixed_cost, + tc.sub_tasks_count, + -- For subtasks that have their own sub-subtasks, sum values from descendants only + CASE + WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN ( + SELECT SUM(sub_tc.estimated_seconds) + FROM task_costs sub_tc + WHERE sub_tc.root_id = tc.id AND sub_tc.id != tc.id + ) + ELSE tc.estimated_seconds + END as estimated_seconds, + CASE + WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN ( + SELECT SUM(sub_tc.total_time_logged_seconds) + FROM task_costs sub_tc + WHERE sub_tc.root_id = tc.id AND sub_tc.id != tc.id + ) + ELSE tc.total_time_logged_seconds + END as total_time_logged_seconds, + CASE + WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN ( + SELECT SUM(sub_tc.estimated_cost) + FROM task_costs sub_tc + WHERE sub_tc.root_id = tc.id AND sub_tc.id != tc.id + ) + ELSE tc.estimated_cost + END as estimated_cost, + CASE + WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN ( + SELECT SUM(sub_tc.actual_cost_from_logs) + FROM task_costs sub_tc + WHERE sub_tc.root_id = tc.id AND sub_tc.id != tc.id + ) + ELSE tc.actual_cost_from_logs + END as actual_cost_from_logs FROM task_costs tc + WHERE tc.level = 0 -- Only return the requested level (subtasks) ) SELECT - tec.*, - (tec.estimated_cost + tec.fixed_cost) as total_budget, - (tec.actual_cost_from_logs + tec.fixed_cost) as total_actual, - ((tec.actual_cost_from_logs + tec.fixed_cost) - (tec.estimated_cost + tec.fixed_cost)) as variance - FROM task_estimated_costs tec; + at.*, + (at.estimated_cost + at.fixed_cost) as total_budget, + (at.actual_cost_from_logs + at.fixed_cost) as total_actual, + ((at.actual_cost_from_logs + at.fixed_cost) - (at.estimated_cost + at.fixed_cost)) as variance + FROM aggregated_tasks at; `; const result = await db.query(q, [projectId, parentTaskId]); diff --git a/worklenz-backend/src/controllers/tasks-controller-base.ts b/worklenz-backend/src/controllers/tasks-controller-base.ts index d2524bad5..abb074d5e 100644 --- a/worklenz-backend/src/controllers/tasks-controller-base.ts +++ b/worklenz-backend/src/controllers/tasks-controller-base.ts @@ -50,14 +50,17 @@ export default class TasksControllerBase extends WorklenzControllerBase { task.progress = parseInt(task.progress_value); task.complete_ratio = parseInt(task.progress_value); } - // For tasks with no subtasks and no manual progress, calculate based on time + // For tasks with no subtasks and no manual progress else { - task.progress = task.total_minutes_spent && task.total_minutes - ? ~~(task.total_minutes_spent / task.total_minutes * 100) - : 0; - - // Set complete_ratio to match progress - task.complete_ratio = task.progress; + // Only calculate time-based progress if time-based calculation is enabled for the project + if (task.project_use_time_progress && task.total_minutes_spent && task.total_minutes) { + task.progress = ~~(task.total_minutes_spent / task.total_minutes * 100); + task.complete_ratio = task.progress; + } else { + // Default to 0% progress for incomplete tasks when time-based calculation is not enabled + task.progress = 0; + task.complete_ratio = 0; + } } // Ensure numeric values @@ -76,7 +79,31 @@ export default class TasksControllerBase extends WorklenzControllerBase { task.is_sub_task = !!task.parent_task_id; task.time_spent_string = `${task.time_spent.hours}h ${(task.time_spent.minutes)}m`; - task.total_time_string = `${~~(task.total_minutes / 60)}h ${(task.total_minutes % 60)}m`; + + // Use recursive estimation for parent tasks, own estimation for leaf tasks + const recursiveEstimation = task.recursive_estimation || {}; + const hasSubtasks = (task.sub_tasks_count || 0) > 0; + + let displayMinutes; + if (hasSubtasks) { + // For parent tasks, use recursive estimation (sum of all subtasks) + displayMinutes = recursiveEstimation.recursive_total_minutes || 0; + } else { + // For leaf tasks, use their own estimation + displayMinutes = task.total_minutes || 0; + } + + // Format time string - show "0h" for zero time instead of "0h 0m" + const hours = ~~(displayMinutes / 60); + const minutes = displayMinutes % 60; + + if (displayMinutes === 0) { + task.total_time_string = "0h"; + } else if (minutes === 0) { + task.total_time_string = `${hours}h`; + } else { + task.total_time_string = `${hours}h ${minutes}m`; + } task.name_color = getColor(task.name); task.priority_color = PriorityColorCodes[task.priority_value] || PriorityColorCodes["0"]; diff --git a/worklenz-backend/src/controllers/tasks-controller-v2.ts b/worklenz-backend/src/controllers/tasks-controller-v2.ts index 6e01c6861..1d1e45e97 100644 --- a/worklenz-backend/src/controllers/tasks-controller-v2.ts +++ b/worklenz-backend/src/controllers/tasks-controller-v2.ts @@ -258,6 +258,7 @@ export default class TasksControllerV2 extends TasksControllerBase { (SELECT id FROM task_priorities WHERE id = t.priority_id) AS priority, (SELECT value FROM task_priorities WHERE id = t.priority_id) AS priority_value, total_minutes, + (SELECT get_task_recursive_estimation(t.id)) AS recursive_estimation, (SELECT SUM(time_spent) FROM task_work_log WHERE task_id = t.id) AS total_minutes_spent, created_at, updated_at, diff --git a/worklenz-backend/src/controllers/tasks-controller.ts b/worklenz-backend/src/controllers/tasks-controller.ts index 37ff8f844..53611a5b1 100644 --- a/worklenz-backend/src/controllers/tasks-controller.ts +++ b/worklenz-backend/src/controllers/tasks-controller.ts @@ -427,9 +427,24 @@ export default class TasksController extends TasksControllerBase { task.names = WorklenzControllerBase.createTagList(task.assignees); - const totalMinutes = task.total_minutes; - const hours = Math.floor(totalMinutes / 60); - const minutes = totalMinutes % 60; + // Use recursive estimation if task has subtasks, otherwise use own estimation + const recursiveEstimation = task.recursive_estimation || {}; + // Check both the recursive estimation count and the actual database count + const hasSubtasks = (task.sub_tasks_count || 0) > 0; + + let totalMinutes, hours, minutes; + + if (hasSubtasks) { + // For parent tasks, use the sum of all subtasks' estimation (excluding parent's own estimation) + totalMinutes = recursiveEstimation.recursive_total_minutes || 0; + hours = recursiveEstimation.recursive_total_hours || 0; + minutes = recursiveEstimation.recursive_remaining_minutes || 0; + } else { + // For tasks without subtasks, use their own estimation + totalMinutes = task.total_minutes || 0; + hours = Math.floor(totalMinutes / 60); + minutes = totalMinutes % 60; + } task.total_hours = hours; task.total_minutes = minutes; @@ -608,6 +623,18 @@ export default class TasksController extends TasksControllerBase { return res.status(200).send(new ServerResponse(true, null)); } + @HandleExceptions() + public static async resetParentTaskEstimations(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + const q = `SELECT reset_all_parent_task_estimations() AS updated_count;`; + const result = await db.query(q); + const [data] = result.rows; + + return res.status(200).send(new ServerResponse(true, { + message: `Reset estimation for ${data.updated_count} parent tasks`, + updated_count: data.updated_count + })); + } + @HandleExceptions() public static async bulkAssignMembers(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { const { tasks, members, project_id } = req.body; diff --git a/worklenz-backend/src/routes/apis/tasks-api-router.ts b/worklenz-backend/src/routes/apis/tasks-api-router.ts index bb6af547e..006229f43 100644 --- a/worklenz-backend/src/routes/apis/tasks-api-router.ts +++ b/worklenz-backend/src/routes/apis/tasks-api-router.ts @@ -69,4 +69,7 @@ tasksApiRouter.put("/labels/:id", idParamValidator, safeControllerFunction(Tasks // Add custom column value update route tasksApiRouter.put("/:taskId/custom-column", TasksControllerV2.updateCustomColumnValue); +// Add route to reset parent task estimations +tasksApiRouter.post("/reset-parent-estimations", safeControllerFunction(TasksController.resetParentTaskEstimations)); + export default tasksApiRouter; diff --git a/worklenz-frontend/src/components/task-drawer/shared/info-tab/task-details-form.tsx b/worklenz-frontend/src/components/task-drawer/shared/info-tab/task-details-form.tsx index 06552c772..7c445b10b 100644 --- a/worklenz-frontend/src/components/task-drawer/shared/info-tab/task-details-form.tsx +++ b/worklenz-frontend/src/components/task-drawer/shared/info-tab/task-details-form.tsx @@ -82,14 +82,9 @@ const TaskDetailsForm = ({ taskFormViewModel = null, subTasks = [] }: TaskDetail const [form] = Form.useForm(); const { project } = useAppSelector(state => state.projectReducer); - // Calculate sum of subtasks estimation - const subTasksEstimation = subTasks.reduce( - (acc, subTask) => ({ - hours: acc.hours + (subTask.total_hours || 0), - minutes: acc.minutes + (subTask.total_minutes || 0) - }), - { hours: 0, minutes: 0 } - ); + // No need to calculate subtask estimation on frontend anymore + // The backend now provides recursive estimation directly in the task data + const subTasksEstimation: { hours: number; minutes: number } | undefined = undefined; useEffect(() => { if (!taskFormViewModel) { diff --git a/worklenz-frontend/src/features/finance/finance-drawer/finance-drawer.tsx b/worklenz-frontend/src/features/finance/finance-drawer/finance-drawer.tsx index 382c8c668..eec3db605 100644 --- a/worklenz-frontend/src/features/finance/finance-drawer/finance-drawer.tsx +++ b/worklenz-frontend/src/features/finance/finance-drawer/finance-drawer.tsx @@ -43,9 +43,11 @@ const FinanceDrawer = () => { const themeMode = useAppSelector((state) => state.themeReducer.mode); const dispatch = useAppDispatch(); - const currency = useAppSelector( - (state) => state.financeReducer.currency - ).toUpperCase(); + + // Get project currency from project finances, fallback to finance reducer currency + const projectCurrency = useAppSelector((state) => state.projectFinances.project?.currency); + const fallbackCurrency = useAppSelector((state) => state.financeReducer.currency); + const currency = (projectCurrency || fallbackCurrency || 'USD').toUpperCase(); // function handle drawer close const handleClose = () => { 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 index 3e9e4d108..14a05a036 100644 --- a/worklenz-frontend/src/features/finance/ratecard-drawer/import-ratecards-drawer.tsx +++ b/worklenz-frontend/src/features/finance/ratecard-drawer/import-ratecards-drawer.tsx @@ -22,9 +22,10 @@ const ImportRatecardsDrawer: React.FC = () => { const isDrawerOpen = useAppSelector( (state) => state.financeReducer.isImportRatecardsDrawerOpen ); - const currency = useAppSelector( - (state) => state.financeReducer.currency - ).toUpperCase(); + // Get project currency from project finances, fallback to finance reducer currency + const projectCurrency = useAppSelector((state) => state.projectFinances.project?.currency); + const fallbackCurrency = useAppSelector((state) => state.financeReducer.currency); + const currency = (projectCurrency || fallbackCurrency || 'USD').toUpperCase(); const rolesRedux = useAppSelector((state) => state.projectFinanceRateCard.rateCardRoles) || []; From e3e1b2dc14d62e798d280355dae97df151a4de7f Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Mon, 9 Jun 2025 13:05:34 +0530 Subject: [PATCH 098/105] fix(tasks-controller): cap progress calculation at 100% and synchronize complete_ratio - Updated progress calculation to ensure it does not exceed 100% when time-based progress is enabled. - Set complete_ratio to match the calculated progress, improving accuracy in task completion representation. - Simplified comments for clarity regarding progress defaults and calculations. --- .../src/controllers/tasks-controller-base.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/worklenz-backend/src/controllers/tasks-controller-base.ts b/worklenz-backend/src/controllers/tasks-controller-base.ts index abb074d5e..bc038deca 100644 --- a/worklenz-backend/src/controllers/tasks-controller-base.ts +++ b/worklenz-backend/src/controllers/tasks-controller-base.ts @@ -52,15 +52,17 @@ export default class TasksControllerBase extends WorklenzControllerBase { } // For tasks with no subtasks and no manual progress else { - // Only calculate time-based progress if time-based calculation is enabled for the project + // Only calculate progress based on time if time-based progress is enabled for the project if (task.project_use_time_progress && task.total_minutes_spent && task.total_minutes) { - task.progress = ~~(task.total_minutes_spent / task.total_minutes * 100); - task.complete_ratio = task.progress; + // Cap the progress at 100% to prevent showing more than 100% progress + task.progress = Math.min(~~(task.total_minutes_spent / task.total_minutes * 100), 100); } else { - // Default to 0% progress for incomplete tasks when time-based calculation is not enabled + // Default to 0% progress when time-based calculation is not enabled task.progress = 0; - task.complete_ratio = 0; } + + // Set complete_ratio to match progress + task.complete_ratio = task.progress; } // Ensure numeric values From e0a290c18f9cb290081894ceef6c0ef3622696e5 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Mon, 9 Jun 2025 17:03:09 +0530 Subject: [PATCH 099/105] feat(project-finance): enhance fixed cost calculations and parent task updates - Updated SQL queries in ProjectFinanceController to aggregate fixed costs from current tasks and their descendants, improving financial accuracy. - Introduced a new async thunk to update task fixed costs with recalculation, ensuring UI responsiveness and accurate parent task totals. - Implemented recursive functions in the project finance slice to maintain accurate financial data for parent tasks based on subtasks. - Enhanced the FinanceTable component to support these updates, ensuring totals reflect the latest calculations across task hierarchies. --- .../controllers/project-finance-controller.ts | 30 +++- .../projects/finance/project-finance.slice.ts | 167 +++++++++++++++++- .../finance-table/finance-table.tsx | 100 +++++++---- .../finance/project-view-finance.tsx | 49 ++++- 4 files changed, 297 insertions(+), 49 deletions(-) diff --git a/worklenz-backend/src/controllers/project-finance-controller.ts b/worklenz-backend/src/controllers/project-finance-controller.ts index 773b5ae72..91da1b2e2 100644 --- a/worklenz-backend/src/controllers/project-finance-controller.ts +++ b/worklenz-backend/src/controllers/project-finance-controller.ts @@ -174,7 +174,15 @@ export default class ProjectfinanceController extends WorklenzControllerBase { tc.phase_id, tc.assignees, tc.billable, - tc.fixed_cost, + -- Fixed cost aggregation: include current task + all descendants + CASE + WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN ( + SELECT SUM(sub_tc.fixed_cost) + FROM task_costs sub_tc + WHERE sub_tc.root_id = tc.id + ) + ELSE tc.fixed_cost + END as fixed_cost, tc.sub_tasks_count, -- For parent tasks, sum values from descendants only (exclude parent task itself) CASE @@ -688,7 +696,15 @@ export default class ProjectfinanceController extends WorklenzControllerBase { tc.phase_id, tc.assignees, tc.billable, - tc.fixed_cost, + -- Fixed cost aggregation: include current task + all descendants + CASE + WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN ( + SELECT SUM(sub_tc.fixed_cost) + FROM task_costs sub_tc + WHERE sub_tc.root_id = tc.id + ) + ELSE tc.fixed_cost + END as fixed_cost, tc.sub_tasks_count, -- For subtasks that have their own sub-subtasks, sum values from descendants only CASE @@ -932,7 +948,15 @@ export default class ProjectfinanceController extends WorklenzControllerBase { tc.phase_id, tc.assignees, tc.billable, - tc.fixed_cost, + -- Fixed cost aggregation: include current task + all descendants + CASE + WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN ( + SELECT SUM(sub_tc.fixed_cost) + FROM task_costs sub_tc + WHERE sub_tc.root_id = tc.id + ) + ELSE tc.fixed_cost + END as fixed_cost, tc.sub_tasks_count, -- For parent tasks, sum values from descendants only (exclude parent task itself) CASE diff --git a/worklenz-frontend/src/features/projects/finance/project-finance.slice.ts b/worklenz-frontend/src/features/projects/finance/project-finance.slice.ts index 42581a935..444fc9fb3 100644 --- a/worklenz-frontend/src/features/projects/finance/project-finance.slice.ts +++ b/worklenz-frontend/src/features/projects/finance/project-finance.slice.ts @@ -106,6 +106,26 @@ export const updateTaskFixedCostAsync = createAsyncThunk( } ); +export const updateTaskFixedCostWithRecalculation = createAsyncThunk( + 'projectFinances/updateTaskFixedCostWithRecalculation', + async ({ taskId, groupId, fixedCost, projectId, groupBy, billableFilter }: { + taskId: string; + groupId: string; + fixedCost: number; + projectId: string; + groupBy: GroupTypes; + billableFilter?: BillableFilterType; + }, { dispatch }) => { + // Update the fixed cost + await projectFinanceApiService.updateTaskFixedCost(taskId, fixedCost); + + // Trigger a silent refresh to get accurate calculations from backend + dispatch(fetchProjectFinancesSilent({ projectId, groupBy, billableFilter })); + + return { taskId, groupId, fixedCost }; + } +); + export const projectFinancesSlice = createSlice({ name: 'projectFinances', initialState, @@ -231,6 +251,78 @@ export const projectFinancesSlice = createSlice({ if (state.project) { state.project.currency = action.payload; } + }, + updateParentTaskCalculations: (state, action: PayloadAction<{ taskId: string; groupId: string }>) => { + const { taskId, groupId } = action.payload; + const group = state.taskGroups.find(g => g.group_id === groupId); + + if (group) { + // Recursive function to recalculate parent task totals + const recalculateParentTotals = (tasks: IProjectFinanceTask[], targetId: string): boolean => { + for (const task of tasks) { + if (task.id === targetId) { + // If this task has subtasks, recalculate its totals from subtasks + if (task.sub_tasks && task.sub_tasks.length > 0) { + const subtaskTotals = task.sub_tasks.reduce((acc, subtask) => ({ + estimated_cost: acc.estimated_cost + (subtask.estimated_cost || 0), + fixed_cost: acc.fixed_cost + (subtask.fixed_cost || 0), + total_actual: acc.total_actual + (subtask.total_actual || 0), + estimated_seconds: acc.estimated_seconds + (subtask.estimated_seconds || 0), + total_time_logged_seconds: acc.total_time_logged_seconds + (subtask.total_time_logged_seconds || 0) + }), { + estimated_cost: 0, + fixed_cost: 0, + total_actual: 0, + estimated_seconds: 0, + total_time_logged_seconds: 0 + }); + + // Update parent task with aggregated values + task.estimated_cost = subtaskTotals.estimated_cost; + task.fixed_cost = subtaskTotals.fixed_cost; + task.total_actual = subtaskTotals.total_actual; + task.estimated_seconds = subtaskTotals.estimated_seconds; + task.total_time_logged_seconds = subtaskTotals.total_time_logged_seconds; + task.total_budget = task.estimated_cost + task.fixed_cost; + task.variance = task.total_actual - task.total_budget; + } + return true; + } + + // Search in subtasks recursively and recalculate if found + if (task.sub_tasks && recalculateParentTotals(task.sub_tasks, targetId)) { + // After updating subtask, recalculate this parent's totals + if (task.sub_tasks && task.sub_tasks.length > 0) { + const subtaskTotals = task.sub_tasks.reduce((acc, subtask) => ({ + estimated_cost: acc.estimated_cost + (subtask.estimated_cost || 0), + fixed_cost: acc.fixed_cost + (subtask.fixed_cost || 0), + total_actual: acc.total_actual + (subtask.total_actual || 0), + estimated_seconds: acc.estimated_seconds + (subtask.estimated_seconds || 0), + total_time_logged_seconds: acc.total_time_logged_seconds + (subtask.total_time_logged_seconds || 0) + }), { + estimated_cost: 0, + fixed_cost: 0, + total_actual: 0, + estimated_seconds: 0, + total_time_logged_seconds: 0 + }); + + task.estimated_cost = subtaskTotals.estimated_cost; + task.fixed_cost = subtaskTotals.fixed_cost; + task.total_actual = subtaskTotals.total_actual; + task.estimated_seconds = subtaskTotals.estimated_seconds; + task.total_time_logged_seconds = subtaskTotals.total_time_logged_seconds; + task.total_budget = task.estimated_cost + task.fixed_cost; + task.variance = task.total_actual - task.total_budget; + } + return true; + } + } + return false; + }; + + recalculateParentTotals(group.tasks, taskId); + } } }, extraReducers: (builder) => { @@ -248,8 +340,39 @@ export const projectFinancesSlice = createSlice({ state.loading = false; }) .addCase(fetchProjectFinancesSilent.fulfilled, (state, action) => { + // Helper function to preserve expansion state and sub_tasks during updates + const preserveExpansionState = (existingTasks: IProjectFinanceTask[], newTasks: IProjectFinanceTask[]): IProjectFinanceTask[] => { + return newTasks.map(newTask => { + const existingTask = existingTasks.find(t => t.id === newTask.id); + if (existingTask) { + // Preserve expansion state and subtasks + const updatedTask = { + ...newTask, + show_sub_tasks: existingTask.show_sub_tasks, + sub_tasks: existingTask.sub_tasks ? + preserveExpansionState(existingTask.sub_tasks, newTask.sub_tasks || []) : + newTask.sub_tasks + }; + return updatedTask; + } + return newTask; + }); + }; + + // Update groups while preserving expansion state + const updatedTaskGroups = action.payload.groups.map(newGroup => { + const existingGroup = state.taskGroups.find(g => g.group_id === newGroup.group_id); + if (existingGroup) { + return { + ...newGroup, + tasks: preserveExpansionState(existingGroup.tasks, newGroup.tasks) + }; + } + return newGroup; + }); + // Update data without changing loading state for silent refresh - state.taskGroups = action.payload.groups; + state.taskGroups = updatedTaskGroups; state.projectRateCards = action.payload.project_rate_cards; state.project = action.payload.project; }) @@ -263,7 +386,44 @@ export const projectFinancesSlice = createSlice({ for (const task of tasks) { if (task.id === targetId) { task.fixed_cost = fixedCost; - // Don't recalculate here - trigger a refresh instead for accuracy + // Recalculate financial values immediately for UI responsiveness + const totalBudget = (task.estimated_cost || 0) + fixedCost; + const totalActual = task.total_actual || 0; + const variance = totalActual - totalBudget; + + task.total_budget = totalBudget; + task.variance = variance; + return true; + } + + // Search in subtasks recursively + if (task.sub_tasks && findAndUpdateTask(task.sub_tasks, targetId)) { + return true; + } + } + return false; + }; + + findAndUpdateTask(group.tasks, taskId); + } + }) + .addCase(updateTaskFixedCostWithRecalculation.fulfilled, (state, action) => { + const { taskId, groupId, fixedCost } = action.payload; + const group = state.taskGroups.find(g => g.group_id === groupId); + + if (group) { + // Recursive function to find and update a task in the hierarchy + const findAndUpdateTask = (tasks: IProjectFinanceTask[], targetId: string): boolean => { + for (const task of tasks) { + if (task.id === targetId) { + task.fixed_cost = fixedCost; + // Immediate calculation for UI responsiveness + const totalBudget = (task.estimated_cost || 0) + fixedCost; + const totalActual = task.total_actual || 0; + const variance = totalActual - totalBudget; + + task.total_budget = totalBudget; + task.variance = variance; return true; } @@ -321,7 +481,8 @@ export const { updateTaskEstimatedCost, updateTaskTimeLogged, toggleTaskExpansion, - updateProjectFinanceCurrency + updateProjectFinanceCurrency, + updateParentTaskCalculations } = projectFinancesSlice.actions; export default projectFinancesSlice.reducer; 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 index 968956a05..95236d519 100644 --- 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 @@ -13,6 +13,8 @@ import Avatars from '@/components/avatars/avatars'; import { IProjectFinanceGroup, IProjectFinanceTask } from '@/types/project/project-finance.types'; import { updateTaskFixedCostAsync, + updateTaskFixedCostWithRecalculation, + updateParentTaskCalculations, fetchProjectFinancesSilent, toggleTaskExpansion, fetchSubTasks @@ -50,6 +52,7 @@ const FinanceTable = ({ // Get the latest task groups from Redux store const taskGroups = useAppSelector((state) => state.projectFinances.taskGroups); const activeGroup = useAppSelector((state) => state.projectFinances.activeGroup); + const billableFilter = useAppSelector((state) => state.projectFinances.billableFilter); // Auth and permissions const auth = useAuthService(); @@ -144,12 +147,21 @@ const FinanceTable = ({ const fixedCost = value || 0; try { - // Make the API call to persist the change + // First update the task fixed cost await dispatch(updateTaskFixedCostAsync({ taskId, groupId: table.group_id, fixedCost })).unwrap(); - // Silent refresh the data to get accurate calculations from backend without loading animation + // Then update parent task calculations to reflect the change + dispatch(updateParentTaskCalculations({ taskId, groupId: table.group_id })); + + // Finally, trigger a silent refresh to ensure backend consistency if (projectId) { - dispatch(fetchProjectFinancesSilent({ projectId, groupBy: activeGroup })); + setTimeout(() => { + dispatch(fetchProjectFinancesSilent({ + projectId, + groupBy: activeGroup, + billableFilter + })); + }, 100); // Small delay to allow UI update to complete first } } catch (error) { console.error('Failed to update fixed cost:', error); @@ -459,39 +471,57 @@ const FinanceTable = ({ }, [tasks, selectedTask, editingFixedCostValue, hasEditPermission, themeMode, hoveredTaskId]); // Calculate totals for the current table - // Since the backend already aggregates subtask values into parent tasks, - // we only need to sum the parent tasks (tasks without is_sub_task flag) + // Recursively calculate totals including all subtasks const totals = useMemo(() => { - return tasks.reduce( - (acc, task) => { - // Calculate actual cost from logs (total_actual - fixed_cost) - const actualCostFromLogs = (task.total_actual || 0) - (task.fixed_cost || 0); - - // The backend already handles aggregation for parent tasks with subtasks - // Parent tasks contain the sum of their subtasks' values - // So we can safely sum all parent tasks (which are the tasks in this array) - return { - hours: acc.hours + (task.estimated_seconds || 0), - total_time_logged: acc.total_time_logged + (task.total_time_logged_seconds || 0), - estimated_cost: acc.estimated_cost + (task.estimated_cost || 0), - actual_cost_from_logs: acc.actual_cost_from_logs + actualCostFromLogs, - fixed_cost: acc.fixed_cost + (task.fixed_cost || 0), - total_budget: acc.total_budget + (task.total_budget || 0), - total_actual: acc.total_actual + (task.total_actual || 0), - variance: acc.variance + (task.variance || 0) - }; - }, - { - hours: 0, - total_time_logged: 0, - estimated_cost: 0, - actual_cost_from_logs: 0, - fixed_cost: 0, - total_budget: 0, - total_actual: 0, - variance: 0 - } - ); + const calculateTaskTotalsRecursively = (taskList: IProjectFinanceTask[]): any => { + return taskList.reduce( + (acc, task) => { + // Calculate actual cost from logs (total_actual - fixed_cost) + const actualCostFromLogs = (task.total_actual || 0) - (task.fixed_cost || 0); + + // Add current task values + const taskTotals = { + hours: acc.hours + (task.estimated_seconds || 0), + total_time_logged: acc.total_time_logged + (task.total_time_logged_seconds || 0), + estimated_cost: acc.estimated_cost + (task.estimated_cost || 0), + actual_cost_from_logs: acc.actual_cost_from_logs + actualCostFromLogs, + fixed_cost: acc.fixed_cost + (task.fixed_cost || 0), + total_budget: acc.total_budget + (task.total_budget || 0), + total_actual: acc.total_actual + (task.total_actual || 0), + variance: acc.variance + (task.variance || 0) + }; + + // If task has subtasks, recursively add their totals + if (task.sub_tasks && task.sub_tasks.length > 0) { + const subTaskTotals = calculateTaskTotalsRecursively(task.sub_tasks); + return { + hours: taskTotals.hours + subTaskTotals.hours, + total_time_logged: taskTotals.total_time_logged + subTaskTotals.total_time_logged, + estimated_cost: taskTotals.estimated_cost + subTaskTotals.estimated_cost, + actual_cost_from_logs: taskTotals.actual_cost_from_logs + subTaskTotals.actual_cost_from_logs, + fixed_cost: taskTotals.fixed_cost + subTaskTotals.fixed_cost, + total_budget: taskTotals.total_budget + subTaskTotals.total_budget, + total_actual: taskTotals.total_actual + subTaskTotals.total_actual, + variance: taskTotals.variance + subTaskTotals.variance + }; + } + + return taskTotals; + }, + { + hours: 0, + total_time_logged: 0, + estimated_cost: 0, + actual_cost_from_logs: 0, + fixed_cost: 0, + total_budget: 0, + total_actual: 0, + variance: 0 + } + ); + }; + + return calculateTaskTotalsRecursively(tasks); }, [tasks]); // Format the totals for display 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 index 4b75d2641..50a8ed813 100644 --- a/worklenz-frontend/src/pages/projects/projectView/finance/project-view-finance.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/finance/project-view-finance.tsx @@ -55,15 +55,48 @@ const ProjectViewFinance = () => { }; } - const totals = taskGroups.reduce((acc, group) => { - group.tasks.forEach(task => { - acc.totalEstimatedCost += task.estimated_cost || 0; - acc.totalFixedCost += task.fixed_cost || 0; - acc.totalBudget += task.total_budget || 0; - acc.totalActualCost += task.total_actual || 0; - acc.totalVariance += task.variance || 0; + const calculateTaskTotalsRecursively = (tasks: any[]): any => { + return tasks.reduce((acc, task) => { + // Add current task values + const taskTotals = { + totalEstimatedCost: acc.totalEstimatedCost + (task.estimated_cost || 0), + totalFixedCost: acc.totalFixedCost + (task.fixed_cost || 0), + totalBudget: acc.totalBudget + (task.total_budget || 0), + totalActualCost: acc.totalActualCost + (task.total_actual || 0), + totalVariance: acc.totalVariance + (task.variance || 0) + }; + + // If task has subtasks, recursively add their totals + if (task.sub_tasks && task.sub_tasks.length > 0) { + const subTaskTotals = calculateTaskTotalsRecursively(task.sub_tasks); + return { + totalEstimatedCost: taskTotals.totalEstimatedCost + subTaskTotals.totalEstimatedCost, + totalFixedCost: taskTotals.totalFixedCost + subTaskTotals.totalFixedCost, + totalBudget: taskTotals.totalBudget + subTaskTotals.totalBudget, + totalActualCost: taskTotals.totalActualCost + subTaskTotals.totalActualCost, + totalVariance: taskTotals.totalVariance + subTaskTotals.totalVariance + }; + } + + return taskTotals; + }, { + totalEstimatedCost: 0, + totalFixedCost: 0, + totalBudget: 0, + totalActualCost: 0, + totalVariance: 0 }); - return acc; + }; + + const totals = taskGroups.reduce((acc, group) => { + const groupTotals = calculateTaskTotalsRecursively(group.tasks); + return { + totalEstimatedCost: acc.totalEstimatedCost + groupTotals.totalEstimatedCost, + totalFixedCost: acc.totalFixedCost + groupTotals.totalFixedCost, + totalBudget: acc.totalBudget + groupTotals.totalBudget, + totalActualCost: acc.totalActualCost + groupTotals.totalActualCost, + totalVariance: acc.totalVariance + groupTotals.totalVariance + }; }, { totalEstimatedCost: 0, totalFixedCost: 0, From 06488d80ff29241f8c44e36bb859aa103c24471b Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Wed, 11 Jun 2025 10:05:40 +0530 Subject: [PATCH 100/105] feat(project-finance): optimize task cost calculations and enhance UI responsiveness - Implemented checks in the ProjectFinanceController to prevent fixed cost updates for parent tasks with subtasks, ensuring accurate financial data. - Enhanced the project finance slice with memoization and optimized recursive calculations for task hierarchies, improving performance and reducing unnecessary API calls. - Updated the FinanceTable component to reflect these changes, ensuring totals are calculated without double counting and providing immediate UI updates. - Added a README to document the new optimized finance calculation system and its features. --- .../controllers/project-finance-controller.ts | 27 ++ .../src/features/projects/finance/README.md | 83 ++++ .../projects/finance/project-finance.slice.ts | 398 +++++++++--------- .../finance-table/finance-table-wrapper.tsx | 91 ++-- .../finance-table/finance-table.tsx | 183 +++++--- .../finance/project-view-finance.tsx | 54 +-- 6 files changed, 499 insertions(+), 337 deletions(-) create mode 100644 worklenz-frontend/src/features/projects/finance/README.md diff --git a/worklenz-backend/src/controllers/project-finance-controller.ts b/worklenz-backend/src/controllers/project-finance-controller.ts index 91da1b2e2..b5cfda45e 100644 --- a/worklenz-backend/src/controllers/project-finance-controller.ts +++ b/worklenz-backend/src/controllers/project-finance-controller.ts @@ -399,6 +399,33 @@ export default class ProjectfinanceController extends WorklenzControllerBase { .send(new ServerResponse(false, null, "Invalid fixed cost value")); } + // Check if the task has subtasks - parent tasks should not have editable fixed costs + const checkParentQuery = ` + SELECT + t.id, + t.name, + (SELECT COUNT(*) FROM tasks st WHERE st.parent_task_id = t.id AND st.archived = false) as sub_tasks_count + FROM tasks t + WHERE t.id = $1 AND t.archived = false; + `; + + const checkResult = await db.query(checkParentQuery, [taskId]); + + if (checkResult.rows.length === 0) { + return res + .status(404) + .send(new ServerResponse(false, null, "Task not found")); + } + + const task = checkResult.rows[0]; + + // Prevent updating fixed cost for parent tasks + if (task.sub_tasks_count > 0) { + return res + .status(400) + .send(new ServerResponse(false, null, "Cannot update fixed cost for parent tasks. Fixed cost is calculated from subtasks.")); + } + const q = ` UPDATE tasks SET fixed_cost = $1, updated_at = NOW() diff --git a/worklenz-frontend/src/features/projects/finance/README.md b/worklenz-frontend/src/features/projects/finance/README.md new file mode 100644 index 000000000..82b8cac47 --- /dev/null +++ b/worklenz-frontend/src/features/projects/finance/README.md @@ -0,0 +1,83 @@ +# Optimized Finance Calculation System + +## Overview + +This system provides efficient frontend recalculation of project finance data when fixed costs are updated, eliminating the need for API refetches and ensuring optimal performance even with deeply nested task hierarchies. + +## Key Features + +### 1. Hierarchical Recalculation +- When a nested subtask's fixed cost is updated, all parent tasks are automatically recalculated +- Parent task totals are aggregated from their subtasks to avoid double counting +- Calculations propagate up the entire task hierarchy efficiently + +### 2. Performance Optimizations +- **Memoization**: Task calculations are cached to avoid redundant computations +- **Smart Cache Management**: Cache entries expire automatically and are cleaned up periodically +- **Selective Updates**: Only tasks that have actually changed trigger recalculations + +### 3. Frontend-Only Updates +- No API refetches required for fixed cost updates +- Immediate UI responsiveness +- Reduced server load and network traffic + +## How It Works + +### Task Update Flow +1. User updates fixed cost in UI +2. `updateTaskFixedCostAsync` is dispatched +3. API call updates the backend +4. Redux reducer updates the task and triggers `recalculateTaskHierarchy` +5. All parent tasks are recalculated automatically +6. UI updates immediately with new values + +### Calculation Logic +```typescript +// For parent tasks with subtasks +parentTask.fixed_cost = sum(subtask.fixed_cost) +parentTask.total_budget = parentTask.estimated_cost + parentTask.fixed_cost +parentTask.variance = parentTask.total_actual - parentTask.total_budget + +// For leaf tasks +task.total_budget = task.estimated_cost + task.fixed_cost +task.variance = task.total_actual - task.total_budget +``` + +### Memoization Strategy +- Cache key includes all relevant financial fields +- Cache entries expire after 10 minutes +- Cache is cleared when fresh data is loaded from API +- Automatic cleanup prevents memory leaks + +## Usage Examples + +### Updating Fixed Cost +```typescript +// This will automatically recalculate all parent tasks +dispatch(updateTaskFixedCostAsync({ + taskId: 'subtask-123', + groupId: 'group-456', + fixedCost: 1500 +})); +``` + +### Budget Statistics +The budget statistics in the project overview are calculated efficiently: +- Avoids double counting in nested hierarchies +- Uses aggregated values from parent tasks +- Updates automatically when any task changes + +## Performance Benefits + +1. **Reduced API Calls**: No refetching required for fixed cost updates +2. **Faster UI Updates**: Immediate recalculation and display +3. **Memory Efficient**: Smart caching with automatic cleanup +4. **Scalable**: Handles deeply nested task hierarchies efficiently + +## Cache Management + +The system includes automatic cache management: +- Cache cleanup every 5 minutes +- Entries expire after 10 minutes +- Manual cache clearing when fresh data is loaded +- Memory-efficient with automatic garbage collection \ No newline at end of file diff --git a/worklenz-frontend/src/features/projects/finance/project-finance.slice.ts b/worklenz-frontend/src/features/projects/finance/project-finance.slice.ts index 444fc9fb3..fdb571979 100644 --- a/worklenz-frontend/src/features/projects/finance/project-finance.slice.ts +++ b/worklenz-frontend/src/features/projects/finance/project-finance.slice.ts @@ -17,7 +17,7 @@ interface ProjectFinanceState { project: IProjectFinanceProject | null; } -// Utility functions for frontend calculations +// Enhanced utility functions for efficient frontend calculations const secondsToHours = (seconds: number) => seconds / 3600; const calculateTaskCosts = (task: IProjectFinanceTask) => { @@ -25,8 +25,6 @@ const calculateTaskCosts = (task: IProjectFinanceTask) => { const timeLoggedHours = secondsToHours(task.total_time_logged_seconds || 0); const fixedCost = task.fixed_cost || 0; - // For fixed cost updates, we'll rely on the backend values - // and trigger a re-fetch to ensure accuracy const totalBudget = (task.estimated_cost || 0) + fixedCost; const totalActual = task.total_actual || 0; const variance = totalActual - totalBudget; @@ -40,30 +38,152 @@ const calculateTaskCosts = (task: IProjectFinanceTask) => { }; }; -const calculateGroupTotals = (tasks: IProjectFinanceTask[]) => { - return tasks.reduce( - (acc, task) => { - const { hours, timeLoggedHours, totalBudget, totalActual, variance } = calculateTaskCosts(task); - return { - hours: acc.hours + hours, - total_time_logged: acc.total_time_logged + timeLoggedHours, - estimated_cost: acc.estimated_cost + (task.estimated_cost || 0), - total_budget: acc.total_budget + totalBudget, - total_actual: acc.total_actual + totalActual, - variance: acc.variance + variance - }; - }, - { - hours: 0, - total_time_logged: 0, - estimated_cost: 0, - total_budget: 0, - total_actual: 0, - variance: 0 +// Memoization cache for task calculations to improve performance +const taskCalculationCache = new Map(); + +// Cache cleanup interval (5 minutes) +const CACHE_CLEANUP_INTERVAL = 5 * 60 * 1000; +const CACHE_MAX_AGE = 10 * 60 * 1000; // 10 minutes + +// Periodic cache cleanup +setInterval(() => { + const now = Date.now(); + Array.from(taskCalculationCache.entries()).forEach(([key, value]) => { + if (now - value.timestamp > CACHE_MAX_AGE) { + taskCalculationCache.delete(key); } + }); +}, CACHE_CLEANUP_INTERVAL); + +// Generate cache key for task +const generateTaskCacheKey = (task: IProjectFinanceTask): string => { + return `${task.id}-${task.estimated_cost}-${task.fixed_cost}-${task.total_actual}-${task.estimated_seconds}-${task.total_time_logged_seconds}`; +}; + +// Check if task has changed significantly to warrant recalculation +const hasTaskChanged = (oldTask: IProjectFinanceTask, newTask: IProjectFinanceTask): boolean => { + return ( + oldTask.estimated_cost !== newTask.estimated_cost || + oldTask.fixed_cost !== newTask.fixed_cost || + oldTask.total_actual !== newTask.total_actual || + oldTask.estimated_seconds !== newTask.estimated_seconds || + oldTask.total_time_logged_seconds !== newTask.total_time_logged_seconds ); }; +// Optimized recursive calculation for task hierarchy with memoization +const recalculateTaskHierarchy = (tasks: IProjectFinanceTask[]): IProjectFinanceTask[] => { + return tasks.map(task => { + const cacheKey = generateTaskCacheKey(task); + const cached = taskCalculationCache.get(cacheKey); + + // If task has subtasks, first recalculate all subtasks recursively + if (task.sub_tasks && task.sub_tasks.length > 0) { + const updatedSubTasks = recalculateTaskHierarchy(task.sub_tasks); + + // Calculate parent task totals from subtasks + const subtaskTotals = updatedSubTasks.reduce((acc, subtask) => ({ + estimated_cost: acc.estimated_cost + (subtask.estimated_cost || 0), + fixed_cost: acc.fixed_cost + (subtask.fixed_cost || 0), + total_actual: acc.total_actual + (subtask.total_actual || 0), + estimated_seconds: acc.estimated_seconds + (subtask.estimated_seconds || 0), + total_time_logged_seconds: acc.total_time_logged_seconds + (subtask.total_time_logged_seconds || 0) + }), { + estimated_cost: 0, + fixed_cost: 0, + total_actual: 0, + estimated_seconds: 0, + total_time_logged_seconds: 0 + }); + + // Update parent task with aggregated values + const updatedTask = { + ...task, + sub_tasks: updatedSubTasks, + estimated_cost: subtaskTotals.estimated_cost, + fixed_cost: subtaskTotals.fixed_cost, + total_actual: subtaskTotals.total_actual, + estimated_seconds: subtaskTotals.estimated_seconds, + total_time_logged_seconds: subtaskTotals.total_time_logged_seconds, + total_budget: subtaskTotals.estimated_cost + subtaskTotals.fixed_cost, + variance: subtaskTotals.total_actual - (subtaskTotals.estimated_cost + subtaskTotals.fixed_cost) + }; + + // Cache the result + taskCalculationCache.set(cacheKey, { + task: { ...task }, + result: updatedTask, + timestamp: Date.now() + }); + + return updatedTask; + } + + // For leaf tasks, check cache first + if (cached && !hasTaskChanged(cached.task, task)) { + return { ...cached.result, ...task }; // Merge with current task to preserve other properties + } + + // For leaf tasks, just recalculate their own values + const { totalBudget, totalActual, variance } = calculateTaskCosts(task); + const updatedTask = { + ...task, + total_budget: totalBudget, + total_actual: totalActual, + variance: variance + }; + + // Cache the result + taskCalculationCache.set(cacheKey, { + task: { ...task }, + result: updatedTask, + timestamp: Date.now() + }); + + return updatedTask; + }); +}; + +// Optimized function to find and update a specific task, then recalculate hierarchy +const updateTaskAndRecalculateHierarchy = ( + tasks: IProjectFinanceTask[], + targetId: string, + updateFn: (task: IProjectFinanceTask) => IProjectFinanceTask +): { updated: boolean; tasks: IProjectFinanceTask[] } => { + let updated = false; + + const updatedTasks = tasks.map(task => { + if (task.id === targetId) { + updated = true; + return updateFn(task); + } + + // Search in subtasks recursively + if (task.sub_tasks && task.sub_tasks.length > 0) { + const result = updateTaskAndRecalculateHierarchy(task.sub_tasks, targetId, updateFn); + if (result.updated) { + updated = true; + return { + ...task, + sub_tasks: result.tasks + }; + } + } + + return task; + }); + + // If a task was updated, recalculate the entire hierarchy to ensure parent totals are correct + return { + updated, + tasks: updated ? recalculateTaskHierarchy(updatedTasks) : updatedTasks + }; +}; + const initialState: ProjectFinanceState = { activeTab: 'finance', activeGroup: 'status', @@ -106,25 +226,10 @@ export const updateTaskFixedCostAsync = createAsyncThunk( } ); -export const updateTaskFixedCostWithRecalculation = createAsyncThunk( - 'projectFinances/updateTaskFixedCostWithRecalculation', - async ({ taskId, groupId, fixedCost, projectId, groupBy, billableFilter }: { - taskId: string; - groupId: string; - fixedCost: number; - projectId: string; - groupBy: GroupTypes; - billableFilter?: BillableFilterType; - }, { dispatch }) => { - // Update the fixed cost - await projectFinanceApiService.updateTaskFixedCost(taskId, fixedCost); - - // Trigger a silent refresh to get accurate calculations from backend - dispatch(fetchProjectFinancesSilent({ projectId, groupBy, billableFilter })); - - return { taskId, groupId, fixedCost }; - } -); +// Function to clear calculation cache (useful for testing or when data is refreshed) +const clearCalculationCache = () => { + taskCalculationCache.clear(); +}; export const projectFinancesSlice = createSlice({ name: 'projectFinances', @@ -144,24 +249,18 @@ export const projectFinancesSlice = createSlice({ const group = state.taskGroups.find(g => g.group_id === groupId); if (group) { - // Recursive function to find and update a task in the hierarchy - const findAndUpdateTask = (tasks: IProjectFinanceTask[], targetId: string): boolean => { - for (const task of tasks) { - if (task.id === targetId) { - task.fixed_cost = fixedCost; - // Don't recalculate here - let the backend handle it and we'll refresh - return true; - } - - // Search in subtasks recursively - if (task.sub_tasks && findAndUpdateTask(task.sub_tasks, targetId)) { - return true; - } - } - return false; - }; + const result = updateTaskAndRecalculateHierarchy( + group.tasks, + taskId, + (task) => ({ + ...task, + fixed_cost: fixedCost + }) + ); - findAndUpdateTask(group.tasks, taskId); + if (result.updated) { + group.tasks = result.tasks; + } } }, updateTaskEstimatedCost: (state, action: PayloadAction<{ taskId: string; groupId: string; estimatedCost: number }>) => { @@ -169,58 +268,39 @@ export const projectFinancesSlice = createSlice({ const group = state.taskGroups.find(g => g.group_id === groupId); if (group) { - // Recursive function to find and update a task in the hierarchy - const findAndUpdateTask = (tasks: IProjectFinanceTask[], targetId: string): boolean => { - for (const task of tasks) { - if (task.id === targetId) { - task.estimated_cost = estimatedCost; - // Recalculate task costs after updating estimated cost - const { totalBudget, totalActual, variance } = calculateTaskCosts(task); - task.total_budget = totalBudget; - task.total_actual = totalActual; - task.variance = variance; - return true; - } - - // Search in subtasks recursively - if (task.sub_tasks && findAndUpdateTask(task.sub_tasks, targetId)) { - return true; - } - } - return false; - }; + const result = updateTaskAndRecalculateHierarchy( + group.tasks, + taskId, + (task) => ({ + ...task, + estimated_cost: estimatedCost + }) + ); - findAndUpdateTask(group.tasks, taskId); + if (result.updated) { + group.tasks = result.tasks; + } } }, - updateTaskTimeLogged: (state, action: PayloadAction<{ taskId: string; groupId: string; timeLoggedSeconds: number; timeLoggedString: string }>) => { - const { taskId, groupId, timeLoggedSeconds, timeLoggedString } = action.payload; + updateTaskTimeLogged: (state, action: PayloadAction<{ taskId: string; groupId: string; timeLoggedSeconds: number; timeLoggedString: string; totalActual: number }>) => { + const { taskId, groupId, timeLoggedSeconds, timeLoggedString, totalActual } = action.payload; const group = state.taskGroups.find(g => g.group_id === groupId); if (group) { - // Recursive function to find and update a task in the hierarchy - const findAndUpdateTask = (tasks: IProjectFinanceTask[], targetId: string): boolean => { - for (const task of tasks) { - if (task.id === targetId) { - task.total_time_logged_seconds = timeLoggedSeconds; - task.total_time_logged = timeLoggedString; - // Recalculate task costs after updating time logged - const { totalBudget, totalActual, variance } = calculateTaskCosts(task); - task.total_budget = totalBudget; - task.total_actual = totalActual; - task.variance = variance; - return true; - } - - // Search in subtasks recursively - if (task.sub_tasks && findAndUpdateTask(task.sub_tasks, targetId)) { - return true; - } - } - return false; - }; + const result = updateTaskAndRecalculateHierarchy( + group.tasks, + taskId, + (task) => ({ + ...task, + total_time_logged_seconds: timeLoggedSeconds, + total_time_logged: timeLoggedString, + total_actual: totalActual + }) + ); - findAndUpdateTask(group.tasks, taskId); + if (result.updated) { + group.tasks = result.tasks; + } } }, toggleTaskExpansion: (state, action: PayloadAction<{ taskId: string; groupId: string }>) => { @@ -252,78 +332,6 @@ export const projectFinancesSlice = createSlice({ state.project.currency = action.payload; } }, - updateParentTaskCalculations: (state, action: PayloadAction<{ taskId: string; groupId: string }>) => { - const { taskId, groupId } = action.payload; - const group = state.taskGroups.find(g => g.group_id === groupId); - - if (group) { - // Recursive function to recalculate parent task totals - const recalculateParentTotals = (tasks: IProjectFinanceTask[], targetId: string): boolean => { - for (const task of tasks) { - if (task.id === targetId) { - // If this task has subtasks, recalculate its totals from subtasks - if (task.sub_tasks && task.sub_tasks.length > 0) { - const subtaskTotals = task.sub_tasks.reduce((acc, subtask) => ({ - estimated_cost: acc.estimated_cost + (subtask.estimated_cost || 0), - fixed_cost: acc.fixed_cost + (subtask.fixed_cost || 0), - total_actual: acc.total_actual + (subtask.total_actual || 0), - estimated_seconds: acc.estimated_seconds + (subtask.estimated_seconds || 0), - total_time_logged_seconds: acc.total_time_logged_seconds + (subtask.total_time_logged_seconds || 0) - }), { - estimated_cost: 0, - fixed_cost: 0, - total_actual: 0, - estimated_seconds: 0, - total_time_logged_seconds: 0 - }); - - // Update parent task with aggregated values - task.estimated_cost = subtaskTotals.estimated_cost; - task.fixed_cost = subtaskTotals.fixed_cost; - task.total_actual = subtaskTotals.total_actual; - task.estimated_seconds = subtaskTotals.estimated_seconds; - task.total_time_logged_seconds = subtaskTotals.total_time_logged_seconds; - task.total_budget = task.estimated_cost + task.fixed_cost; - task.variance = task.total_actual - task.total_budget; - } - return true; - } - - // Search in subtasks recursively and recalculate if found - if (task.sub_tasks && recalculateParentTotals(task.sub_tasks, targetId)) { - // After updating subtask, recalculate this parent's totals - if (task.sub_tasks && task.sub_tasks.length > 0) { - const subtaskTotals = task.sub_tasks.reduce((acc, subtask) => ({ - estimated_cost: acc.estimated_cost + (subtask.estimated_cost || 0), - fixed_cost: acc.fixed_cost + (subtask.fixed_cost || 0), - total_actual: acc.total_actual + (subtask.total_actual || 0), - estimated_seconds: acc.estimated_seconds + (subtask.estimated_seconds || 0), - total_time_logged_seconds: acc.total_time_logged_seconds + (subtask.total_time_logged_seconds || 0) - }), { - estimated_cost: 0, - fixed_cost: 0, - total_actual: 0, - estimated_seconds: 0, - total_time_logged_seconds: 0 - }); - - task.estimated_cost = subtaskTotals.estimated_cost; - task.fixed_cost = subtaskTotals.fixed_cost; - task.total_actual = subtaskTotals.total_actual; - task.estimated_seconds = subtaskTotals.estimated_seconds; - task.total_time_logged_seconds = subtaskTotals.total_time_logged_seconds; - task.total_budget = task.estimated_cost + task.fixed_cost; - task.variance = task.total_actual - task.total_budget; - } - return true; - } - } - return false; - }; - - recalculateParentTotals(group.tasks, taskId); - } - } }, extraReducers: (builder) => { builder @@ -335,6 +343,8 @@ export const projectFinancesSlice = createSlice({ state.taskGroups = action.payload.groups; state.projectRateCards = action.payload.project_rate_cards; state.project = action.payload.project; + // Clear cache when fresh data is loaded + clearCalculationCache(); }) .addCase(fetchProjectFinances.rejected, (state) => { state.loading = false; @@ -375,6 +385,8 @@ export const projectFinancesSlice = createSlice({ state.taskGroups = updatedTaskGroups; state.projectRateCards = action.payload.project_rate_cards; state.project = action.payload.project; + // Clear cache when data is refreshed from backend + clearCalculationCache(); }) .addCase(updateTaskFixedCostAsync.fulfilled, (state, action) => { const { taskId, groupId, fixedCost } = action.payload; @@ -407,37 +419,6 @@ export const projectFinancesSlice = createSlice({ findAndUpdateTask(group.tasks, taskId); } }) - .addCase(updateTaskFixedCostWithRecalculation.fulfilled, (state, action) => { - const { taskId, groupId, fixedCost } = action.payload; - const group = state.taskGroups.find(g => g.group_id === groupId); - - if (group) { - // Recursive function to find and update a task in the hierarchy - const findAndUpdateTask = (tasks: IProjectFinanceTask[], targetId: string): boolean => { - for (const task of tasks) { - if (task.id === targetId) { - task.fixed_cost = fixedCost; - // Immediate calculation for UI responsiveness - const totalBudget = (task.estimated_cost || 0) + fixedCost; - const totalActual = task.total_actual || 0; - const variance = totalActual - totalBudget; - - task.total_budget = totalBudget; - task.variance = variance; - return true; - } - - // Search in subtasks recursively - if (task.sub_tasks && findAndUpdateTask(task.sub_tasks, targetId)) { - return true; - } - } - return false; - }; - - findAndUpdateTask(group.tasks, taskId); - } - }) .addCase(fetchSubTasks.fulfilled, (state, action) => { const { parentTaskId, subTasks } = action.payload; @@ -481,8 +462,7 @@ export const { updateTaskEstimatedCost, updateTaskTimeLogged, toggleTaskExpansion, - updateProjectFinanceCurrency, - updateParentTaskCalculations + updateProjectFinanceCurrency } = projectFinancesSlice.actions; export default projectFinancesSlice.reducer; 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 index 0e5b6dd0d..475a761b5 100644 --- 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 @@ -8,7 +8,7 @@ import { openFinanceDrawer } from '@/features/finance/finance-slice'; import { financeTableColumns, FinanceTableColumnKeys } from '@/lib/project/project-view-finance-table-columns'; import FinanceTable from './finance-table'; import FinanceDrawer from '@/features/finance/finance-drawer/finance-drawer'; -import { IProjectFinanceGroup } from '@/types/project/project-finance.types'; +import { IProjectFinanceGroup, IProjectFinanceTask } from '@/types/project/project-finance.types'; import { createPortal } from 'react-dom'; interface FinanceTableWrapperProps { @@ -64,33 +64,37 @@ const FinanceTableWrapper: React.FC = ({ activeTablesL // 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_seconds) || 0; - acc.cost += ((task.total_actual || 0) - (task.fixed_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_seconds) || 0; - acc.estimated_cost += task.estimated_cost || 0; - }); - return acc; - }, - { + // Recursive function to calculate totals from task hierarchy without double counting + const calculateTaskTotalsRecursively = (tasks: IProjectFinanceTask[]): any => { + return tasks.reduce((acc, task) => { + // For parent tasks with subtasks, only count the aggregated values (no double counting) + // For leaf tasks, count their individual values + if (task.sub_tasks && task.sub_tasks.length > 0) { + // Parent task - use its aggregated values which already include subtask totals + return { + hours: acc.hours + (task.estimated_seconds || 0), + cost: acc.cost + ((task.total_actual || 0) - (task.fixed_cost || 0)), + fixedCost: acc.fixedCost + (task.fixed_cost || 0), + totalBudget: acc.totalBudget + (task.total_budget || 0), + totalActual: acc.totalActual + (task.total_actual || 0), + variance: acc.variance + (task.variance || 0), + total_time_logged: acc.total_time_logged + (task.total_time_logged_seconds || 0), + estimated_cost: acc.estimated_cost + (task.estimated_cost || 0) + }; + } else { + // Leaf task - use its individual values + return { + hours: acc.hours + (task.estimated_seconds || 0), + cost: acc.cost + ((task.total_actual || 0) - (task.fixed_cost || 0)), + fixedCost: acc.fixedCost + (task.fixed_cost || 0), + totalBudget: acc.totalBudget + (task.total_budget || 0), + totalActual: acc.totalActual + (task.total_actual || 0), + variance: acc.variance + (task.variance || 0), + total_time_logged: acc.total_time_logged + (task.total_time_logged_seconds || 0), + estimated_cost: acc.estimated_cost + (task.estimated_cost || 0) + }; + } + }, { hours: 0, cost: 0, fixedCost: 0, @@ -98,9 +102,32 @@ const FinanceTableWrapper: React.FC = ({ activeTablesL totalActual: 0, variance: 0, total_time_logged: 0, - estimated_cost: 0, - } - ); + estimated_cost: 0 + }); + }; + + return taskGroups.reduce((acc, table: IProjectFinanceGroup) => { + const groupTotals = calculateTaskTotalsRecursively(table.tasks); + return { + hours: acc.hours + groupTotals.hours, + cost: acc.cost + groupTotals.cost, + fixedCost: acc.fixedCost + groupTotals.fixedCost, + totalBudget: acc.totalBudget + groupTotals.totalBudget, + totalActual: acc.totalActual + groupTotals.totalActual, + variance: acc.variance + groupTotals.variance, + total_time_logged: acc.total_time_logged + groupTotals.total_time_logged, + estimated_cost: acc.estimated_cost + groupTotals.estimated_cost + }; + }, { + hours: 0, + cost: 0, + fixedCost: 0, + totalBudget: 0, + totalActual: 0, + variance: 0, + total_time_logged: 0, + estimated_cost: 0 + }); }, [taskGroups]); @@ -251,4 +278,4 @@ const FinanceTableWrapper: React.FC = ({ activeTablesL ); }; -export default FinanceTableWrapper; +export default FinanceTableWrapper; \ No newline at end of file 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 index 95236d519..67098181b 100644 --- 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 @@ -13,9 +13,6 @@ import Avatars from '@/components/avatars/avatars'; import { IProjectFinanceGroup, IProjectFinanceTask } from '@/types/project/project-finance.types'; import { updateTaskFixedCostAsync, - updateTaskFixedCostWithRecalculation, - updateParentTaskCalculations, - fetchProjectFinancesSilent, toggleTaskExpansion, fetchSubTasks } from '@/features/projects/finance/project-finance.slice'; @@ -146,23 +143,35 @@ const FinanceTable = ({ const handleFixedCostChange = async (value: number | null, taskId: string) => { const fixedCost = value || 0; + // Find the task to check if it's a parent task + const findTask = (tasks: IProjectFinanceTask[], id: string): IProjectFinanceTask | null => { + for (const task of tasks) { + if (task.id === id) return task; + if (task.sub_tasks) { + const found = findTask(task.sub_tasks, id); + if (found) return found; + } + } + return null; + }; + + const task = findTask(tasks, taskId); + if (!task) { + console.error('Task not found:', taskId); + return; + } + + // Prevent editing fixed cost for parent tasks + if (task.sub_tasks_count > 0) { + console.warn('Cannot edit fixed cost for parent tasks. Fixed cost is calculated from subtasks.'); + return; + } + try { - // First update the task fixed cost + // Update the task fixed cost - this will automatically trigger hierarchical recalculation await dispatch(updateTaskFixedCostAsync({ taskId, groupId: table.group_id, fixedCost })).unwrap(); - // Then update parent task calculations to reflect the change - dispatch(updateParentTaskCalculations({ taskId, groupId: table.group_id })); - - // Finally, trigger a silent refresh to ensure backend consistency - if (projectId) { - setTimeout(() => { - dispatch(fetchProjectFinancesSilent({ - projectId, - groupBy: activeGroup, - billableFilter - })); - }, 100); // Small delay to allow UI update to complete first - } + // No need for manual parent calculations or API refetch - the Redux slice handles it efficiently } catch (error) { console.error('Failed to update fixed cost:', error); } @@ -377,7 +386,11 @@ const FinanceTable = ({ case FinanceTableColumnKeys.ESTIMATED_COST: return {formatNumber(task.estimated_cost)}; case FinanceTableColumnKeys.FIXED_COST: - return selectedTask?.id === task.id && hasEditPermission ? ( + // Parent tasks with subtasks should not be editable - they aggregate from subtasks + const isParentTask = task.sub_tasks_count > 0; + const canEditThisTask = hasEditPermission && !isParentTask; + + return selectedTask?.id === task.id && canEditThisTask ? ( { @@ -404,17 +417,20 @@ const FinanceTable = ({ ) : ( { + onClick={canEditThisTask ? (e) => { e.stopPropagation(); setSelectedTask(task); setEditingFixedCostValue(task.fixed_cost); } : undefined} + title={isParentTask ? 'Fixed cost is calculated from subtasks' : undefined} > {formatNumber(task.fixed_cost)} @@ -471,57 +487,50 @@ const FinanceTable = ({ }, [tasks, selectedTask, editingFixedCostValue, hasEditPermission, themeMode, hoveredTaskId]); // Calculate totals for the current table - // Recursively calculate totals including all subtasks + // Optimized calculation that avoids double counting in nested hierarchies const totals = useMemo(() => { - const calculateTaskTotalsRecursively = (taskList: IProjectFinanceTask[]): any => { - return taskList.reduce( - (acc, task) => { - // Calculate actual cost from logs (total_actual - fixed_cost) - const actualCostFromLogs = (task.total_actual || 0) - (task.fixed_cost || 0); - - // Add current task values - const taskTotals = { - hours: acc.hours + (task.estimated_seconds || 0), - total_time_logged: acc.total_time_logged + (task.total_time_logged_seconds || 0), - estimated_cost: acc.estimated_cost + (task.estimated_cost || 0), - actual_cost_from_logs: acc.actual_cost_from_logs + actualCostFromLogs, - fixed_cost: acc.fixed_cost + (task.fixed_cost || 0), - total_budget: acc.total_budget + (task.total_budget || 0), - total_actual: acc.total_actual + (task.total_actual || 0), - variance: acc.variance + (task.variance || 0) - }; - - // If task has subtasks, recursively add their totals - if (task.sub_tasks && task.sub_tasks.length > 0) { - const subTaskTotals = calculateTaskTotalsRecursively(task.sub_tasks); - return { - hours: taskTotals.hours + subTaskTotals.hours, - total_time_logged: taskTotals.total_time_logged + subTaskTotals.total_time_logged, - estimated_cost: taskTotals.estimated_cost + subTaskTotals.estimated_cost, - actual_cost_from_logs: taskTotals.actual_cost_from_logs + subTaskTotals.actual_cost_from_logs, - fixed_cost: taskTotals.fixed_cost + subTaskTotals.fixed_cost, - total_budget: taskTotals.total_budget + subTaskTotals.total_budget, - total_actual: taskTotals.total_actual + subTaskTotals.total_actual, - variance: taskTotals.variance + subTaskTotals.variance - }; - } - - return taskTotals; - }, - { - hours: 0, - total_time_logged: 0, - estimated_cost: 0, - actual_cost_from_logs: 0, - fixed_cost: 0, - total_budget: 0, - total_actual: 0, - variance: 0 + const calculateTaskTotalsFlat = (taskList: IProjectFinanceTask[]): any => { + let totals = { + hours: 0, + total_time_logged: 0, + estimated_cost: 0, + actual_cost_from_logs: 0, + fixed_cost: 0, + total_budget: 0, + total_actual: 0, + variance: 0 + }; + + for (const task of taskList) { + // For parent tasks with subtasks, only count the aggregated values (no double counting) + // For leaf tasks, count their individual values + if (task.sub_tasks && task.sub_tasks.length > 0) { + // Parent task - use its aggregated values which already include subtask totals + totals.hours += task.estimated_seconds || 0; + totals.total_time_logged += task.total_time_logged_seconds || 0; + totals.estimated_cost += task.estimated_cost || 0; + totals.actual_cost_from_logs += (task.total_actual || 0) - (task.fixed_cost || 0); + totals.fixed_cost += task.fixed_cost || 0; + totals.total_budget += task.total_budget || 0; + totals.total_actual += task.total_actual || 0; + totals.variance += task.variance || 0; + } else { + // Leaf task - use its individual values + totals.hours += task.estimated_seconds || 0; + totals.total_time_logged += task.total_time_logged_seconds || 0; + totals.estimated_cost += task.estimated_cost || 0; + totals.actual_cost_from_logs += (task.total_actual || 0) - (task.fixed_cost || 0); + totals.fixed_cost += task.fixed_cost || 0; + totals.total_budget += task.total_budget || 0; + totals.total_actual += task.total_actual || 0; + totals.variance += task.variance || 0; } - ); + } + + return totals; }; - return calculateTaskTotalsRecursively(tasks); + return calculateTaskTotalsFlat(tasks); }, [tasks]); // Format the totals for display @@ -593,6 +602,42 @@ const FinanceTable = ({ {/* task rows with recursive hierarchy */} {!isCollapse && flattenedTasks} + + {/* Group totals row */} + {!isCollapse && tasks.length > 0 && ( + + {financeTableColumns.map((col) => ( + + {col.key === FinanceTableColumnKeys.TASK ? ( + + Group Total + + ) : col.key === FinanceTableColumnKeys.MEMBERS ? null : ( + renderFinancialTableHeaderContent(col.key) + )} + + ))} + + )} ); }; 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 index 50a8ed813..459cad88d 100644 --- a/worklenz-frontend/src/pages/projects/projectView/finance/project-view-finance.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/finance/project-view-finance.tsx @@ -55,41 +55,41 @@ const ProjectViewFinance = () => { }; } - const calculateTaskTotalsRecursively = (tasks: any[]): any => { - return tasks.reduce((acc, task) => { - // Add current task values - const taskTotals = { - totalEstimatedCost: acc.totalEstimatedCost + (task.estimated_cost || 0), - totalFixedCost: acc.totalFixedCost + (task.fixed_cost || 0), - totalBudget: acc.totalBudget + (task.total_budget || 0), - totalActualCost: acc.totalActualCost + (task.total_actual || 0), - totalVariance: acc.totalVariance + (task.variance || 0) - }; - - // If task has subtasks, recursively add their totals - if (task.sub_tasks && task.sub_tasks.length > 0) { - const subTaskTotals = calculateTaskTotalsRecursively(task.sub_tasks); - return { - totalEstimatedCost: taskTotals.totalEstimatedCost + subTaskTotals.totalEstimatedCost, - totalFixedCost: taskTotals.totalFixedCost + subTaskTotals.totalFixedCost, - totalBudget: taskTotals.totalBudget + subTaskTotals.totalBudget, - totalActualCost: taskTotals.totalActualCost + subTaskTotals.totalActualCost, - totalVariance: taskTotals.totalVariance + subTaskTotals.totalVariance - }; - } - - return taskTotals; - }, { + // Optimized calculation that avoids double counting in nested hierarchies + const calculateTaskTotalsFlat = (tasks: any[]): any => { + let totals = { totalEstimatedCost: 0, totalFixedCost: 0, totalBudget: 0, totalActualCost: 0, totalVariance: 0 - }); + }; + + for (const task of tasks) { + // For parent tasks with subtasks, only count the aggregated values (no double counting) + // For leaf tasks, count their individual values + if (task.sub_tasks && task.sub_tasks.length > 0) { + // Parent task - use its aggregated values which already include subtask totals + totals.totalEstimatedCost += task.estimated_cost || 0; + totals.totalFixedCost += task.fixed_cost || 0; + totals.totalBudget += task.total_budget || 0; + totals.totalActualCost += task.total_actual || 0; + totals.totalVariance += task.variance || 0; + } else { + // Leaf task - use its individual values + totals.totalEstimatedCost += task.estimated_cost || 0; + totals.totalFixedCost += task.fixed_cost || 0; + totals.totalBudget += task.total_budget || 0; + totals.totalActualCost += task.total_actual || 0; + totals.totalVariance += task.variance || 0; + } + } + + return totals; }; const totals = taskGroups.reduce((acc, group) => { - const groupTotals = calculateTaskTotalsRecursively(group.tasks); + const groupTotals = calculateTaskTotalsFlat(group.tasks); return { totalEstimatedCost: acc.totalEstimatedCost + groupTotals.totalEstimatedCost, totalFixedCost: acc.totalFixedCost + groupTotals.totalFixedCost, From c5bac36c533f33a1e7a78a0e2f30e4e369f6f54c Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Wed, 11 Jun 2025 12:28:25 +0530 Subject: [PATCH 101/105] feat(project-finance): enhance task cost tracking and UI updates - Added `actual_cost_from_logs` to task data structure for improved cost tracking. - Updated SQL queries in ProjectFinanceController to ensure accurate fixed cost updates and task hierarchy recalculations. - Enhanced the project finance slice to optimize task hierarchy recalculations, ensuring accurate financial data representation. - Modified FinanceTable component to reflect changes in cost calculations, preventing double counting and improving UI responsiveness. --- .../controllers/project-finance-controller.ts | 13 +- .../projects/finance/project-finance.slice.ts | 108 +++++++++------- .../finance-table/finance-table-wrapper.tsx | 4 +- .../finance-table/finance-table.tsx | 122 +++++++++--------- .../types/project/project-finance.types.ts | 1 + 5 files changed, 135 insertions(+), 113 deletions(-) diff --git a/worklenz-backend/src/controllers/project-finance-controller.ts b/worklenz-backend/src/controllers/project-finance-controller.ts index b5cfda45e..4199078a8 100644 --- a/worklenz-backend/src/controllers/project-finance-controller.ts +++ b/worklenz-backend/src/controllers/project-finance-controller.ts @@ -360,6 +360,7 @@ export default class ProjectfinanceController extends WorklenzControllerBase { Number(task.total_time_logged_seconds) || 0 ), estimated_cost: Number(task.estimated_cost) || 0, + actual_cost_from_logs: Number(task.actual_cost_from_logs) || 0, fixed_cost: Number(task.fixed_cost) || 0, total_budget: Number(task.total_budget) || 0, total_actual: Number(task.total_actual) || 0, @@ -426,14 +427,15 @@ export default class ProjectfinanceController extends WorklenzControllerBase { .send(new ServerResponse(false, null, "Cannot update fixed cost for parent tasks. Fixed cost is calculated from subtasks.")); } - const q = ` + // Update only the specific subtask's fixed cost + const updateQuery = ` 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]); + const result = await db.query(updateQuery, [fixed_cost, taskId]); if (result.rows.length === 0) { return res @@ -441,7 +443,10 @@ export default class ProjectfinanceController extends WorklenzControllerBase { .send(new ServerResponse(false, null, "Task not found")); } - return res.status(200).send(new ServerResponse(true, result.rows[0])); + return res.status(200).send(new ServerResponse(true, { + updated_task: result.rows[0], + message: "Fixed cost updated successfully." + })); } @HandleExceptions() @@ -839,6 +844,7 @@ export default class ProjectfinanceController extends WorklenzControllerBase { Number(task.total_time_logged_seconds) || 0 ), estimated_cost: Number(task.estimated_cost) || 0, + actual_cost_from_logs: Number(task.actual_cost_from_logs) || 0, fixed_cost: Number(task.fixed_cost) || 0, total_budget: Number(task.total_budget) || 0, total_actual: Number(task.total_actual) || 0, @@ -1161,6 +1167,7 @@ export default class ProjectfinanceController extends WorklenzControllerBase { Number(task.total_time_logged_seconds) || 0 ), estimated_cost: Number(task.estimated_cost) || 0, + actual_cost_from_logs: Number(task.actual_cost_from_logs) || 0, fixed_cost: Number(task.fixed_cost) || 0, total_budget: Number(task.total_budget) || 0, total_actual: Number(task.total_actual) || 0, diff --git a/worklenz-frontend/src/features/projects/finance/project-finance.slice.ts b/worklenz-frontend/src/features/projects/finance/project-finance.slice.ts index fdb571979..564379df1 100644 --- a/worklenz-frontend/src/features/projects/finance/project-finance.slice.ts +++ b/worklenz-frontend/src/features/projects/finance/project-finance.slice.ts @@ -78,52 +78,65 @@ const hasTaskChanged = (oldTask: IProjectFinanceTask, newTask: IProjectFinanceTa // Optimized recursive calculation for task hierarchy with memoization const recalculateTaskHierarchy = (tasks: IProjectFinanceTask[]): IProjectFinanceTask[] => { return tasks.map(task => { - const cacheKey = generateTaskCacheKey(task); - const cached = taskCalculationCache.get(cacheKey); - - // If task has subtasks, first recalculate all subtasks recursively + // If task has loaded subtasks, recalculate from subtasks if (task.sub_tasks && task.sub_tasks.length > 0) { const updatedSubTasks = recalculateTaskHierarchy(task.sub_tasks); - // Calculate parent task totals from subtasks + // Calculate totals from subtasks only (for time and costs from logs) const subtaskTotals = updatedSubTasks.reduce((acc, subtask) => ({ estimated_cost: acc.estimated_cost + (subtask.estimated_cost || 0), fixed_cost: acc.fixed_cost + (subtask.fixed_cost || 0), - total_actual: acc.total_actual + (subtask.total_actual || 0), + actual_cost_from_logs: acc.actual_cost_from_logs + (subtask.actual_cost_from_logs || 0), estimated_seconds: acc.estimated_seconds + (subtask.estimated_seconds || 0), total_time_logged_seconds: acc.total_time_logged_seconds + (subtask.total_time_logged_seconds || 0) }), { estimated_cost: 0, fixed_cost: 0, - total_actual: 0, + actual_cost_from_logs: 0, estimated_seconds: 0, total_time_logged_seconds: 0 }); + // For parent tasks with loaded subtasks: use ONLY the subtask totals + // The parent's original values were backend-aggregated, now we use frontend subtask aggregation + const totalFixedCost = subtaskTotals.fixed_cost; // Only subtask fixed costs + const totalEstimatedCost = subtaskTotals.estimated_cost; // Only subtask estimated costs + const totalActualCostFromLogs = subtaskTotals.actual_cost_from_logs; // Only subtask logged costs + const totalActual = totalActualCostFromLogs + totalFixedCost; + // Update parent task with aggregated values const updatedTask = { ...task, sub_tasks: updatedSubTasks, - estimated_cost: subtaskTotals.estimated_cost, - fixed_cost: subtaskTotals.fixed_cost, - total_actual: subtaskTotals.total_actual, + estimated_cost: totalEstimatedCost, + fixed_cost: totalFixedCost, + actual_cost_from_logs: totalActualCostFromLogs, + total_actual: totalActual, estimated_seconds: subtaskTotals.estimated_seconds, total_time_logged_seconds: subtaskTotals.total_time_logged_seconds, - total_budget: subtaskTotals.estimated_cost + subtaskTotals.fixed_cost, - variance: subtaskTotals.total_actual - (subtaskTotals.estimated_cost + subtaskTotals.fixed_cost) + total_budget: totalEstimatedCost + totalFixedCost, + variance: totalActual - (totalEstimatedCost + totalFixedCost) }; - // Cache the result - taskCalculationCache.set(cacheKey, { - task: { ...task }, - result: updatedTask, - timestamp: Date.now() - }); - return updatedTask; } + // For parent tasks without loaded subtasks, trust backend-calculated values + if (task.sub_tasks_count > 0 && (!task.sub_tasks || task.sub_tasks.length === 0)) { + // Parent task with unloaded subtasks - backend has already calculated aggregated values + const { totalBudget, totalActual, variance } = calculateTaskCosts(task); + return { + ...task, + total_budget: totalBudget, + total_actual: totalActual, + variance: variance + }; + } + // For leaf tasks, check cache first + const cacheKey = generateTaskCacheKey(task); + const cached = taskCalculationCache.get(cacheKey); + if (cached && !hasTaskChanged(cached.task, task)) { return { ...cached.result, ...task }; // Merge with current task to preserve other properties } @@ -137,7 +150,7 @@ const recalculateTaskHierarchy = (tasks: IProjectFinanceTask[]): IProjectFinance variance: variance }; - // Cache the result + // Cache the result only for leaf tasks taskCalculationCache.set(cacheKey, { task: { ...task }, result: updatedTask, @@ -340,7 +353,12 @@ export const projectFinancesSlice = createSlice({ }) .addCase(fetchProjectFinances.fulfilled, (state, action) => { state.loading = false; - state.taskGroups = action.payload.groups; + // Apply hierarchy recalculation to ensure parent tasks show correct aggregated values + const recalculatedGroups = action.payload.groups.map(group => ({ + ...group, + tasks: recalculateTaskHierarchy(group.tasks) + })); + state.taskGroups = recalculatedGroups; state.projectRateCards = action.payload.project_rate_cards; state.project = action.payload.project; // Clear cache when fresh data is loaded @@ -369,16 +387,20 @@ export const projectFinancesSlice = createSlice({ }); }; - // Update groups while preserving expansion state + // Update groups while preserving expansion state and applying hierarchy recalculation const updatedTaskGroups = action.payload.groups.map(newGroup => { const existingGroup = state.taskGroups.find(g => g.group_id === newGroup.group_id); if (existingGroup) { + const tasksWithExpansion = preserveExpansionState(existingGroup.tasks, newGroup.tasks); return { ...newGroup, - tasks: preserveExpansionState(existingGroup.tasks, newGroup.tasks) + tasks: recalculateTaskHierarchy(tasksWithExpansion) }; } - return newGroup; + return { + ...newGroup, + tasks: recalculateTaskHierarchy(newGroup.tasks) + }; }); // Update data without changing loading state for silent refresh @@ -393,30 +415,20 @@ export const projectFinancesSlice = createSlice({ const group = state.taskGroups.find(g => g.group_id === groupId); if (group) { - // Recursive function to find and update a task in the hierarchy - const findAndUpdateTask = (tasks: IProjectFinanceTask[], targetId: string): boolean => { - for (const task of tasks) { - if (task.id === targetId) { - task.fixed_cost = fixedCost; - // Recalculate financial values immediately for UI responsiveness - const totalBudget = (task.estimated_cost || 0) + fixedCost; - const totalActual = task.total_actual || 0; - const variance = totalActual - totalBudget; - - task.total_budget = totalBudget; - task.variance = variance; - return true; - } - - // Search in subtasks recursively - if (task.sub_tasks && findAndUpdateTask(task.sub_tasks, targetId)) { - return true; - } - } - return false; - }; + // Update the specific task's fixed cost and recalculate the entire hierarchy + const result = updateTaskAndRecalculateHierarchy( + group.tasks, + taskId, + (task) => ({ + ...task, + fixed_cost: fixedCost + }) + ); - findAndUpdateTask(group.tasks, taskId); + if (result.updated) { + group.tasks = result.tasks; + clearCalculationCache(); + } } }) .addCase(fetchSubTasks.fulfilled, (state, action) => { @@ -447,6 +459,8 @@ export const projectFinancesSlice = createSlice({ // Find the parent task in any group and add the subtasks for (const group of state.taskGroups) { if (findAndUpdateTask(group.tasks, parentTaskId)) { + // Recalculate the hierarchy after adding subtasks to ensure parent values are correct + group.tasks = recalculateTaskHierarchy(group.tasks); break; } } 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 index 475a761b5..0fc13c138 100644 --- 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 @@ -73,7 +73,7 @@ const FinanceTableWrapper: React.FC = ({ activeTablesL // Parent task - use its aggregated values which already include subtask totals return { hours: acc.hours + (task.estimated_seconds || 0), - cost: acc.cost + ((task.total_actual || 0) - (task.fixed_cost || 0)), + cost: acc.cost + (task.actual_cost_from_logs || 0), fixedCost: acc.fixedCost + (task.fixed_cost || 0), totalBudget: acc.totalBudget + (task.total_budget || 0), totalActual: acc.totalActual + (task.total_actual || 0), @@ -85,7 +85,7 @@ const FinanceTableWrapper: React.FC = ({ activeTablesL // Leaf task - use its individual values return { hours: acc.hours + (task.estimated_seconds || 0), - cost: acc.cost + ((task.total_actual || 0) - (task.fixed_cost || 0)), + cost: acc.cost + (task.actual_cost_from_logs || 0), fixedCost: acc.fixedCost + (task.fixed_cost || 0), totalBudget: acc.totalBudget + (task.total_budget || 0), totalActual: acc.totalActual + (task.total_actual || 0), 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 index 67098181b..46ecd9b15 100644 --- 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 @@ -48,8 +48,6 @@ const FinanceTable = ({ // Get the latest task groups from Redux store const taskGroups = useAppSelector((state) => state.projectFinances.taskGroups); - const activeGroup = useAppSelector((state) => state.projectFinances.activeGroup); - const billableFilter = useAppSelector((state) => state.projectFinances.billableFilter); // Auth and permissions const auth = useAuthService(); @@ -71,7 +69,7 @@ const FinanceTable = ({ useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if (selectedTask && !(event.target as Element)?.closest('.fixed-cost-input')) { - // Save current value before closing + // Save current value before closing if it has changed if (editingFixedCostValue !== null) { immediateSaveFixedCost(editingFixedCostValue, selectedTask.id); } else { @@ -85,7 +83,7 @@ const FinanceTable = ({ return () => { document.removeEventListener('mousedown', handleClickOutside); }; - }, [selectedTask, editingFixedCostValue]); + }, [selectedTask, editingFixedCostValue, tasks]); // Cleanup timeout on unmount useEffect(() => { @@ -169,9 +167,11 @@ const FinanceTable = ({ try { // Update the task fixed cost - this will automatically trigger hierarchical recalculation + // The Redux slice handles parent task updates through recalculateTaskHierarchy await dispatch(updateTaskFixedCostAsync({ taskId, groupId: table.group_id, fixedCost })).unwrap(); - // No need for manual parent calculations or API refetch - the Redux slice handles it efficiently + setSelectedTask(null); + setEditingFixedCostValue(null); } catch (error) { console.error('Failed to update fixed cost:', error); } @@ -211,7 +211,24 @@ const FinanceTable = ({ // Set new timeout saveTimeoutRef.current = setTimeout(() => { - if (value !== null) { + // Find the current task to check if value actually changed + const findTask = (tasks: IProjectFinanceTask[], id: string): IProjectFinanceTask | null => { + for (const task of tasks) { + if (task.id === id) return task; + if (task.sub_tasks) { + const found = findTask(task.sub_tasks, id); + if (found) return found; + } + } + return null; + }; + + const currentTask = findTask(tasks, taskId); + const currentFixedCost = currentTask?.fixed_cost || 0; + const newFixedCost = value || 0; + + // Only save if the value actually changed + if (newFixedCost !== currentFixedCost && value !== null) { handleFixedCostChange(value, taskId); setSelectedTask(null); setEditingFixedCostValue(null); @@ -227,11 +244,30 @@ const FinanceTable = ({ saveTimeoutRef.current = null; } - if (value !== null) { + // Find the current task to check if value actually changed + const findTask = (tasks: IProjectFinanceTask[], id: string): IProjectFinanceTask | null => { + for (const task of tasks) { + if (task.id === id) return task; + if (task.sub_tasks) { + const found = findTask(task.sub_tasks, id); + if (found) return found; + } + } + return null; + }; + + const currentTask = findTask(tasks, taskId); + const currentFixedCost = currentTask?.fixed_cost || 0; + const newFixedCost = value || 0; + + // Only save if the value actually changed + if (newFixedCost !== currentFixedCost && value !== null) { handleFixedCostChange(value, taskId); + } else { + // Just close the editor without saving + setSelectedTask(null); + setEditingFixedCostValue(null); } - setSelectedTask(null); - setEditingFixedCostValue(null); }; // Calculate indentation based on nesting level @@ -453,7 +489,7 @@ const FinanceTable = ({ case FinanceTableColumnKeys.TOTAL_ACTUAL: return {formatNumber(task.total_actual)}; case FinanceTableColumnKeys.COST: - return {formatNumber((task.total_actual || 0) - (task.fixed_cost || 0))}; + return {formatNumber(task.actual_cost_from_logs || 0)}; default: return null; } @@ -489,7 +525,7 @@ const FinanceTable = ({ // Calculate totals for the current table // Optimized calculation that avoids double counting in nested hierarchies const totals = useMemo(() => { - const calculateTaskTotalsFlat = (taskList: IProjectFinanceTask[]): any => { + const calculateTaskTotalsRecursive = (taskList: IProjectFinanceTask[]): any => { let totals = { hours: 0, total_time_logged: 0, @@ -502,24 +538,24 @@ const FinanceTable = ({ }; for (const task of taskList) { - // For parent tasks with subtasks, only count the aggregated values (no double counting) - // For leaf tasks, count their individual values if (task.sub_tasks && task.sub_tasks.length > 0) { - // Parent task - use its aggregated values which already include subtask totals - totals.hours += task.estimated_seconds || 0; - totals.total_time_logged += task.total_time_logged_seconds || 0; - totals.estimated_cost += task.estimated_cost || 0; - totals.actual_cost_from_logs += (task.total_actual || 0) - (task.fixed_cost || 0); - totals.fixed_cost += task.fixed_cost || 0; - totals.total_budget += task.total_budget || 0; - totals.total_actual += task.total_actual || 0; - totals.variance += task.variance || 0; + // Parent task with loaded subtasks - only count the subtasks recursively + // This completely avoids the parent's aggregated values to prevent double counting + const subtaskTotals = calculateTaskTotalsRecursive(task.sub_tasks); + totals.hours += subtaskTotals.hours; + totals.total_time_logged += subtaskTotals.total_time_logged; + totals.estimated_cost += subtaskTotals.estimated_cost; + totals.actual_cost_from_logs += subtaskTotals.actual_cost_from_logs; + totals.fixed_cost += subtaskTotals.fixed_cost; + totals.total_budget += subtaskTotals.total_budget; + totals.total_actual += subtaskTotals.total_actual; + totals.variance += subtaskTotals.variance; } else { - // Leaf task - use its individual values + // Leaf task or parent task without loaded subtasks - use its values directly totals.hours += task.estimated_seconds || 0; totals.total_time_logged += task.total_time_logged_seconds || 0; totals.estimated_cost += task.estimated_cost || 0; - totals.actual_cost_from_logs += (task.total_actual || 0) - (task.fixed_cost || 0); + totals.actual_cost_from_logs += task.actual_cost_from_logs || 0; totals.fixed_cost += task.fixed_cost || 0; totals.total_budget += task.total_budget || 0; totals.total_actual += task.total_actual || 0; @@ -530,7 +566,7 @@ const FinanceTable = ({ return totals; }; - return calculateTaskTotalsFlat(tasks); + return calculateTaskTotalsRecursive(tasks); }, [tasks]); // Format the totals for display @@ -602,42 +638,6 @@ const FinanceTable = ({ {/* task rows with recursive hierarchy */} {!isCollapse && flattenedTasks} - - {/* Group totals row */} - {!isCollapse && tasks.length > 0 && ( - - {financeTableColumns.map((col) => ( - - {col.key === FinanceTableColumnKeys.TASK ? ( - - Group Total - - ) : col.key === FinanceTableColumnKeys.MEMBERS ? null : ( - renderFinancialTableHeaderContent(col.key) - )} - - ))} - - )} ); }; diff --git a/worklenz-frontend/src/types/project/project-finance.types.ts b/worklenz-frontend/src/types/project/project-finance.types.ts index f52fea18d..554c24652 100644 --- a/worklenz-frontend/src/types/project/project-finance.types.ts +++ b/worklenz-frontend/src/types/project/project-finance.types.ts @@ -34,6 +34,7 @@ export interface IProjectFinanceTask { total_time_logged_seconds: number; total_time_logged: string; // Formatted time string like "4h 30m 12s" estimated_cost: number; + actual_cost_from_logs: number; members: IProjectFinanceMember[]; billable: boolean; fixed_cost: number; From c84034b43682470b1f63373b13624a9ad3776b67 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Wed, 11 Jun 2025 16:24:54 +0530 Subject: [PATCH 102/105] refactor(project-finance): streamline task cost calculations and prevent double counting - Removed fixed cost from budget calculations, as actual costs are now aggregated from logs and backend data. - Updated recursive functions in the FinanceTable and project finance slice to ensure accurate totals without double counting. - Enhanced comments for clarity on the calculation logic for parent and leaf tasks, improving maintainability. --- .../projects/finance/project-finance.slice.ts | 8 ++--- .../finance-table/finance-table-wrapper.tsx | 32 ++++++++++--------- .../finance-table/finance-table.tsx | 10 +++--- 3 files changed, 26 insertions(+), 24 deletions(-) diff --git a/worklenz-frontend/src/features/projects/finance/project-finance.slice.ts b/worklenz-frontend/src/features/projects/finance/project-finance.slice.ts index 564379df1..c76b5550c 100644 --- a/worklenz-frontend/src/features/projects/finance/project-finance.slice.ts +++ b/worklenz-frontend/src/features/projects/finance/project-finance.slice.ts @@ -23,9 +23,9 @@ const secondsToHours = (seconds: number) => seconds / 3600; const calculateTaskCosts = (task: IProjectFinanceTask) => { const hours = secondsToHours(task.estimated_seconds || 0); const timeLoggedHours = secondsToHours(task.total_time_logged_seconds || 0); - const fixedCost = task.fixed_cost || 0; - const totalBudget = (task.estimated_cost || 0) + fixedCost; + const totalBudget = task.estimated_cost || 0; + // task.total_actual already includes actual_cost_from_logs + fixed_cost from backend const totalActual = task.total_actual || 0; const variance = totalActual - totalBudget; @@ -114,8 +114,8 @@ const recalculateTaskHierarchy = (tasks: IProjectFinanceTask[]): IProjectFinance total_actual: totalActual, estimated_seconds: subtaskTotals.estimated_seconds, total_time_logged_seconds: subtaskTotals.total_time_logged_seconds, - total_budget: totalEstimatedCost + totalFixedCost, - variance: totalActual - (totalEstimatedCost + totalFixedCost) + total_budget: totalEstimatedCost, + variance: totalActual - totalEstimatedCost }; return updatedTask; 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 index 0fc13c138..251e6ff52 100644 --- 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 @@ -67,29 +67,31 @@ const FinanceTableWrapper: React.FC = ({ activeTablesL // Recursive function to calculate totals from task hierarchy without double counting const calculateTaskTotalsRecursively = (tasks: IProjectFinanceTask[]): any => { return tasks.reduce((acc, task) => { - // For parent tasks with subtasks, only count the aggregated values (no double counting) - // For leaf tasks, count their individual values + // For parent tasks with subtasks, aggregate values from subtasks only + // For leaf tasks, use their individual values if (task.sub_tasks && task.sub_tasks.length > 0) { - // Parent task - use its aggregated values which already include subtask totals + // Parent task - only use aggregated values from subtasks (no parent's own values) + const subtaskTotals = calculateTaskTotalsRecursively(task.sub_tasks); return { - hours: acc.hours + (task.estimated_seconds || 0), - cost: acc.cost + (task.actual_cost_from_logs || 0), - fixedCost: acc.fixedCost + (task.fixed_cost || 0), - totalBudget: acc.totalBudget + (task.total_budget || 0), - totalActual: acc.totalActual + (task.total_actual || 0), - variance: acc.variance + (task.variance || 0), - total_time_logged: acc.total_time_logged + (task.total_time_logged_seconds || 0), - estimated_cost: acc.estimated_cost + (task.estimated_cost || 0) + hours: acc.hours + subtaskTotals.hours, + cost: acc.cost + subtaskTotals.cost, + fixedCost: acc.fixedCost + subtaskTotals.fixedCost, + totalBudget: acc.totalBudget + subtaskTotals.totalBudget, + totalActual: acc.totalActual + subtaskTotals.totalActual, + variance: acc.variance + subtaskTotals.variance, + total_time_logged: acc.total_time_logged + subtaskTotals.total_time_logged, + estimated_cost: acc.estimated_cost + subtaskTotals.estimated_cost }; } else { - // Leaf task - use its individual values + // Leaf task - calculate values from individual task properties + const leafTotalActual = (task.actual_cost_from_logs || 0) + (task.fixed_cost || 0); return { hours: acc.hours + (task.estimated_seconds || 0), cost: acc.cost + (task.actual_cost_from_logs || 0), fixedCost: acc.fixedCost + (task.fixed_cost || 0), - totalBudget: acc.totalBudget + (task.total_budget || 0), - totalActual: acc.totalActual + (task.total_actual || 0), - variance: acc.variance + (task.variance || 0), + totalBudget: acc.totalBudget + (task.estimated_cost || 0), + totalActual: acc.totalActual + leafTotalActual, + variance: acc.variance + (leafTotalActual - (task.estimated_cost || 0)), total_time_logged: acc.total_time_logged + (task.total_time_logged_seconds || 0), estimated_cost: acc.estimated_cost + (task.estimated_cost || 0) }; 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 index 46ecd9b15..23a064f2a 100644 --- 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 @@ -539,8 +539,7 @@ const FinanceTable = ({ for (const task of taskList) { if (task.sub_tasks && task.sub_tasks.length > 0) { - // Parent task with loaded subtasks - only count the subtasks recursively - // This completely avoids the parent's aggregated values to prevent double counting + // Parent task with loaded subtasks - only use subtasks values (no parent's own values) const subtaskTotals = calculateTaskTotalsRecursive(task.sub_tasks); totals.hours += subtaskTotals.hours; totals.total_time_logged += subtaskTotals.total_time_logged; @@ -552,14 +551,15 @@ const FinanceTable = ({ totals.variance += subtaskTotals.variance; } else { // Leaf task or parent task without loaded subtasks - use its values directly + const leafTotalActual = (task.actual_cost_from_logs || 0) + (task.fixed_cost || 0); totals.hours += task.estimated_seconds || 0; totals.total_time_logged += task.total_time_logged_seconds || 0; totals.estimated_cost += task.estimated_cost || 0; totals.actual_cost_from_logs += task.actual_cost_from_logs || 0; totals.fixed_cost += task.fixed_cost || 0; - totals.total_budget += task.total_budget || 0; - totals.total_actual += task.total_actual || 0; - totals.variance += task.variance || 0; + totals.total_budget += task.estimated_cost || 0; + totals.total_actual += leafTotalActual; + totals.variance += leafTotalActual - (task.estimated_cost || 0); } } From a08d1efc36aba2b5d08a6bf83473db78217e2446 Mon Sep 17 00:00:00 2001 From: chamiakJ Date: Thu, 12 Jun 2025 16:08:19 +0530 Subject: [PATCH 103/105] fix(finance-table): correct budget and variance calculations for leaf tasks - Updated the calculation of total budget to include fixed costs for leaf tasks, ensuring accurate financial representation. - Adjusted variance calculations to reflect the new budget logic, preventing discrepancies in financial reporting. --- .../finance/finance-tab/finance-table/finance-table.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 index 23a064f2a..596bdd1c5 100644 --- 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 @@ -552,14 +552,15 @@ const FinanceTable = ({ } else { // Leaf task or parent task without loaded subtasks - use its values directly const leafTotalActual = (task.actual_cost_from_logs || 0) + (task.fixed_cost || 0); + const leafTotalBudget = (task.estimated_cost || 0) + (task.fixed_cost || 0); totals.hours += task.estimated_seconds || 0; totals.total_time_logged += task.total_time_logged_seconds || 0; totals.estimated_cost += task.estimated_cost || 0; totals.actual_cost_from_logs += task.actual_cost_from_logs || 0; totals.fixed_cost += task.fixed_cost || 0; - totals.total_budget += task.estimated_cost || 0; + totals.total_budget += leafTotalBudget; totals.total_actual += leafTotalActual; - totals.variance += leafTotalActual - (task.estimated_cost || 0); + totals.variance += leafTotalActual - leafTotalBudget; } } From 4783b5ec102b96b308bd60eadbf719e8fbfff544 Mon Sep 17 00:00:00 2001 From: chamiakJ Date: Thu, 12 Jun 2025 16:21:48 +0530 Subject: [PATCH 104/105] fix(finance-table): refine budget and variance calculations for improved accuracy - Updated budget calculations to consistently include fixed costs across all tasks. - Adjusted variance logic to align with the new budget calculations, ensuring accurate financial reporting. - Increased save delay for fixed cost input to 5 seconds, allowing users more time to edit values. - Added text selection on input focus for better user experience. --- .../finance-table/finance-table-wrapper.tsx | 5 ++-- .../finance-table/finance-table.tsx | 26 ++++++++++++------- 2 files changed, 20 insertions(+), 11 deletions(-) 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 index 251e6ff52..10acc136e 100644 --- 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 @@ -85,13 +85,14 @@ const FinanceTableWrapper: React.FC = ({ activeTablesL } else { // Leaf task - calculate values from individual task properties const leafTotalActual = (task.actual_cost_from_logs || 0) + (task.fixed_cost || 0); + const leafTotalBudget = (task.estimated_cost || 0) + (task.fixed_cost || 0); return { hours: acc.hours + (task.estimated_seconds || 0), cost: acc.cost + (task.actual_cost_from_logs || 0), fixedCost: acc.fixedCost + (task.fixed_cost || 0), - totalBudget: acc.totalBudget + (task.estimated_cost || 0), + totalBudget: acc.totalBudget + leafTotalBudget, totalActual: acc.totalActual + leafTotalActual, - variance: acc.variance + (leafTotalActual - (task.estimated_cost || 0)), + variance: acc.variance + (leafTotalActual - leafTotalBudget), total_time_logged: acc.total_time_logged + (task.total_time_logged_seconds || 0), estimated_cost: acc.estimated_cost + (task.estimated_cost || 0) }; 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 index 596bdd1c5..2423a4c66 100644 --- 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 @@ -230,10 +230,9 @@ const FinanceTable = ({ // Only save if the value actually changed if (newFixedCost !== currentFixedCost && value !== null) { handleFixedCostChange(value, taskId); - setSelectedTask(null); - setEditingFixedCostValue(null); + // Don't close the input automatically - let user explicitly close it } - }, 1000); // Save after 1 second of inactivity + }, 5000); // Save after 5 seconds of inactivity }; // Immediate save function (for enter/blur) @@ -442,6 +441,10 @@ const FinanceTable = ({ // Immediate save on enter immediateSaveFixedCost(editingFixedCostValue, task.id); }} + onFocus={(e) => { + // Select all text when input is focused + e.target.select(); + }} autoFocus style={{ width: '100%', textAlign: 'right', fontSize: Math.max(12, 14 - level * 0.5) }} formatter={(value) => `${value}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',')} @@ -472,22 +475,27 @@ const FinanceTable = ({ ); case FinanceTableColumnKeys.VARIANCE: + const taskTotalBudgetForVariance = (task.estimated_cost || 0) + (task.fixed_cost || 0); + const taskTotalActualForVariance = (task.actual_cost_from_logs || 0) + (task.fixed_cost || 0); + const taskVariance = taskTotalActualForVariance - taskTotalBudgetForVariance; return ( 0 ? '#FF0000' : '#6DC376', + color: taskVariance > 0 ? '#FF0000' : '#6DC376', fontSize: Math.max(12, 14 - level * 0.5) }} > - {task.variance < 0 ? '+' + formatNumber(Math.abs(task.variance)) : - task.variance > 0 ? '-' + formatNumber(task.variance) : - formatNumber(task.variance)} + {taskVariance < 0 ? '+' + formatNumber(Math.abs(taskVariance)) : + taskVariance > 0 ? '-' + formatNumber(taskVariance) : + formatNumber(taskVariance)} ); case FinanceTableColumnKeys.TOTAL_BUDGET: - return {formatNumber(task.total_budget)}; + const taskTotalBudget = (task.estimated_cost || 0) + (task.fixed_cost || 0); + return {formatNumber(taskTotalBudget)}; case FinanceTableColumnKeys.TOTAL_ACTUAL: - return {formatNumber(task.total_actual)}; + const taskTotalActual = (task.actual_cost_from_logs || 0) + (task.fixed_cost || 0); + return {formatNumber(taskTotalActual)}; case FinanceTableColumnKeys.COST: return {formatNumber(task.actual_cost_from_logs || 0)}; default: From ccb8e68192b91dd96df977e4e76d8ace6a356270 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Fri, 13 Jun 2025 09:50:43 +0530 Subject: [PATCH 105/105] feat(reporting): add billable and non-billable time tracking to member reports - Implemented SQL logic to calculate billable and non-billable time for team members in the reporting module. - Enhanced the reporting members table to display new time tracking metrics with appropriate headers and tooltips. - Created a new TimeLogsCell component to visually represent billable vs non-billable time with percentage breakdowns. - Updated localization files for English, Spanish, and Portuguese to include new terms related to time tracking. --- .../reporting/reporting-members-controller.ts | 55 ++++++++++++- .../public/locales/en/reporting-members.json | 7 +- .../public/locales/es/reporting-members.json | 7 +- .../public/locales/pt/reporting-members.json | 7 +- .../members-reports-table.tsx | 10 +++ .../tablesCells/timeLogsCell/TimeLogsCell.tsx | 79 +++++++++++++++++++ .../src/types/reporting/reporting.types.ts | 2 + 7 files changed, 160 insertions(+), 7 deletions(-) create mode 100644 worklenz-frontend/src/pages/reporting/members-reports/members-reports-table/tablesCells/timeLogsCell/TimeLogsCell.tsx diff --git a/worklenz-backend/src/controllers/reporting/reporting-members-controller.ts b/worklenz-backend/src/controllers/reporting/reporting-members-controller.ts index 975004372..44050265d 100644 --- a/worklenz-backend/src/controllers/reporting/reporting-members-controller.ts +++ b/worklenz-backend/src/controllers/reporting/reporting-members-controller.ts @@ -31,6 +31,7 @@ export default class ReportingMembersController extends ReportingControllerBase const completedDurationClasue = this.completedDurationFilter(key, dateRange); const overdueActivityLogsClause = this.getActivityLogsOverdue(key, dateRange); const activityLogCreationFilter = this.getActivityLogsCreationClause(key, dateRange); + const timeLogDateRangeClause = this.getTimeLogDateRangeClause(key, dateRange); const q = `SELECT COUNT(DISTINCT email) AS total, (SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(t))), '[]'::JSON) @@ -100,7 +101,25 @@ export default class ReportingMembersController extends ReportingControllerBase FROM tasks t LEFT JOIN tasks_assignees ta ON t.id = ta.task_id WHERE team_member_id = tmiv.team_member_id - AND is_doing((SELECT new_value FROM task_activity_logs tl WHERE tl.task_id = t.id AND tl.attribute_type = 'status' ${activityLogCreationFilter} ORDER BY tl.created_at DESC LIMIT 1)::UUID, t.project_id) ${archivedClause}) AS ongoing_by_activity_logs + AND is_doing((SELECT new_value FROM task_activity_logs tl WHERE tl.task_id = t.id AND tl.attribute_type = 'status' ${activityLogCreationFilter} ORDER BY tl.created_at DESC LIMIT 1)::UUID, t.project_id) ${archivedClause}) AS ongoing_by_activity_logs, + + (SELECT COALESCE(SUM(twl.time_spent), 0) + FROM task_work_log twl + LEFT JOIN tasks t ON twl.task_id = t.id + WHERE twl.user_id = (SELECT user_id FROM team_members WHERE id = tmiv.team_member_id) + AND t.billable IS TRUE + AND t.project_id IN (SELECT id FROM projects WHERE team_id = $1) + ${timeLogDateRangeClause} + ${includeArchived ? "" : `AND t.project_id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = t.project_id AND archived_projects.user_id = '${userId}')`}) AS billable_time, + + (SELECT COALESCE(SUM(twl.time_spent), 0) + FROM task_work_log twl + LEFT JOIN tasks t ON twl.task_id = t.id + WHERE twl.user_id = (SELECT user_id FROM team_members WHERE id = tmiv.team_member_id) + AND t.billable IS FALSE + AND t.project_id IN (SELECT id FROM projects WHERE team_id = $1) + ${timeLogDateRangeClause} + ${includeArchived ? "" : `AND t.project_id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = t.project_id AND archived_projects.user_id = '${userId}')`}) AS non_billable_time FROM team_member_info_view tmiv WHERE tmiv.team_id = $1 ${teamsClause} AND tmiv.team_member_id IN (SELECT team_member_id @@ -311,6 +330,30 @@ export default class ReportingMembersController extends ReportingControllerBase return ""; } + protected static getTimeLogDateRangeClause(key: string, dateRange: string[]) { + if (dateRange.length === 2) { + const start = moment(dateRange[0]).format("YYYY-MM-DD"); + const end = moment(dateRange[1]).format("YYYY-MM-DD"); + + if (start === end) { + return `AND twl.created_at::DATE = '${start}'::DATE`; + } + + return `AND twl.created_at::DATE >= '${start}'::DATE AND twl.created_at < '${end}'::DATE + INTERVAL '1 day'`; + } + + if (key === DATE_RANGES.YESTERDAY) + return `AND twl.created_at >= (CURRENT_DATE - INTERVAL '1 day')::DATE AND twl.created_at < CURRENT_DATE::DATE`; + if (key === DATE_RANGES.LAST_WEEK) + return `AND twl.created_at >= (CURRENT_DATE - INTERVAL '1 week')::DATE AND twl.created_at < CURRENT_DATE::DATE + INTERVAL '1 day'`; + if (key === DATE_RANGES.LAST_MONTH) + return `AND twl.created_at >= (CURRENT_DATE - INTERVAL '1 month')::DATE AND twl.created_at < CURRENT_DATE::DATE + INTERVAL '1 day'`; + if (key === DATE_RANGES.LAST_QUARTER) + return `AND twl.created_at >= (CURRENT_DATE - INTERVAL '3 months')::DATE AND twl.created_at < CURRENT_DATE::DATE + INTERVAL '1 day'`; + + return ""; + } + private static formatDuration(duration: moment.Duration) { const empty = "0h 0m"; let format = ""; @@ -423,6 +466,8 @@ export default class ReportingMembersController extends ReportingControllerBase { header: "Overdue Tasks", key: "overdue_tasks", width: 20 }, { header: "Completed Tasks", key: "completed_tasks", width: 20 }, { header: "Ongoing Tasks", key: "ongoing_tasks", width: 20 }, + { header: "Billable Time (seconds)", key: "billable_time", width: 25 }, + { header: "Non-Billable Time (seconds)", key: "non_billable_time", width: 25 }, { header: "Done Tasks(%)", key: "done_tasks", width: 20 }, { header: "Doing Tasks(%)", key: "doing_tasks", width: 20 }, { header: "Todo Tasks(%)", key: "todo_tasks", width: 20 } @@ -430,14 +475,14 @@ export default class ReportingMembersController extends ReportingControllerBase // set title sheet.getCell("A1").value = `Members from ${teamName}`; - sheet.mergeCells("A1:K1"); + sheet.mergeCells("A1:M1"); sheet.getCell("A1").alignment = { horizontal: "center" }; sheet.getCell("A1").style.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "D9D9D9" } }; sheet.getCell("A1").font = { size: 16 }; // set export date sheet.getCell("A2").value = `Exported on : ${exportDate}`; - sheet.mergeCells("A2:K2"); + sheet.mergeCells("A2:M2"); sheet.getCell("A2").alignment = { horizontal: "center" }; sheet.getCell("A2").style.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "F2F2F2" } }; sheet.getCell("A2").font = { size: 12 }; @@ -447,7 +492,7 @@ export default class ReportingMembersController extends ReportingControllerBase sheet.mergeCells("A3:D3"); // set table headers - sheet.getRow(5).values = ["Member", "Email", "Tasks Assigned", "Overdue Tasks", "Completed Tasks", "Ongoing Tasks", "Done Tasks(%)", "Doing Tasks(%)", "Todo Tasks(%)"]; + sheet.getRow(5).values = ["Member", "Email", "Tasks Assigned", "Overdue Tasks", "Completed Tasks", "Ongoing Tasks", "Billable Time (seconds)", "Non-Billable Time (seconds)", "Done Tasks(%)", "Doing Tasks(%)", "Todo Tasks(%)"]; sheet.getRow(5).font = { bold: true }; for (const member of result.members) { @@ -458,6 +503,8 @@ export default class ReportingMembersController extends ReportingControllerBase overdue_tasks: member.overdue, completed_tasks: member.completed, ongoing_tasks: member.ongoing, + billable_time: member.billable_time || 0, + non_billable_time: member.non_billable_time || 0, done_tasks: member.completed, doing_tasks: member.ongoing_by_activity_logs, todo_tasks: member.todo_by_activity_logs diff --git a/worklenz-frontend/public/locales/en/reporting-members.json b/worklenz-frontend/public/locales/en/reporting-members.json index a8035dcd5..637decfc5 100644 --- a/worklenz-frontend/public/locales/en/reporting-members.json +++ b/worklenz-frontend/public/locales/en/reporting-members.json @@ -31,5 +31,10 @@ "todoText": "To Do", "doingText": "Doing", - "doneText": "Done" + "doneText": "Done", + + "timeLogsColumn": "Time Logs", + "timeLogsColumnTooltip": "Shows the proportion of billable vs non-billable time", + "billable": "Billable", + "nonBillable": "Non-Billable" } diff --git a/worklenz-frontend/public/locales/es/reporting-members.json b/worklenz-frontend/public/locales/es/reporting-members.json index d87cafb8a..73887b7e4 100644 --- a/worklenz-frontend/public/locales/es/reporting-members.json +++ b/worklenz-frontend/public/locales/es/reporting-members.json @@ -31,5 +31,10 @@ "todoText": "Por Hacer", "doingText": "Haciendo", - "doneText": "Hecho" + "doneText": "Hecho", + + "timeLogsColumn": "Registros de Tiempo", + "timeLogsColumnTooltip": "Muestra la proporción de tiempo facturable vs no facturable", + "billable": "Facturable", + "nonBillable": "No Facturable" } diff --git a/worklenz-frontend/public/locales/pt/reporting-members.json b/worklenz-frontend/public/locales/pt/reporting-members.json index a8035dcd5..dfc68e897 100644 --- a/worklenz-frontend/public/locales/pt/reporting-members.json +++ b/worklenz-frontend/public/locales/pt/reporting-members.json @@ -31,5 +31,10 @@ "todoText": "To Do", "doingText": "Doing", - "doneText": "Done" + "doneText": "Done", + + "timeLogsColumn": "Registros de Tempo", + "timeLogsColumnTooltip": "Mostra a proporção de tempo faturável vs não faturável", + "billable": "Faturável", + "nonBillable": "Não Faturável" } diff --git a/worklenz-frontend/src/pages/reporting/members-reports/members-reports-table/members-reports-table.tsx b/worklenz-frontend/src/pages/reporting/members-reports/members-reports-table/members-reports-table.tsx index 26b261122..3658a7e37 100644 --- a/worklenz-frontend/src/pages/reporting/members-reports/members-reports-table/members-reports-table.tsx +++ b/worklenz-frontend/src/pages/reporting/members-reports/members-reports-table/members-reports-table.tsx @@ -6,6 +6,7 @@ import { useAppDispatch } from '@/hooks/useAppDispatch'; import CustomTableTitle from '@/components/CustomTableTitle'; import TasksProgressCell from './tablesCells/tasksProgressCell/TasksProgressCell'; import MemberCell from './tablesCells/memberCell/MemberCell'; +import TimeLogsCell from './tablesCells/timeLogsCell/TimeLogsCell'; import { fetchMembersData, setPagination, @@ -54,6 +55,15 @@ const MembersReportsTable = () => { return todo || doing || done ? : '-'; }, }, + { + key: 'timeLogs', + title: , + render: record => { + const billableTime = record.billable_time || 0; + const nonBillableTime = record.non_billable_time || 0; + return ; + }, + }, { key: 'tasksAssigned', title: ( diff --git a/worklenz-frontend/src/pages/reporting/members-reports/members-reports-table/tablesCells/timeLogsCell/TimeLogsCell.tsx b/worklenz-frontend/src/pages/reporting/members-reports/members-reports-table/tablesCells/timeLogsCell/TimeLogsCell.tsx new file mode 100644 index 000000000..84a10d5ed --- /dev/null +++ b/worklenz-frontend/src/pages/reporting/members-reports/members-reports-table/tablesCells/timeLogsCell/TimeLogsCell.tsx @@ -0,0 +1,79 @@ +import { Tooltip } from 'antd'; +import { useTranslation } from 'react-i18next'; + +interface TimeLogsCellProps { + billableTime: number; + nonBillableTime: number; +} + +const TimeLogsCell = ({ billableTime, nonBillableTime }: TimeLogsCellProps) => { + const { t } = useTranslation('reporting-members'); + const totalTime = billableTime + nonBillableTime; + + if (totalTime === 0) return '-'; + + const billablePercentage = Math.round((billableTime / totalTime) * 100); + const nonBillablePercentage = 100 - billablePercentage; + + // Ensure minimum visibility for very small percentages + const minWidth = 2; // minimum 2% width for visibility + const billableWidth = Math.max(billablePercentage, billablePercentage > 0 ? minWidth : 0); + const nonBillableWidth = Math.max(nonBillablePercentage, nonBillablePercentage > 0 ? minWidth : 0); + + // Format time in hours and minutes + const formatTime = (seconds: number) => { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + if (hours > 0) { + return `${hours}h ${minutes}m`; + } + return `${minutes}m`; + }; + + const tooltipContent = ( +
+
+
+ {t('billable')}: {formatTime(billableTime)} ({billablePercentage}%) +
+
+
+ {t('nonBillable')}: {formatTime(nonBillableTime)} ({nonBillablePercentage}%) +
+
+ Total: {formatTime(totalTime)} +
+
+ ); + + return ( + +
+
+ {/* Billable time section (green) */} + {billableTime > 0 && ( +
+ )} + {/* Non-billable time section (gray) */} + {nonBillableTime > 0 && ( +
+ )} + {/* Percentage text overlay */} +
+ + {billablePercentage}% + +
+
+
+ + ); +}; + +export default TimeLogsCell; \ No newline at end of file diff --git a/worklenz-frontend/src/types/reporting/reporting.types.ts b/worklenz-frontend/src/types/reporting/reporting.types.ts index a0ff7bf7c..ebebbdf43 100644 --- a/worklenz-frontend/src/types/reporting/reporting.types.ts +++ b/worklenz-frontend/src/types/reporting/reporting.types.ts @@ -136,6 +136,8 @@ export interface IRPTMember { ongoing: number; todo: number; member_teams: any; + billable_time?: number; + non_billable_time?: number; } export interface ISingleMemberLogs {