feat(reporting): enhance utilization tracking and filtering in time reports

This commit is contained in:
shancds
2025-05-29 15:38:25 +05:30
parent f1920c17b4
commit b94c56f50d
7 changed files with 233 additions and 26 deletions

View File

@@ -492,19 +492,43 @@ export default class ReportingAllocationController extends ReportingControllerBa
GROUP BY tmiv.email, tmiv.name, tmiv.team_member_id GROUP BY tmiv.email, tmiv.name, tmiv.team_member_id
ORDER BY logged_time DESC;`; ORDER BY logged_time DESC;`;
const result = await db.query(q, []); const result = await db.query(q, []);
const utilization = (req.body.utilization || []) as string[];
for (const member of result.rows) { // Precompute totalWorkingHours * 3600 for efficiency
member.value = member.logged_time ? parseFloat(moment.duration(member.logged_time, "seconds").asHours().toFixed(2)) : 0; 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.color_code = getColor(member.name);
member.total_working_hours = totalWorkingHours; 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.utilization_percent = utilizationPercent.toFixed(2);
member.utilized_hours = member.logged_time ? (parseFloat(member.logged_time) / 3600).toFixed(2) : '0.00'; member.utilized_hours = utilizedHours.toFixed(2);
// 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.over_under_utilized_hours = overUnder.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() @HandleExceptions()

View File

@@ -42,5 +42,7 @@
"noTeams": "No teams found", "noTeams": "No teams found",
"noData": "No data found", "noData": "No data found",
"members": "Members", "members": "Members",
"searchByMember": "Search by member" "searchByMember": "Search by member",
"utilization": "Utilization"
} }

View File

