From 549728cdafff962e2280be6da90439a975a32840 Mon Sep 17 00:00:00 2001 From: shancds Date: Thu, 29 May 2025 08:24:16 +0530 Subject: [PATCH 1/8] 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 4db8e3d5..32513b1e 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 1529d46b..0c74203e 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 9518495b..9de106e6 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 4caa7f49..310f8880 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 00000000..fd9a03e4 --- /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 20c3e152..5b4a4b7c 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 2/8] 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 0c74203e..1529d46b 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 9de106e6..25c7ede7 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 fd9a03e4..499a8b1a 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 3/8] 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 b5da8dd2..e2e67ac9 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 4/8] 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 310f8880..f44f711c 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 5/8] feat(reporting): enhance utilization tracking and filtering in time reports --- .../reporting-allocation-controller.ts | 38 +++++-- .../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, 233 insertions(+), 26 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 32513b1e..9add9696 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, []); + const utilization = (req.body.utilization || []) as string[]; - for (const member of result.rows) { - member.value = member.logged_time ? parseFloat(moment.duration(member.logged_time, "seconds").asHours().toFixed(2)) : 0; + // 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 e2e67ac9..dc1f1fe0 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 25c7ede7..af0c8904 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 f44f711c..b5096183 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 5b4a4b7c..4b7ab36d 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 00000000..14ed2f2e --- /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 aa36069c..6ca74391 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 6/8] 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 af0c8904..81ad17c9 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 1f6bbce0ae7d9ead30cce99eb08098d70e245617 Mon Sep 17 00:00:00 2001 From: shancds Date: Thu, 29 May 2025 17:50:11 +0530 Subject: [PATCH 7/8] 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 9add9696..98feafd9 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 1529d46b..488a0619 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 b5096183..4c517925 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 cdbcebd1..e777eed6 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 00000000..ce760d4c --- /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 6ca74391..a0ff7bf7 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 8/8] 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 98feafd9..962530f9 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 4c517925..e7021893 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 || []);