feat(reporting): add total time utilization component and enhance localization

- Introduced a new TotalTimeUtilization component to display total time logged, expected capacity, and team utilization metrics.
- Updated existing time-report localization files to include new keys for total time logged, expected capacity, and utilization states across multiple languages.
- Refactored MembersTimeReports to integrate the new TotalTimeUtilization component, improving the reporting interface.
- Enhanced the overall structure and organization of the reporting components for better maintainability.
This commit is contained in:
chamikaJ
2025-07-24 11:41:04 +05:30
parent 20ce0c9687
commit 67a75685a9
11 changed files with 525 additions and 27 deletions

View File

@@ -46,5 +46,20 @@
"filterByBillableStatus": "按计费状态筛选", "filterByBillableStatus": "按计费状态筛选",
"searchByMember": "按成员搜索", "searchByMember": "按成员搜索",
"members": "成员", "members": "成员",
"utilization": "利用率" "utilization": "利用率",
"totalTimeLogged": "总记录时间",
"acrossAllTeamMembers": "跨所有团队成员",
"expectedCapacity": "预期容量",
"basedOnWorkingSchedule": "基于工作时间表",
"teamUtilization": "团队利用率",
"targetRange": "目标范围",
"variance": "差异",
"overCapacity": "超出容量",
"underCapacity": "容量不足",
"considerWorkloadRedistribution": "考虑工作负载重新分配",
"capacityAvailableForNewProjects": "可用于新项目的容量",
"optimal": "最佳",
"underUtilized": "利用率不足",
"overUtilized": "过度利用"
} }

View File

@@ -58,5 +58,20 @@
"showSelected": "Shfaq Vetëm të Zgjedhurat", "showSelected": "Shfaq Vetëm të Zgjedhurat",
"expandAll": "Zgjero të Gjitha", "expandAll": "Zgjero të Gjitha",
"collapseAll": "Mbyll të Gjitha", "collapseAll": "Mbyll të Gjitha",
"ungrouped": "Pa Grupuar" "ungrouped": "Pa Grupuar",
"totalTimeLogged": "Koha Totale e Regjistruar",
"acrossAllTeamMembers": "Në të gjithë anëtarët e ekipit",
"expectedCapacity": "Kapaciteti i Pritur",
"basedOnWorkingSchedule": "Bazuar në orarin e punës",
"teamUtilization": "Përdorimi i Ekipit",
"targetRange": "Gama e Objektivit",
"variance": "Varianca",
"overCapacity": "Mbi Kapacitetin",
"underCapacity": "Nën Kapacitetin",
"considerWorkloadRedistribution": "Konsidero rishpërndarjen e ngarkesës së punës",
"capacityAvailableForNewProjects": "Kapaciteti i disponueshëm për projekte të reja",
"optimal": "Optimal",
"underUtilized": "I Përdorur Pak",
"overUtilized": "I Përdorur Shumë"
} }

View File

@@ -58,5 +58,20 @@
"showSelected": "Nur Ausgewählte anzeigen", "showSelected": "Nur Ausgewählte anzeigen",
"expandAll": "Alle erweitern", "expandAll": "Alle erweitern",
"collapseAll": "Alle einklappen", "collapseAll": "Alle einklappen",
"ungrouped": "Nicht gruppiert" "ungrouped": "Nicht gruppiert",
"totalTimeLogged": "Gesamte erfasste Zeit",
"acrossAllTeamMembers": "Über alle Teammitglieder",
"expectedCapacity": "Erwartete Kapazität",
"basedOnWorkingSchedule": "Basierend auf Arbeitsplan",
"teamUtilization": "Team-Auslastung",
"targetRange": "Zielbereich",
"variance": "Abweichung",
"overCapacity": "Überkapazität",
"underCapacity": "Unterkapazität",
"considerWorkloadRedistribution": "Arbeitslast-Umverteilung erwägen",
"capacityAvailableForNewProjects": "Kapazität für neue Projekte verfügbar",
"optimal": "Optimal",
"underUtilized": "Unterausgelastet",
"overUtilized": "Überausgelastet"
} }

View File

