Merge pull request #146 from shancds/feature/reporting-time-members-filtter
Feature/reporting time members filtter
This commit is contained in:
@@ -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,31 +473,76 @@ 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, []);
|
||||
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;
|
||||
|
||||
// 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(1)
|
||||
: '0';
|
||||
|
||||
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()
|
||||
|
||||
@@ -40,5 +40,9 @@
|
||||
"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",
|
||||
"utilization": "Utilization"
|
||||
|
||||
}
|
||||
|
||||
@@ -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<IServerResponse<IRPTTimeMember[]>> => {
|
||||
getMemberTimeSheets: async (body = {}, archived = false): Promise<IServerResponse<IRPTTimeMemberViewModel>> => {
|
||||
const q = toQueryString({ archived });
|
||||
const response = await apiClient.post(`${rootUrl}/time-reports/members/${q}`, body);
|
||||
return response.data;
|
||||
|
||||
@@ -23,6 +23,12 @@ interface ITimeReportsOverviewState {
|
||||
billable: boolean;
|
||||
nonBillable: boolean;
|
||||
};
|
||||
|
||||
members: any[];
|
||||
loadingMembers: boolean;
|
||||
|
||||
utilization: any[];
|
||||
loadingUtilization: boolean;
|
||||
}
|
||||
|
||||
const initialState: ITimeReportsOverviewState = {
|
||||
@@ -42,6 +48,15 @@ const initialState: ITimeReportsOverviewState = {
|
||||
billable: true,
|
||||
nonBillable: true,
|
||||
},
|
||||
members: [],
|
||||
loadingMembers: false,
|
||||
|
||||
utilization: [],
|
||||
loadingUtilization: false,
|
||||
};
|
||||
|
||||
const selectedMembers = (state: ITimeReportsOverviewState) => {
|
||||
return state.members.filter(member => member.selected).map(member => member.id) as string[];
|
||||
};
|
||||
|
||||
const selectedTeams = (state: ITimeReportsOverviewState) => {
|
||||
@@ -54,6 +69,59 @@ 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 }) => {
|
||||
const state = getState() as { timeReportsOverviewReducer: ITimeReportsOverviewState };
|
||||
const { timeReportsOverviewReducer } = state;
|
||||
|
||||
try {
|
||||
const res = await reportingApiService.getMembers(selectedMembers(timeReportsOverviewReducer));
|
||||
if (res.done) {
|
||||
return res.body;
|
||||
} else {
|
||||
return rejectWithValue(res.message || 'Failed to fetch members');
|
||||
}
|
||||
} catch (error) {
|
||||
let errorMessage = 'An error occurred while fetching members';
|
||||
if (error instanceof Error) {
|
||||
errorMessage = error.message;
|
||||
}
|
||||
return rejectWithValue(errorMessage);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const fetchReportingTeams = createAsyncThunk(
|
||||
'timeReportsOverview/fetchReportingTeams',
|
||||
async () => {
|
||||
@@ -141,6 +209,31 @@ const timeReportsOverviewSlice = createSlice({
|
||||
setArchived: (state, action: PayloadAction<boolean>) => {
|
||||
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<boolean>) => {
|
||||
state.members.forEach(member => {
|
||||
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 => {
|
||||
builder.addCase(fetchReportingTeams.fulfilled, (state, action) => {
|
||||
@@ -185,6 +278,37 @@ const timeReportsOverviewSlice = createSlice({
|
||||
builder.addCase(fetchReportingProjects.rejected, state => {
|
||||
state.loadingProjects = false;
|
||||
});
|
||||
builder.addCase(fetchReportingMembers.fulfilled, (state, action) => {
|
||||
const members = action.payload.members.map((member: any) => ({
|
||||
id: member.id,
|
||||
name: member.name,
|
||||
selected: true,
|
||||
avatar_url: member.avatar_url,
|
||||
email: member.email,
|
||||
}));
|
||||
state.members = members;
|
||||
state.loadingMembers = false;
|
||||
});
|
||||
|
||||
builder.addCase(fetchReportingMembers.pending, state => {
|
||||
state.loadingMembers = true;
|
||||
});
|
||||
|
||||
builder.addCase(fetchReportingMembers.rejected, (state, action) => {
|
||||
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);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -197,6 +321,10 @@ export const {
|
||||
setSelectOrDeselectProject,
|
||||
setSelectOrDeselectAllProjects,
|
||||
setSelectOrDeselectBillable,
|
||||
setSelectOrDeselectMember,
|
||||
setSelectOrDeselectAllMembers,
|
||||
setSelectOrDeselectUtilization,
|
||||
setSelectOrDeselectAllUtilization,
|
||||
setNoCategory,
|
||||
setArchived,
|
||||
} = timeReportsOverviewSlice.actions;
|
||||
|
||||
@@ -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<MembersTimeSheetRef>((_, ref) => {
|
||||
const MembersTimeSheet = forwardRef<MembersTimeSheetRef, MembersTimeSheetProps>(({ onTotalsUpdate }, ref) => {
|
||||
const { t } = useTranslation('time-report');
|
||||
const dispatch = useAppDispatch();
|
||||
const chartRef = React.useRef<ChartJS<'bar', string[], unknown>>(null);
|
||||
@@ -35,6 +38,10 @@ const MembersTimeSheet = forwardRef<MembersTimeSheetRef>((_, ref) => {
|
||||
loadingCategories,
|
||||
projects: filterProjects,
|
||||
loadingProjects,
|
||||
members,
|
||||
loadingMembers,
|
||||
utilization,
|
||||
loadingUtilization,
|
||||
billable,
|
||||
archived,
|
||||
} = useAppSelector(state => state.timeReportsOverviewReducer);
|
||||
@@ -98,33 +105,26 @@ const MembersTimeSheet = forwardRef<MembersTimeSheetRef>((_, 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';
|
||||
} else if (percent <= 110) {
|
||||
status = 'Optimal';
|
||||
} else {
|
||||
status = 'Over';
|
||||
switch (member.utilization_state) {
|
||||
case 'under':
|
||||
color = '🟧';
|
||||
break;
|
||||
case 'optimal':
|
||||
color = '🟩';
|
||||
break;
|
||||
case 'over':
|
||||
color = '🟥';
|
||||
break;
|
||||
default:
|
||||
color = '';
|
||||
}
|
||||
return [
|
||||
`${context.dataset.label}: ${hours} h`,
|
||||
`Utilization: ${percent}%`,
|
||||
`${status} Utilized: ${overUnder} h`
|
||||
`${color} Utilization: ${percent}%`,
|
||||
`${member.utilization_state} 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';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -170,11 +170,14 @@ const MembersTimeSheet = forwardRef<MembersTimeSheetRef>((_, 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);
|
||||
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),
|
||||
utilization: selectedUtilization.map(item => item.id),
|
||||
duration,
|
||||
date_range: dateRange,
|
||||
billable,
|
||||
@@ -182,9 +185,18 @@ const MembersTimeSheet = forwardRef<MembersTimeSheetRef>((_, ref) => {
|
||||
|
||||
const res = await reportingTimesheetApiService.getMemberTimeSheets(body, archived);
|
||||
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);
|
||||
logger.error('Error fetching chart data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -193,7 +205,7 @@ const MembersTimeSheet = forwardRef<MembersTimeSheetRef>((_, ref) => {
|
||||
|
||||
useEffect(() => {
|
||||
fetchChartData();
|
||||
}, [dispatch, duration, dateRange, billable, archived, teams, filterProjects, categories]);
|
||||
}, [dispatch, duration, dateRange, billable, archived, teams, filterProjects, categories, members, utilization]);
|
||||
|
||||
const exportChart = () => {
|
||||
if (chartRef.current) {
|
||||
|
||||
@@ -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<MembersTimeSheetRef>(null);
|
||||
|
||||
const [totals, setTotals] = useState<IRPTTimeTotals>({
|
||||
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 (
|
||||
<Flex vertical>
|
||||
<TimeReportingRightHeader
|
||||
@@ -25,7 +35,7 @@ const MembersTimeReports = () => {
|
||||
exportType={[{ key: 'png', label: 'PNG' }]}
|
||||
export={handleExport}
|
||||
/>
|
||||
|
||||
<TotalTimeUtilization totals={totals} />
|
||||
<Card
|
||||
style={{ borderRadius: '4px' }}
|
||||
title={
|
||||
@@ -41,7 +51,7 @@ const MembersTimeReports = () => {
|
||||
},
|
||||
}}
|
||||
>
|
||||
<MembersTimeSheet ref={chartRef} />
|
||||
<MembersTimeSheet onTotalsUpdate={handleTotalsUpdate} ref={chartRef} />
|
||||
</Card>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
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) => {
|
||||
dispatch(setSelectOrDeselectMember({ id, selected: checked }));
|
||||
};
|
||||
|
||||
// Handle "Select All" checkbox change
|
||||
const handleSelectAllChange = (e: CheckboxChangeEvent) => {
|
||||
const isChecked = e.target.checked;
|
||||
setSelectAll(isChecked);
|
||||
dispatch(setSelectOrDeselectAllMembers(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 }}>
|
||||
<Input
|
||||
onClick={e => e.stopPropagation()}
|
||||
placeholder={t('searchByMember')}
|
||||
value={searchText}
|
||||
onChange={e => setSearchText(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ padding: '0 12px', flexShrink: 0 }}>
|
||||
<Checkbox
|
||||
onClick={e => e.stopPropagation()}
|
||||
onChange={handleSelectAllChange}
|
||||
checked={selectAll}
|
||||
>
|
||||
{t('selectAll')}
|
||||
</Checkbox>
|
||||
</div>
|
||||
<Divider style={{ margin: '4px 0', flexShrink: 0 }} />
|
||||
<div
|
||||
style={{
|
||||
overflowY: 'auto',
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{filteredMembers.map(member => (
|
||||
<div
|
||||
key={member.id}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
cursor: 'pointer',
|
||||
'&:hover': {
|
||||
backgroundColor: token.colorBgTextHover,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Avatar src={member.avatar_url} alt={member.name} />
|
||||
<Checkbox
|
||||
onClick={e => e.stopPropagation()}
|
||||
checked={member.selected}
|
||||
onChange={e => handleCheckboxChange(member.id, e.target.checked)}
|
||||
>
|
||||
{member.name}
|
||||
</Checkbox>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Button loading={loadingMembers}>
|
||||
{t('members')} <CaretDownFilled />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default Members;
|
||||
@@ -8,7 +8,11 @@ import {
|
||||
fetchReportingTeams,
|
||||
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();
|
||||
@@ -18,6 +22,8 @@ const TimeReportPageHeader: React.FC = () => {
|
||||
await dispatch(fetchReportingTeams());
|
||||
await dispatch(fetchReportingCategories());
|
||||
await dispatch(fetchReportingProjects());
|
||||
await dispatch(fetchReportingMembers());
|
||||
await dispatch(fetchReportingUtilization());
|
||||
};
|
||||
|
||||
fetchData();
|
||||
@@ -29,6 +35,8 @@ const TimeReportPageHeader: React.FC = () => {
|
||||
<Categories />
|
||||
<Projects />
|
||||
<Billable />
|
||||
<Members/>
|
||||
<Utilization />
|
||||
</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;
|
||||
@@ -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<TotalTimeUtilizationProps> = ({ totals }) => {
|
||||
return (
|
||||
<Flex gap={16} style={{ marginBottom: '16px' }}>
|
||||
<Card style={{ borderRadius: '4px', flex: 1 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 14, color: '#888' }}>Total Time Logs</div>
|
||||
<div style={{ fontSize: 24, fontWeight: 600 }}>{totals.total_time_logs}h</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card style={{ borderRadius: '4px', flex: 1 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 14, color: '#888' }}>Estimated Hours</div>
|
||||
<div style={{ fontSize: 24, fontWeight: 600 }}>{totals.total_estimated_hours}h</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card style={{ borderRadius: '4px', flex: 1 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 14, color: '#888' }}>Utilization (%)</div>
|
||||
<div style={{ fontSize: 24, fontWeight: 600 }}>{totals.total_utilization}%</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default TotalTimeUtilization;
|
||||
@@ -409,6 +409,16 @@ export interface IRPTTimeMember {
|
||||
utilized_hours?: string;
|
||||
utilization_percent?: string;
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user