feat(reporting): enhance utilization tracking and filtering in time reports
This commit is contained in:
@@ -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()
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user