@@ -59,5 +59,20 @@
"showSelected": "Show Selected Only", "showSelected": "Show Selected Only",
"expandAll": "Expand All", "expandAll": "Expand All",
"collapseAll": "Collapse All", "collapseAll": "Collapse All",
"ungrouped": "Ungrouped" "ungrouped": "Ungrouped",
"totalTimeLogged": "Total Time Logged",
"acrossAllTeamMembers": "Across all team members",
"expectedCapacity": "Expected Capacity",
"basedOnWorkingSchedule": "Based on working schedule",
"teamUtilization": "Team Utilization",
"targetRange": "Target Range",
"variance": "Variance",
"overCapacity": "Over Capacity",
"underCapacity": "Under Capacity",
"considerWorkloadRedistribution": "Consider workload redistribution",
"capacityAvailableForNewProjects": "Capacity available for new projects",
"optimal": "Optimal",
"underUtilized": "Under Utilized",
"overUtilized": "Over Utilized"
} }

View File

@@ -59,5 +59,20 @@
"showSelected": "Mostrar Solo Seleccionados", "showSelected": "Mostrar Solo Seleccionados",
"expandAll": "Expandir Todo", "expandAll": "Expandir Todo",
"collapseAll": "Contraer Todo", "collapseAll": "Contraer Todo",
"ungrouped": "Sin Agrupar" "ungrouped": "Sin Agrupar",
"totalTimeLogged": "Tiempo Total Registrado",
"acrossAllTeamMembers": "En todos los miembros del equipo",
"expectedCapacity": "Capacidad Esperada",
"basedOnWorkingSchedule": "Basado en el horario de trabajo",
"teamUtilization": "Utilización del Equipo",
"targetRange": "Rango Objetivo",
"variance": "Varianza",
"overCapacity": "Sobre Capacidad",
"underCapacity": "Bajo Capacidad",
"considerWorkloadRedistribution": "Considerar redistribución de carga de trabajo",
"capacityAvailableForNewProjects": "Capacidad disponible para nuevos proyectos",
"optimal": "Óptimo",
"underUtilized": "Subutilizado",
"overUtilized": "Sobreutilizado"
} }

View File

@@ -59,5 +59,20 @@
"showSelected": "Mostrar Apenas Selecionados", "showSelected": "Mostrar Apenas Selecionados",
"expandAll": "Expandir Tudo", "expandAll": "Expandir Tudo",
"collapseAll": "Recolher Tudo", "collapseAll": "Recolher Tudo",
"ungrouped": "Não Agrupado" "ungrouped": "Não Agrupado",
"totalTimeLogged": "Tempo Total Registrado",
"acrossAllTeamMembers": "Em todos os membros da equipe",
"expectedCapacity": "Capacidade Esperada",
"basedOnWorkingSchedule": "Baseado no cronograma de trabalho",
"teamUtilization": "Utilização da Equipe",
"targetRange": "Faixa Alvo",
"variance": "Variância",
"overCapacity": "Sobre Capacidade",
"underCapacity": "Abaixo da Capacidade",
"considerWorkloadRedistribution": "Considerar redistribuição de carga de trabalho",
"capacityAvailableForNewProjects": "Capacidade disponível para novos projetos",
"optimal": "Ótimo",
"underUtilized": "Subutilizado",
"overUtilized": "Sobreutilizado"
} }

View File

@@ -47,5 +47,20 @@
"showSelected": "仅显示已选择", "showSelected": "仅显示已选择",
"expandAll": "全部展开", "expandAll": "全部展开",
"collapseAll": "全部折叠", "collapseAll": "全部折叠",
"ungrouped": "未分组" "ungrouped": "未分组",
"totalTimeLogged": "总记录时间",
"acrossAllTeamMembers": "跨所有团队成员",
"expectedCapacity": "预期容量",
"basedOnWorkingSchedule": "基于工作时间表",
"teamUtilization": "团队利用率",
"targetRange": "目标范围",
"variance": "差异",
"overCapacity": "超出容量",
"underCapacity": "容量不足",
"considerWorkloadRedistribution": "考虑工作负载重新分配",
"capacityAvailableForNewProjects": "可用于新项目的容量",
"optimal": "最佳",
"underUtilized": "利用率不足",
"overUtilized": "过度利用"
} }

View File