@@ -26,6 +26,9 @@ interface ITimeReportsOverviewState {
members: any[]; members: any[];
loadingMembers: boolean; loadingMembers: boolean;
utilization: any[];
loadingUtilization: boolean;
} }
const initialState: ITimeReportsOverviewState = { const initialState: ITimeReportsOverviewState = {
@@ -47,6 +50,9 @@ const initialState: ITimeReportsOverviewState = {
}, },
members: [], members: [],
loadingMembers: false, loadingMembers: false,
utilization: [],
loadingUtilization: false,
}; };
const selectedMembers = (state: ITimeReportsOverviewState) => { const selectedMembers = (state: ITimeReportsOverviewState) => {
@@ -63,6 +69,36 @@ const selectedCategories = (state: ITimeReportsOverviewState) => {
.map(category => category.id) as string[]; .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( export const fetchReportingMembers = createAsyncThunk(
'timeReportsOverview/fetchReportingMembers', 'timeReportsOverview/fetchReportingMembers',
async (_, { rejectWithValue, getState }) => { async (_, { rejectWithValue, getState }) => {
@@ -78,7 +114,11 @@ export const fetchReportingMembers = createAsyncThunk(
return rejectWithValue(res.message || 'Failed to fetch members'); return rejectWithValue(res.message || 'Failed to fetch members');
} }
} catch (error) { } 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; 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<boolean>) => {
state.utilization.forEach(utilization => {
utilization.selected = action.payload;
});
},
}, },
extraReducers: builder => { extraReducers: builder => {
builder.addCase(fetchReportingTeams.fulfilled, (state, action) => { builder.addCase(fetchReportingTeams.fulfilled, (state, action) => {
@@ -229,8 +283,8 @@ const timeReportsOverviewSlice = createSlice({
const members = action.payload.map((member: any) => ({ const members = action.payload.map((member: any) => ({
id: member.id, id: member.id,
name: member.name, name: member.name,
selected: true, selected: true,
avatar_url: member.avatar_url, avatar_url: member.avatar_url,
email: member.email, email: member.email,
})); }));
state.members = members; state.members = members;
@@ -245,6 +299,17 @@ const timeReportsOverviewSlice = createSlice({
state.loadingMembers = false; state.loadingMembers = false;
console.error('Error fetching members:', action.payload); 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, setSelectOrDeselectBillable,
setSelectOrDeselectMember, setSelectOrDeselectMember,
setSelectOrDeselectAllMembers, setSelectOrDeselectAllMembers,
setSelectOrDeselectUtilization,
setSelectOrDeselectAllUtilization,
setNoCategory, setNoCategory,
setArchived, setArchived,
} = timeReportsOverviewSlice.actions; } = timeReportsOverviewSlice.actions;

View File

@@ -37,6 +37,8 @@ const MembersTimeSheet = forwardRef<MembersTimeSheetRef>((_, ref) => {
loadingProjects, loadingProjects,
members, members,
loadingMembers, loadingMembers,
utilization,
loadingUtilization,
billable, billable,
archived, archived,
} = useAppSelector(state => state.timeReportsOverviewReducer); } = useAppSelector(state => state.timeReportsOverviewReducer);
@@ -100,22 +102,24 @@ const MembersTimeSheet = forwardRef<MembersTimeSheetRef>((_, ref) => {
const hours = member?.utilized_hours || '0.00'; const hours = member?.utilized_hours || '0.00';
const percent = parseFloat(member?.utilization_percent || '0.00'); const percent = parseFloat(member?.utilization_percent || '0.00');
const overUnder = member?.over_under_utilized_hours || '0.00'; const overUnder = member?.over_under_utilized_hours || '0.00';
let status = '';
let color = ''; let color = '';
if (percent < 90) { switch (member.utilization_state) {
status = 'Under'; case 'under':
color = '🟧'; color = '🟧';
} else if (percent <= 110) { break;
status = 'Optimal'; case 'optimal':
color = '🟩'; color = '🟩';
} else { break;
status = 'Over'; case 'over':
color = '🟥'; color = '🟥';
break;
default:
color = '';
} }
return [ return [
`${context.dataset.label}: ${hours} h`, `${context.dataset.label}: ${hours} h`,
`${color} Utilization: ${percent}%`, `${color} Utilization: ${percent}%`,
`${status} Utilized: ${overUnder} h` `${member.utilization_state} Utilized: ${overUnder} h`
]; ];
}, },
} }
@@ -163,13 +167,14 @@ const MembersTimeSheet = forwardRef<MembersTimeSheetRef>((_, ref) => {
const selectedTeams = teams.filter(team => team.selected); const selectedTeams = teams.filter(team => team.selected);
const selectedProjects = filterProjects.filter(project => project.selected); const selectedProjects = filterProjects.filter(project => project.selected);
const selectedCategories = categories.filter(category => category.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 = { const body = {
teams: selectedTeams.map(t => t.id), teams: selectedTeams.map(t => t.id),
projects: selectedProjects.map(project => project.id), projects: selectedProjects.map(project => project.id),
categories: selectedCategories.map(category => category.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, duration,
date_range: dateRange, date_range: dateRange,
billable, billable,
@@ -189,7 +194,7 @@ const MembersTimeSheet = forwardRef<MembersTimeSheetRef>((_, ref) => {
useEffect(() => { useEffect(() => {
fetchChartData(); fetchChartData();
}, [dispatch, duration, dateRange, billable, archived, teams, filterProjects, categories, members]); }, [dispatch, duration, dateRange, billable, archived, teams, filterProjects, categories, members, utilization]);
const exportChart = () => { const exportChart = () => {
if (chartRef.current) { if (chartRef.current) {

View File

@@ -9,8 +9,10 @@ import {
fetchReportingProjects, fetchReportingProjects,
fetchReportingCategories, fetchReportingCategories,
fetchReportingMembers, fetchReportingMembers,
fetchReportingUtilization,
} from '@/features/reporting/time-reports/time-reports-overview.slice'; } from '@/features/reporting/time-reports/time-reports-overview.slice';
import Members from './members'; import Members from './members';
import Utilization from './utilization';
const TimeReportPageHeader: React.FC = () => { const TimeReportPageHeader: React.FC = () => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
@@ -21,6 +23,7 @@ const TimeReportPageHeader: React.FC = () => {
await dispatch(fetchReportingCategories()); await dispatch(fetchReportingCategories());
await dispatch(fetchReportingProjects()); await dispatch(fetchReportingProjects());
await dispatch(fetchReportingMembers()); await dispatch(fetchReportingMembers());
await dispatch(fetchReportingUtilization());
}; };
fetchData(); fetchData();
@@ -33,6 +36,7 @@ const TimeReportPageHeader: React.FC = () => {
<Projects /> <Projects />
<Billable /> <Billable />
<Members/> <Members/>
<Utilization />
</div> </div>
); );
}; };

View File

@@ -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 (
<Dropdown
menu={undefined}
placement="bottomLeft"
trigger={['click']}
dropdownRender={() => (
<div
style={{
background: token.colorBgContainer,
borderRadius: token.borderRadius,
boxShadow: token.boxShadow,
padding: '4px 0',
maxHeight: '330px',
display: 'flex',
flexDirection: 'column',
}}
>
<div style={{ padding: '8px', flexShrink: 0 }}>
</div>
<div style={{ padding: '0 12px', flexShrink: 0 }}>
<Checkbox
onClick={e => e.stopPropagation()}
onChange={handleSelectAll}
checked={selectAll}
>
{t('selectAll')}
</Checkbox>
</div>
<Divider style={{ margin: '4px 0', flexShrink: 0 }} />
<div
style={{
overflowY: 'auto',
flex: 1,
}}
>
{filteredItems.map((ut, index) => (
<div
key={index}
style={{
padding: '8px 12px',
display: 'flex',
alignItems: 'center',
gap: '8px',
cursor: 'pointer',
'&:hover': {
backgroundColor: token.colorBgTextHover,
},
}}
>
<Checkbox
onClick={e => e.stopPropagation()}
checked={ut.selected}
onChange={e => handleCheckboxChange(ut.id, e.target.checked)}
>
{ut.name}
</Checkbox>
</div>
))}
</div>
</div>
)}
>
<Button loading={loadingUtilization}>
{t('utilization')} <CaretDownFilled />
</Button>
</Dropdown>
);
};
export default Utilization;

View File

@@ -409,6 +409,7 @@ export interface IRPTTimeMember {
utilized_hours?: string; utilized_hours?: string;
utilization_percent?: string; utilization_percent?: string;
over_under_utilized_hours?: string; over_under_utilized_hours?: string;
utilization_state?: string;
} }
export interface IMemberTaskStatGroupResonse { export interface IMemberTaskStatGroupResonse {