@@ -0,0 +1,381 @@
import {
Card,
Flex,
Progress,
Tooltip,
ClockCircleOutlined,
CalendarOutlined,
ArrowUpOutlined,
ArrowDownOutlined,
CheckCircleOutlined,
} from '@/shared/antd-imports';
import React, { useMemo } from 'react';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useTranslation } from 'react-i18next';
import { IRPTTimeTotals } from '@/types/reporting/reporting.types';
interface TotalTimeUtilizationProps {
totals: IRPTTimeTotals;
}
const TotalTimeUtilization: React.FC<TotalTimeUtilizationProps> = ({ totals }) => {
const { t } = useTranslation('time-report');
const themeMode = useAppSelector(state => state.themeReducer.mode);
const isDark = themeMode === 'dark';
const utilizationData = useMemo(() => {
const timeLogged = parseFloat(totals.total_time_logs || '0');
const estimatedHours = parseFloat(totals.total_estimated_hours || '0');
const utilizationPercent = parseFloat(totals.total_utilization || '0');
// Determine utilization status and color
let status: 'under' | 'optimal' | 'over' = 'optimal';
let statusColor = '#52c41a'; // Green
let statusIcon = <CheckCircleOutlined />;
let statusText = t('optimal');
if (utilizationPercent < 90) {
status = 'under';
statusColor = '#faad14'; // Orange
statusIcon = <ArrowDownOutlined />;
statusText = t('underUtilized');
} else if (utilizationPercent > 110) {
status = 'over';
statusColor = '#ff4d4f'; // Red
statusIcon = <ArrowUpOutlined />;
statusText = t('overUtilized');
}
return {
timeLogged,
estimatedHours,
utilizationPercent,
status,
statusColor,
statusIcon,
statusText,
};
}, [totals, t]);
const getThemeColors = useMemo(
() => ({
cardBackground: isDark ? '#1f1f1f' : '#ffffff',
cardBorder: isDark ? '#303030' : '#f0f0f0',
cardShadow: isDark ? '0 2px 8px rgba(0, 0, 0, 0.3)' : '0 2px 8px rgba(0, 0, 0, 0.06)',
cardHoverShadow: isDark ? '0 4px 16px rgba(0, 0, 0, 0.5)' : '0 4px 16px rgba(0, 0, 0, 0.12)',
primaryText: isDark ? '#ffffff' : '#262626',
secondaryText: isDark ? '#bfbfbf' : '#8c8c8c',
tertiaryText: isDark ? '#8c8c8c' : '#595959',
iconBackgrounds: {
blue: isDark ? '#0f1419' : '#e6f7ff',
green: isDark ? '#0f1b0f' : '#f6ffed',
},
iconColors: {
blue: isDark ? '#40a9ff' : '#1890ff',
green: isDark ? '#73d13d' : '#52c41a',
},
progressTrail: isDark ? '#262626' : '#f5f5f5',
varianceBackgrounds: {
positive: isDark ? '#0f1b0f' : '#f6ffed',
negative: isDark ? '#1f0f0f' : '#fff2f0',
},
varianceColors: {
positive: isDark ? '#73d13d' : '#389e0d',
negative: isDark ? '#ff7875' : '#a8071a',
},
}),
[isDark]
);
const cardStyle = {
borderRadius: '8px',
flex: 1,
boxShadow: getThemeColors.cardShadow,
border: `1px solid ${getThemeColors.cardBorder}`,
backgroundColor: getThemeColors.cardBackground,
transition: 'all 0.3s ease',
};
return (
<Flex gap={16} style={{ marginBottom: '16px' }}>
{/* Total Time Logs Card */}
<Card
style={cardStyle}
styles={{
body: { padding: '20px' },
}}
>
<Flex align="center" gap={12}>
<div
style={{
width: 48,
height: 48,
borderRadius: '12px',
backgroundColor: getThemeColors.iconBackgrounds.blue,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '20px',
color: getThemeColors.iconColors.blue,
}}
>
<ClockCircleOutlined />
</div>
<div style={{ flex: 1 }}>
<div
style={{
fontSize: 12,
color: getThemeColors.secondaryText,
fontWeight: 500,
textTransform: 'uppercase',
letterSpacing: '0.5px',
marginBottom: '4px',
}}
>
{t('totalTimeLogged')}
</div>
<div
style={{
fontSize: 28,
fontWeight: 700,
color: getThemeColors.primaryText,
lineHeight: 1,
}}
>
{totals.total_time_logs}h
</div>
<div
style={{
fontSize: 11,
color: getThemeColors.tertiaryText,
marginTop: '2px',
}}
>
{t('acrossAllTeamMembers')}
</div>
</div>
</Flex>
</Card>
{/* Estimated Hours Card */}
<Card
style={cardStyle}
styles={{
body: { padding: '20px' },
}}
>
<Flex align="center" gap={12}>
<div
style={{
width: 48,
height: 48,
borderRadius: '12px',
backgroundColor: getThemeColors.iconBackgrounds.green,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '20px',
color: getThemeColors.iconColors.green,
}}
>
<CalendarOutlined />
</div>
<div style={{ flex: 1 }}>
<div
style={{
fontSize: 12,
color: getThemeColors.secondaryText,
fontWeight: 500,
textTransform: 'uppercase',
letterSpacing: '0.5px',
marginBottom: '4px',
}}
>
{t('expectedCapacity')}
</div>
<div
style={{
fontSize: 28,
fontWeight: 700,
color: getThemeColors.primaryText,
lineHeight: 1,
}}
>
{totals.total_estimated_hours}h
</div>
<div
style={{
fontSize: 11,
color: getThemeColors.tertiaryText,
marginTop: '2px',
}}
>
{t('basedOnWorkingSchedule')}
</div>
</div>
</Flex>
</Card>
{/* Utilization Card with Progress */}
<Card
style={{
...cardStyle,
borderColor: utilizationData.statusColor,
borderWidth: '2px',
}}
styles={{
body: { padding: '20px' },
}}
>
<Flex align="center" gap={12}>
<div
style={{
width: 48,
height: 48,
borderRadius: '12px',
backgroundColor: `${utilizationData.statusColor}${isDark ? '20' : '15'}`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '20px',
color: utilizationData.statusColor,
}}
>
{utilizationData.statusIcon}
</div>
<div style={{ flex: 1 }}>
<Flex justify="space-between" align="center" style={{ marginBottom: '4px' }}>
<div
style={{
fontSize: 12,
color: getThemeColors.secondaryText,
fontWeight: 500,
textTransform: 'uppercase',
letterSpacing: '0.5px',
}}
>
{t('teamUtilization')}
</div>
<Tooltip title={`${utilizationData.statusText} (${t('targetRange')})`}>
<div
style={{
fontSize: 10,
color: utilizationData.statusColor,
fontWeight: 600,
backgroundColor: `${utilizationData.statusColor}${isDark ? '20' : '15'}`,
padding: '2px 6px',
borderRadius: '4px',
textTransform: 'uppercase',
}}
>
{utilizationData.statusText}
</div>
</Tooltip>
</Flex>
<div
style={{
fontSize: 28,
fontWeight: 700,
color: utilizationData.statusColor,
lineHeight: 1,
marginBottom: '8px',
}}
>
{totals.total_utilization}%
</div>
<Progress
percent={Math.min(utilizationData.utilizationPercent, 150)} // Cap at 150% for display
strokeColor={{
'0%': utilizationData.statusColor,
'100%': utilizationData.statusColor,
}}
trailColor={getThemeColors.progressTrail}
strokeWidth={6}
showInfo={false}
style={{ marginBottom: '4px' }}
/>
<Flex
justify="space-between"
style={{ fontSize: 10, color: getThemeColors.secondaryText }}
>
<span>0%</span>
<span style={{ color: '#52c41a' }}>90% - 110%</span>
<span>150%+</span>
</Flex>
</div>
</Flex>
</Card>
{/* Additional Insights Card */}
<Card
style={cardStyle}
styles={{
body: { padding: '20px' },
}}
>
<div style={{ textAlign: 'center' }}>
<div
style={{
fontSize: 12,
color: getThemeColors.secondaryText,
fontWeight: 500,
textTransform: 'uppercase',
letterSpacing: '0.5px',
marginBottom: '8px',
}}
>
{t('variance')}
</div>
<div
style={{
fontSize: 24,
fontWeight: 700,
color:
utilizationData.timeLogged > utilizationData.estimatedHours
? getThemeColors.varianceColors.negative
: getThemeColors.varianceColors.positive,
lineHeight: 1,
marginBottom: '4px',
}}
>
{utilizationData.timeLogged > utilizationData.estimatedHours ? '+' : ''}
{(utilizationData.timeLogged - utilizationData.estimatedHours).toFixed(1)}h
</div>
<div
style={{
fontSize: 11,
color: getThemeColors.tertiaryText,
}}
>
{utilizationData.timeLogged > utilizationData.estimatedHours
? t('overCapacity')
: t('underCapacity')}
</div>
<div
style={{
marginTop: '8px',
padding: '4px 8px',
borderRadius: '4px',
backgroundColor:
utilizationData.timeLogged > utilizationData.estimatedHours
? getThemeColors.varianceBackgrounds.negative
: getThemeColors.varianceBackgrounds.positive,
fontSize: 10,
color:
utilizationData.timeLogged > utilizationData.estimatedHours
? getThemeColors.varianceColors.negative
: getThemeColors.varianceColors.positive,
fontWeight: 500,
}}
>
{utilizationData.timeLogged > utilizationData.estimatedHours
? t('considerWorkloadRedistribution')
: t('capacityAvailableForNewProjects')}
</div>
</div>
</Card>
</Flex>
);
};
export default TotalTimeUtilization;

View File

@@ -1,17 +1,21 @@
import { Card, Flex } from '@/shared/antd-imports'; import { Card, Flex } from 'antd';
import TimeReportPageHeader from '@/components/reporting/time-reports/page-header/TimeReportPageHeader'; import MembersTimeSheet, { MembersTimeSheetRef } from '@/pages/reporting/time-reports/members-time-sheet/members-time-sheet';
import MembersTimeSheet, {
MembersTimeSheetRef,
} from '@/pages/reporting/time-reports/members-time-sheet/members-time-sheet';
import TimeReportingRightHeader from '@/components/reporting/time-reports/right-header/TimeReportingRightHeader';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useDocumentTitle } from '@/hooks/useDoumentTItle'; import { useDocumentTitle } from '@/hooks/useDoumentTItle';
import { useRef } from 'react'; import { useRef, useState } from 'react';
import { IRPTTimeTotals } from '@/types/reporting/reporting.types';
import TimeReportingRightHeader from '@/components/reporting/time-reports/right-header/TimeReportingRightHeader';
import TimeReportPageHeader from '@/components/reporting/time-reports/page-header/TimeReportPageHeader';
import TotalTimeUtilization from '@/components/reporting/time-reports/total-time-utilization/total-time-utilization';
const MembersTimeReports = () => { const MembersTimeReports = () => {
const { t } = useTranslation('time-report'); const { t } = useTranslation('time-report');
const chartRef = useRef<MembersTimeSheetRef>(null); const chartRef = useRef<MembersTimeSheetRef>(null);
const [totals, setTotals] = useState<IRPTTimeTotals>({
total_time_logs: "0",
total_estimated_hours: "0",
total_utilization: "0",
});
useDocumentTitle('Reporting - Allocation'); useDocumentTitle('Reporting - Allocation');
const handleExport = (type: string) => { const handleExport = (type: string) => {
@@ -20,24 +24,18 @@ const MembersTimeReports = () => {
} }
}; };
const handleTotalsUpdate = (totals: { const handleTotalsUpdate = (newTotals: IRPTTimeTotals) => {
total_time_logs: string; setTotals(newTotals);
total_estimated_hours: string;
total_utilization: string;
}) => {
// Handle totals update if needed
// This could be used to display totals in the UI or pass to parent components
console.log('Totals updated:', totals);
}; };
return ( return (
<Flex vertical> <Flex vertical>
<TimeReportingRightHeader <TimeReportingRightHeader
title={t('membersTimeSheet')} title={t('Members Time Sheet')}
exportType={[{ key: 'png', label: 'PNG' }]} exportType={[{ key: 'png', label: 'PNG' }]}
export={handleExport} export={handleExport}
/> />
<TotalTimeUtilization totals={totals} />
<Card <Card
style={{ borderRadius: '4px' }} style={{ borderRadius: '4px' }}
title={ title={
@@ -53,7 +51,7 @@ const MembersTimeReports = () => {
}, },
}} }}
> >
<MembersTimeSheet ref={chartRef} onTotalsUpdate={handleTotalsUpdate} /> <MembersTimeSheet onTotalsUpdate={handleTotalsUpdate} ref={chartRef} />
</Card> </Card>
</Flex> </Flex>
); );

View File

@@ -181,7 +181,9 @@ export {
InfoCircleOutlined, InfoCircleOutlined,
WarningTwoTone, WarningTwoTone,
ShareAltOutlined, ShareAltOutlined,
CloudDownloadOutlined CloudDownloadOutlined,
ArrowUpOutlined,
ArrowDownOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
// Re-export all components with React // Re-export all components with React

View File

@@ -136,6 +136,8 @@ export interface IRPTMember {
ongoing: number; ongoing: number;
todo: number; todo: number;
member_teams: any; member_teams: any;
billable_time?: number;
non_billable_time?: number;
} }
export interface ISingleMemberLogs { export interface ISingleMemberLogs {
@@ -409,6 +411,16 @@ 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 IRPTTimeTotals {
total_estimated_hours?: string;
total_time_logs?: string;
total_utilization?: string;
}
export interface IRPTTimeMemberViewModel {
filteredRows?: IRPTTimeMember[];
totals?: IRPTTimeTotals;
} }
export interface IMemberTaskStatGroupResonse { export interface IMemberTaskStatGroupResonse {