feat(reporting): enhance time reports page with new filters and components
- Added new components for filtering by billable status, categories, projects, members, and teams in the time reports overview. - Implemented a new header component to manage the layout and functionality of the time reports page. - Refactored existing components to improve organization and maintainability, including the removal of deprecated files. - Updated localization files to support new UI elements and ensure consistency across languages. - Adjusted the language selector to reflect the correct language codes for Chinese.
This commit is contained in:
@@ -15,17 +15,19 @@ import { useTranslation } from 'react-i18next';
|
||||
import { reportingTimesheetApiService } from '@/api/reporting/reporting.timesheet.api.service';
|
||||
import { IRPTTimeMember } from '@/types/reporting/reporting.types';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
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);
|
||||
|
||||
const {
|
||||
@@ -35,8 +37,13 @@ const MembersTimeSheet = forwardRef<MembersTimeSheetRef>((_, ref) => {
|
||||
loadingCategories,
|
||||
projects: filterProjects,
|
||||
loadingProjects,
|
||||
members,
|
||||
loadingMembers,
|
||||
utilization,
|
||||
loadingUtilization,
|
||||
billable,
|
||||
archived,
|
||||
noCategory,
|
||||
} = useAppSelector(state => state.timeReportsOverviewReducer);
|
||||
const { duration, dateRange } = useAppSelector(state => state.reportingReducer);
|
||||
|
||||
@@ -44,16 +51,40 @@ const MembersTimeSheet = forwardRef<MembersTimeSheetRef>((_, ref) => {
|
||||
const [jsonData, setJsonData] = useState<IRPTTimeMember[]>([]);
|
||||
|
||||
const labels = Array.isArray(jsonData) ? jsonData.map(item => item.name) : [];
|
||||
const dataValues = Array.isArray(jsonData)
|
||||
? jsonData.map(item => {
|
||||
const loggedTimeInHours = parseFloat(item.logged_time || '0') / 3600;
|
||||
return loggedTimeInHours.toFixed(2);
|
||||
})
|
||||
: [];
|
||||
const colors = Array.isArray(jsonData) ? jsonData.map(item => item.color_code) : [];
|
||||
const dataValues = Array.isArray(jsonData) ? jsonData.map(item => {
|
||||
const loggedTimeInHours = parseFloat(item.logged_time || '0') / 3600;
|
||||
return loggedTimeInHours.toFixed(2);
|
||||
}) : [];
|
||||
const colors = Array.isArray(jsonData) ? jsonData.map(item => {
|
||||
const utilizationPercent = parseFloat(item.utilization_percent || '0');
|
||||
|
||||
if (utilizationPercent < 90) {
|
||||
return '#faad14'; // Orange for under-utilized (< 90%)
|
||||
} else if (utilizationPercent <= 110) {
|
||||
return '#52c41a'; // Green for optimal utilization (90-110%)
|
||||
} else {
|
||||
return '#ef4444'; // Red for over-utilized (> 110%)
|
||||
}
|
||||
}) : [];
|
||||
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
|
||||
// Helper function to format hours to "X hours Y mins"
|
||||
const formatHours = (decimalHours: number) => {
|
||||
const wholeHours = Math.floor(decimalHours);
|
||||
const minutes = Math.round((decimalHours - wholeHours) * 60);
|
||||
|
||||
if (wholeHours === 0 && minutes === 0) {
|
||||
return '0 mins';
|
||||
} else if (wholeHours === 0) {
|
||||
return `${minutes} mins`;
|
||||
} else if (minutes === 0) {
|
||||
return `${wholeHours} ${wholeHours === 1 ? 'hour' : 'hours'}`;
|
||||
} else {
|
||||
return `${wholeHours} ${wholeHours === 1 ? 'hour' : 'hours'} ${minutes} mins`;
|
||||
}
|
||||
};
|
||||
|
||||
// Chart data
|
||||
const data = {
|
||||
labels,
|
||||
@@ -78,27 +109,96 @@ const MembersTimeSheet = forwardRef<MembersTimeSheetRef>((_, ref) => {
|
||||
offset: 20,
|
||||
textStrokeColor: 'black',
|
||||
textStrokeWidth: 4,
|
||||
formatter: function(value: string) {
|
||||
const hours = parseFloat(value);
|
||||
const wholeHours = Math.floor(hours);
|
||||
const minutes = Math.round((hours - wholeHours) * 60);
|
||||
|
||||
if (wholeHours === 0 && minutes === 0) {
|
||||
return '0 mins';
|
||||
} else if (wholeHours === 0) {
|
||||
return `${minutes} mins`;
|
||||
} else if (minutes === 0) {
|
||||
return `${wholeHours} ${wholeHours === 1 ? 'hour' : 'hours'}`;
|
||||
} else {
|
||||
return `${wholeHours} ${wholeHours === 1 ? 'hour' : 'hours'} ${minutes} mins`;
|
||||
}
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
display: false,
|
||||
position: 'top' as const,
|
||||
},
|
||||
tooltip: {
|
||||
// Basic styling
|
||||
backgroundColor: themeMode === 'dark' ? 'rgba(0, 0, 0, 0.9)' : 'rgba(255, 255, 255, 0.9)',
|
||||
titleColor: themeMode === 'dark' ? '#ffffff' : '#000000',
|
||||
bodyColor: themeMode === 'dark' ? '#ffffff' : '#000000',
|
||||
borderColor: themeMode === 'dark' ? '#4a5568' : '#e2e8f0',
|
||||
cornerRadius: 8,
|
||||
padding: 12,
|
||||
|
||||
// Remove colored squares
|
||||
displayColors: false,
|
||||
|
||||
// Positioning - better alignment for horizontal bar chart
|
||||
xAlign: 'left' as const,
|
||||
yAlign: 'center' as const,
|
||||
|
||||
callbacks: {
|
||||
// Customize the title (member name)
|
||||
title: function (context: any) {
|
||||
const idx = context[0].dataIndex;
|
||||
const member = jsonData[idx];
|
||||
return `👤 ${member?.name || 'Unknown Member'}`;
|
||||
},
|
||||
|
||||
// Customize the label content
|
||||
label: function (context: any) {
|
||||
const idx = context.dataIndex;
|
||||
const member = jsonData[idx];
|
||||
const hours = member?.utilized_hours || '0.00';
|
||||
const percent = member?.utilization_percent || '0.00';
|
||||
const overUnder = member?.over_under_utilized_hours || '0.00';
|
||||
const hours = parseFloat(member?.utilized_hours || '0');
|
||||
const percent = parseFloat(member?.utilization_percent || '0.00');
|
||||
const overUnder = parseFloat(member?.over_under_utilized_hours || '0');
|
||||
|
||||
// Color indicators based on utilization state
|
||||
let statusText = '';
|
||||
let criteriaText = '';
|
||||
switch (member.utilization_state) {
|
||||
case 'under':
|
||||
statusText = '🟠 Under-Utilized';
|
||||
criteriaText = '(< 90%)';
|
||||
break;
|
||||
case 'optimal':
|
||||
statusText = '🟢 Optimally Utilized';
|
||||
criteriaText = '(90% - 110%)';
|
||||
break;
|
||||
case 'over':
|
||||
statusText = '🔴 Over-Utilized';
|
||||
criteriaText = '(> 110%)';
|
||||
break;
|
||||
default:
|
||||
statusText = '⚪ Unknown';
|
||||
criteriaText = '';
|
||||
}
|
||||
|
||||
return [
|
||||
`${context.dataset.label}: ${hours} h`,
|
||||
`Utilization: ${percent}%`,
|
||||
`Over/Under Utilized: ${overUnder} h`,
|
||||
`⏱️ ${context.dataset.label}: ${formatHours(hours)}`,
|
||||
`📊 Utilization: ${percent.toFixed(1)}%`,
|
||||
`${statusText} ${criteriaText}`,
|
||||
`📈 Variance: ${formatHours(Math.abs(overUnder))}${overUnder < 0 ? ' (under)' : overUnder > 0 ? ' (over)' : ''}`
|
||||
];
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// Add a footer with additional info
|
||||
footer: function (context: any) {
|
||||
const idx = context[0].dataIndex;
|
||||
const member = jsonData[idx];
|
||||
const loggedTime = parseFloat(member?.logged_time || '0') / 3600;
|
||||
return `📊 Total Logged: ${formatHours(loggedTime)}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
backgroundColor: 'black',
|
||||
indexAxis: 'y' as const,
|
||||
@@ -142,30 +242,93 @@ 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);
|
||||
|
||||
// Format dates using date-fns
|
||||
const formattedDateRange = dateRange ? [
|
||||
format(new Date(dateRange[0]), 'yyyy-MM-dd'),
|
||||
format(new Date(dateRange[1]), 'yyyy-MM-dd')
|
||||
] : undefined;
|
||||
|
||||
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,
|
||||
date_range: formattedDateRange,
|
||||
billable,
|
||||
noCategory,
|
||||
};
|
||||
|
||||
const res = await reportingTimesheetApiService.getMemberTimeSheets(body, archived);
|
||||
|
||||
if (res.done) {
|
||||
setJsonData(res.body || []);
|
||||
// Ensure filteredRows is always an array, even if API returns null/undefined
|
||||
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);
|
||||
} else {
|
||||
// Handle API error case
|
||||
setJsonData([]);
|
||||
onTotalsUpdate({
|
||||
total_time_logs: "0",
|
||||
total_estimated_hours: "0",
|
||||
total_utilization: "0"
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching chart data:', error);
|
||||
logger.error('Error fetching chart data:', error);
|
||||
// Reset data on error
|
||||
setJsonData([]);
|
||||
onTotalsUpdate({
|
||||
total_time_logs: "0",
|
||||
total_estimated_hours: "0",
|
||||
total_utilization: "0"
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Create stable references for selected items to prevent unnecessary re-renders
|
||||
const selectedTeamIds = React.useMemo(() =>
|
||||
teams.filter(team => team.selected).map(t => t.id).join(','),
|
||||
[teams]
|
||||
);
|
||||
|
||||
const selectedProjectIds = React.useMemo(() =>
|
||||
filterProjects.filter(project => project.selected).map(p => p.id).join(','),
|
||||
[filterProjects]
|
||||
);
|
||||
|
||||
const selectedCategoryIds = React.useMemo(() =>
|
||||
categories.filter(category => category.selected).map(c => c.id).join(','),
|
||||
[categories]
|
||||
);
|
||||
|
||||
const selectedMemberIds = React.useMemo(() =>
|
||||
members.filter(member => member.selected).map(m => m.id).join(','),
|
||||
[members]
|
||||
);
|
||||
|
||||
const selectedUtilizationIds = React.useMemo(() =>
|
||||
utilization.filter(item => item.selected).map(u => u.id).join(','),
|
||||
[utilization]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchChartData();
|
||||
}, [dispatch, duration, dateRange, billable, archived, teams, filterProjects, categories]);
|
||||
}, [duration, dateRange, billable, archived, noCategory, selectedTeamIds, selectedProjectIds, selectedCategoryIds, selectedMemberIds, selectedUtilizationIds]);
|
||||
|
||||
const exportChart = () => {
|
||||
if (chartRef.current) {
|
||||
@@ -197,7 +360,7 @@ const MembersTimeSheet = forwardRef<MembersTimeSheetRef>((_, ref) => {
|
||||
};
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
exportChart,
|
||||
exportChart
|
||||
}));
|
||||
|
||||
return (
|
||||
@@ -218,4 +381,4 @@ const MembersTimeSheet = forwardRef<MembersTimeSheetRef>((_, ref) => {
|
||||
|
||||
MembersTimeSheet.displayName = 'MembersTimeSheet';
|
||||
|
||||
export default MembersTimeSheet;
|
||||
export default MembersTimeSheet;
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Card, Flex, Segmented } from '@/shared/antd-imports';
|
||||
import TimeReportPageHeader from '@/pages/reporting/timeReports/page-header/time-report-page-header';
|
||||
import TimeReportPageHeader from '@/components/reporting/time-reports/page-header/TimeReportPageHeader';
|
||||
import EstimatedVsActualTimeSheet, {
|
||||
EstimatedVsActualTimeSheetRef,
|
||||
} from '@/pages/reporting/time-reports/estimated-vs-actual-time-sheet/estimated-vs-actual-time-sheet';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDocumentTitle } from '@/hooks/useDoumentTItle';
|
||||
import TimeReportingRightHeader from './timeReportingRightHeader/TimeReportingRightHeader';
|
||||
import TimeReportingRightHeader from '@/components/reporting/time-reports/right-header/TimeReportingRightHeader';
|
||||
import { useState, useRef } from 'react';
|
||||
|
||||
const EstimatedVsActualTimeReports = () => {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Card, Flex } from '@/shared/antd-imports';
|
||||
import TimeReportPageHeader from '@/pages/reporting/timeReports/page-header/time-report-page-header';
|
||||
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 TimeReportingRightHeader from './timeReportingRightHeader/TimeReportingRightHeader';
|
||||
import TimeReportingRightHeader from '@/components/reporting/time-reports/right-header/TimeReportingRightHeader';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDocumentTitle } from '@/hooks/useDoumentTItle';
|
||||
import { useRef } from 'react';
|
||||
@@ -20,10 +20,20 @@ const MembersTimeReports = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleTotalsUpdate = (totals: {
|
||||
total_time_logs: string;
|
||||
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 (
|
||||
<Flex vertical>
|
||||
<TimeReportingRightHeader
|
||||
title={t('Members Time Sheet')}
|
||||
title={t('membersTimeSheet')}
|
||||
exportType={[{ key: 'png', label: 'PNG' }]}
|
||||
export={handleExport}
|
||||
/>
|
||||
@@ -43,7 +53,7 @@ const MembersTimeReports = () => {
|
||||
},
|
||||
}}
|
||||
>
|
||||
<MembersTimeSheet ref={chartRef} />
|
||||
<MembersTimeSheet ref={chartRef} onTotalsUpdate={handleTotalsUpdate} />
|
||||
</Card>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React from 'react';
|
||||
import TimeReportPageHeader from '@/pages/reporting/timeReports/page-header/time-report-page-header';
|
||||
import TimeReportPageHeader from '@/components/reporting/time-reports/page-header/TimeReportPageHeader';
|
||||
import { Flex } from '@/shared/antd-imports';
|
||||
import TimeSheetTable from '@/pages/reporting/time-reports/time-sheet-table/time-sheet-table';
|
||||
import TimeReportingRightHeader from './timeReportingRightHeader/TimeReportingRightHeader';
|
||||
import TimeReportingRightHeader from '@/components/reporting/time-reports/right-header/TimeReportingRightHeader';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDocumentTitle } from '@/hooks/useDoumentTItle';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import { setSelectOrDeselectBillable } from '@/features/reporting/time-reports/time-reports-overview.slice';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { CaretDownFilled } from '@/shared/antd-imports';
|
||||
import { Button, Checkbox, Dropdown, MenuProps } from '@/shared/antd-imports';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const Billable: React.FC = () => {
|
||||
const { t } = useTranslation('time-report');
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const { billable } = useAppSelector(state => state.timeReportsOverviewReducer);
|
||||
|
||||
// Dropdown items for the menu
|
||||
const menuItems: MenuProps['items'] = [
|
||||
{
|
||||
key: 'search',
|
||||
label: <Checkbox checked={billable.billable}>{t('billable')}</Checkbox>,
|
||||
onClick: () => {
|
||||
dispatch(setSelectOrDeselectBillable({ ...billable, billable: !billable.billable }));
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'selectAll',
|
||||
label: <Checkbox checked={billable.nonBillable}>{t('nonBillable')}</Checkbox>,
|
||||
onClick: () => {
|
||||
dispatch(setSelectOrDeselectBillable({ ...billable, nonBillable: !billable.nonBillable }));
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Dropdown
|
||||
menu={{ items: menuItems }}
|
||||
placement="bottomLeft"
|
||||
trigger={['click']}
|
||||
overlayStyle={{ maxHeight: '330px', overflowY: 'auto' }}
|
||||
>
|
||||
<Button>
|
||||
{t('billable')} <CaretDownFilled />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Billable;
|
||||
@@ -1,143 +0,0 @@
|
||||
import {
|
||||
fetchReportingProjects,
|
||||
setNoCategory,
|
||||
setSelectOrDeselectAllCategories,
|
||||
setSelectOrDeselectCategory,
|
||||
} from '@/features/reporting/time-reports/time-reports-overview.slice';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { CaretDownFilled } from '@/shared/antd-imports';
|
||||
import { Button, Card, Checkbox, Divider, Dropdown, Input, theme } from '@/shared/antd-imports';
|
||||
import { CheckboxChangeEvent } from 'antd/es/checkbox';
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const Categories: React.FC = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [selectAll, setSelectAll] = useState(true);
|
||||
const { t } = useTranslation('time-report');
|
||||
const [dropdownVisible, setDropdownVisible] = useState(false);
|
||||
const { categories, loadingCategories, noCategory } = useAppSelector(
|
||||
state => state.timeReportsOverviewReducer
|
||||
);
|
||||
const { token } = theme.useToken();
|
||||
|
||||
const filteredItems = categories.filter(item =>
|
||||
item.name?.toLowerCase().includes(searchText.toLowerCase())
|
||||
);
|
||||
|
||||
// Handle checkbox change for individual items
|
||||
const handleCheckboxChange = async (key: string, checked: boolean) => {
|
||||
await dispatch(setSelectOrDeselectCategory({ id: key, selected: checked }));
|
||||
await dispatch(fetchReportingProjects());
|
||||
};
|
||||
|
||||
// Handle "Select All" checkbox change
|
||||
const handleSelectAllChange = async (e: CheckboxChangeEvent) => {
|
||||
const isChecked = e.target.checked;
|
||||
setSelectAll(isChecked);
|
||||
await dispatch(setNoCategory(isChecked));
|
||||
await dispatch(setSelectOrDeselectAllCategories(isChecked));
|
||||
await dispatch(fetchReportingProjects());
|
||||
};
|
||||
|
||||
const handleNoCategoryChange = async (checked: boolean) => {
|
||||
await dispatch(setNoCategory(checked));
|
||||
await dispatch(fetchReportingProjects());
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<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('searchByCategory')}
|
||||
value={searchText}
|
||||
onChange={e => setSearchText(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{categories.length > 0 && (
|
||||
<div style={{ padding: '0 12px', flexShrink: 0 }}>
|
||||
<Checkbox
|
||||
onClick={e => e.stopPropagation()}
|
||||
onChange={handleSelectAllChange}
|
||||
checked={selectAll}
|
||||
>
|
||||
{t('selectAll')}
|
||||
</Checkbox>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ padding: '8px 12px 4px 12px', flexShrink: 0 }}>
|
||||
<Checkbox
|
||||
onClick={e => e.stopPropagation()}
|
||||
checked={noCategory}
|
||||
onChange={e => handleNoCategoryChange(e.target.checked)}
|
||||
>
|
||||
{t('noCategory')}
|
||||
</Checkbox>
|
||||
</div>
|
||||
<Divider style={{ margin: '4px 0', flexShrink: 0 }} />
|
||||
<div
|
||||
style={{
|
||||
overflowY: 'auto',
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{filteredItems.length > 0 ? (
|
||||
filteredItems.map(item => (
|
||||
<div
|
||||
key={item.id}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
onClick={e => e.stopPropagation()}
|
||||
checked={item.selected}
|
||||
onChange={e => handleCheckboxChange(item.id || '', e.target.checked)}
|
||||
>
|
||||
{item.name}
|
||||
</Checkbox>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div style={{ padding: '8px 12px' }}>{t('noCategories')}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
onOpenChange={visible => {
|
||||
setDropdownVisible(visible);
|
||||
if (!visible) {
|
||||
setSearchText('');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button loading={loadingCategories}>
|
||||
{t('categories')} <CaretDownFilled />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Categories;
|
||||
@@ -1,680 +0,0 @@
|
||||
import {
|
||||
setSelectOrDeselectAllProjects,
|
||||
setSelectOrDeselectProject,
|
||||
} from '@/features/reporting/time-reports/time-reports-overview.slice';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import {
|
||||
CaretDownFilled,
|
||||
SearchOutlined,
|
||||
ClearOutlined,
|
||||
DownOutlined,
|
||||
RightOutlined,
|
||||
FilterOutlined,
|
||||
} from '@/shared/antd-imports';
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Divider,
|
||||
Dropdown,
|
||||
Input,
|
||||
theme,
|
||||
Typography,
|
||||
Badge,
|
||||
Collapse,
|
||||
Select,
|
||||
Space,
|
||||
Tooltip,
|
||||
Empty,
|
||||
} from '@/shared/antd-imports';
|
||||
import { CheckboxChangeEvent } from 'antd/es/checkbox';
|
||||
import React, { useState, useMemo, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ISelectableProject } from '@/types/reporting/reporting-filters.types';
|
||||
import { themeWiseColor } from '@/utils/themeWiseColor';
|
||||
|
||||
const { Panel } = Collapse;
|
||||
const { Text } = Typography;
|
||||
|
||||
type GroupByOption = 'none' | 'category' | 'team' | 'status';
|
||||
|
||||
interface ProjectGroup {
|
||||
key: string;
|
||||
name: string;
|
||||
color?: string;
|
||||
projects: ISelectableProject[];
|
||||
}
|
||||
|
||||
const Projects: React.FC = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [groupBy, setGroupBy] = useState<GroupByOption>('none');
|
||||
const [showSelectedOnly, setShowSelectedOnly] = useState(false);
|
||||
const [expandedGroups, setExpandedGroups] = useState<string[]>([]);
|
||||
const { t } = useTranslation('time-report');
|
||||
const [dropdownVisible, setDropdownVisible] = useState(false);
|
||||
const { projects, loadingProjects } = useAppSelector(state => state.timeReportsOverviewReducer);
|
||||
const { token } = theme.useToken();
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
|
||||
// Theme-aware color utilities
|
||||
const getThemeAwareColor = useCallback(
|
||||
(lightColor: string, darkColor: string) => {
|
||||
return themeWiseColor(lightColor, darkColor, themeMode);
|
||||
},
|
||||
[themeMode]
|
||||
);
|
||||
|
||||
// Enhanced color processing for project/group colors
|
||||
const processColor = useCallback(
|
||||
(color: string | undefined, fallback?: string) => {
|
||||
if (!color) return fallback || token.colorPrimary;
|
||||
|
||||
// If it's a hex color, ensure it has good contrast in both themes
|
||||
if (color.startsWith('#')) {
|
||||
// For dark mode, lighten dark colors and darken light colors for better visibility
|
||||
if (themeMode === 'dark') {
|
||||
// Simple brightness adjustment for dark mode
|
||||
const hex = color.replace('#', '');
|
||||
const r = parseInt(hex.substr(0, 2), 16);
|
||||
const g = parseInt(hex.substr(2, 2), 16);
|
||||
const b = parseInt(hex.substr(4, 2), 16);
|
||||
|
||||
// Calculate brightness (0-255)
|
||||
const brightness = (r * 299 + g * 587 + b * 114) / 1000;
|
||||
|
||||
// If color is too dark in dark mode, lighten it
|
||||
if (brightness < 100) {
|
||||
const factor = 1.5;
|
||||
const newR = Math.min(255, Math.floor(r * factor));
|
||||
const newG = Math.min(255, Math.floor(g * factor));
|
||||
const newB = Math.min(255, Math.floor(b * factor));
|
||||
return `#${newR.toString(16).padStart(2, '0')}${newG.toString(16).padStart(2, '0')}${newB.toString(16).padStart(2, '0')}`;
|
||||
}
|
||||
} else {
|
||||
// For light mode, ensure colors aren't too light
|
||||
const hex = color.replace('#', '');
|
||||
const r = parseInt(hex.substr(0, 2), 16);
|
||||
const g = parseInt(hex.substr(2, 2), 16);
|
||||
const b = parseInt(hex.substr(4, 2), 16);
|
||||
|
||||
const brightness = (r * 299 + g * 587 + b * 114) / 1000;
|
||||
|
||||
// If color is too light in light mode, darken it
|
||||
if (brightness > 200) {
|
||||
const factor = 0.7;
|
||||
const newR = Math.floor(r * factor);
|
||||
const newG = Math.floor(g * factor);
|
||||
const newB = Math.floor(b * factor);
|
||||
return `#${newR.toString(16).padStart(2, '0')}${newG.toString(16).padStart(2, '0')}${newB.toString(16).padStart(2, '0')}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return color;
|
||||
},
|
||||
[themeMode, token.colorPrimary]
|
||||
);
|
||||
|
||||
// Memoized filtered projects
|
||||
const filteredProjects = useMemo(() => {
|
||||
let filtered = projects.filter(item =>
|
||||
item.name?.toLowerCase().includes(searchText.toLowerCase())
|
||||
);
|
||||
|
||||
if (showSelectedOnly) {
|
||||
filtered = filtered.filter(item => item.selected);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}, [projects, searchText, showSelectedOnly]);
|
||||
|
||||
// Memoized grouped projects
|
||||
const groupedProjects = useMemo(() => {
|
||||
if (groupBy === 'none') {
|
||||
return [
|
||||
{
|
||||
key: 'all',
|
||||
name: t('projects'),
|
||||
projects: filteredProjects,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const groups: { [key: string]: ProjectGroup } = {};
|
||||
|
||||
filteredProjects.forEach(project => {
|
||||
let groupKey: string;
|
||||
let groupName: string;
|
||||
let groupColor: string | undefined;
|
||||
|
||||
switch (groupBy) {
|
||||
case 'category':
|
||||
groupKey = (project as any).category_id || 'uncategorized';
|
||||
groupName = (project as any).category_name || t('noCategory');
|
||||
groupColor = (project as any).category_color;
|
||||
break;
|
||||
case 'team':
|
||||
groupKey = (project as any).team_id || 'no-team';
|
||||
groupName = (project as any).team_name || t('ungrouped');
|
||||
groupColor = (project as any).team_color;
|
||||
break;
|
||||
case 'status':
|
||||
groupKey = (project as any).status_id || 'no-status';
|
||||
groupName = (project as any).status_name || t('ungrouped');
|
||||
groupColor = (project as any).status_color;
|
||||
break;
|
||||
default:
|
||||
groupKey = 'all';
|
||||
groupName = t('projects');
|
||||
}
|
||||
|
||||
if (!groups[groupKey]) {
|
||||
groups[groupKey] = {
|
||||
key: groupKey,
|
||||
name: groupName,
|
||||
color: processColor(groupColor),
|
||||
projects: [],
|
||||
};
|
||||
}
|
||||
|
||||
groups[groupKey].projects.push(project);
|
||||
});
|
||||
|
||||
return Object.values(groups).sort((a, b) => a.name.localeCompare(b.name));
|
||||
}, [filteredProjects, groupBy, t, processColor]);
|
||||
|
||||
// Selected projects count
|
||||
const selectedCount = useMemo(() => projects.filter(p => p.selected).length, [projects]);
|
||||
|
||||
const allSelected = useMemo(
|
||||
() => filteredProjects.length > 0 && filteredProjects.every(p => p.selected),
|
||||
[filteredProjects]
|
||||
);
|
||||
|
||||
const indeterminate = useMemo(
|
||||
() => filteredProjects.some(p => p.selected) && !allSelected,
|
||||
[filteredProjects, allSelected]
|
||||
);
|
||||
|
||||
// Memoize group by options
|
||||
const groupByOptions = useMemo(
|
||||
() => [
|
||||
{ value: 'none', label: t('groupByNone') },
|
||||
{ value: 'category', label: t('groupByCategory') },
|
||||
{ value: 'team', label: t('groupByTeam') },
|
||||
{ value: 'status', label: t('groupByStatus') },
|
||||
],
|
||||
[t]
|
||||
);
|
||||
|
||||
// Memoize dropdown styles to prevent recalculation on every render
|
||||
const dropdownStyles = useMemo(
|
||||
() => ({
|
||||
dropdown: {
|
||||
background: token.colorBgContainer,
|
||||
borderRadius: token.borderRadius,
|
||||
boxShadow: token.boxShadowSecondary,
|
||||
border: `1px solid ${token.colorBorder}`,
|
||||
},
|
||||
groupHeader: {
|
||||
backgroundColor: getThemeAwareColor(token.colorFillTertiary, token.colorFillQuaternary),
|
||||
borderRadius: token.borderRadiusSM,
|
||||
padding: '8px 12px',
|
||||
marginBottom: '4px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
border: `1px solid ${getThemeAwareColor(token.colorBorderSecondary, token.colorBorder)}`,
|
||||
},
|
||||
projectItem: {
|
||||
padding: '8px 12px',
|
||||
borderRadius: token.borderRadiusSM,
|
||||
transition: 'all 0.2s ease',
|
||||
cursor: 'pointer',
|
||||
border: `1px solid transparent`,
|
||||
},
|
||||
toggleIcon: {
|
||||
color: getThemeAwareColor(token.colorTextSecondary, token.colorTextTertiary),
|
||||
fontSize: '12px',
|
||||
transition: 'all 0.2s ease',
|
||||
},
|
||||
expandedToggleIcon: {
|
||||
color: getThemeAwareColor(token.colorPrimary, token.colorPrimaryActive),
|
||||
fontSize: '12px',
|
||||
transition: 'all 0.2s ease',
|
||||
},
|
||||
}),
|
||||
[token, getThemeAwareColor]
|
||||
);
|
||||
|
||||
// Memoize search placeholder and clear tooltip
|
||||
const searchPlaceholder = useMemo(() => t('searchByProject'), [t]);
|
||||
const clearTooltip = useMemo(() => t('clearSearch'), [t]);
|
||||
const showSelectedTooltip = useMemo(() => t('showSelected'), [t]);
|
||||
const selectAllText = useMemo(() => t('selectAll'), [t]);
|
||||
const projectsSelectedText = useMemo(() => t('projectsSelected'), [t]);
|
||||
const noProjectsText = useMemo(() => t('noProjects'), [t]);
|
||||
const noDataText = useMemo(() => t('noData'), [t]);
|
||||
const expandAllText = useMemo(() => t('expandAll'), [t]);
|
||||
const collapseAllText = useMemo(() => t('collapseAll'), [t]);
|
||||
|
||||
// Handle checkbox change for individual items
|
||||
const handleCheckboxChange = useCallback(
|
||||
(key: string, checked: boolean) => {
|
||||
dispatch(setSelectOrDeselectProject({ id: key, selected: checked }));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
// Handle "Select All" checkbox change
|
||||
const handleSelectAllChange = useCallback(
|
||||
(e: CheckboxChangeEvent) => {
|
||||
const isChecked = e.target.checked;
|
||||
dispatch(setSelectOrDeselectAllProjects(isChecked));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
// Clear search
|
||||
const clearSearch = useCallback(() => {
|
||||
setSearchText('');
|
||||
}, []);
|
||||
|
||||
// Toggle group expansion
|
||||
const toggleGroupExpansion = useCallback((groupKey: string) => {
|
||||
setExpandedGroups(prev =>
|
||||
prev.includes(groupKey) ? prev.filter(key => key !== groupKey) : [...prev, groupKey]
|
||||
);
|
||||
}, []);
|
||||
|
||||
// Expand/Collapse all groups
|
||||
const toggleAllGroups = useCallback(
|
||||
(expand: boolean) => {
|
||||
if (expand) {
|
||||
setExpandedGroups(groupedProjects.map(g => g.key));
|
||||
} else {
|
||||
setExpandedGroups([]);
|
||||
}
|
||||
},
|
||||
[groupedProjects]
|
||||
);
|
||||
|
||||
// Render project group
|
||||
const renderProjectGroup = (group: ProjectGroup) => {
|
||||
const isExpanded = expandedGroups.includes(group.key) || groupBy === 'none';
|
||||
const groupSelectedCount = group.projects.filter(p => p.selected).length;
|
||||
|
||||
return (
|
||||
<div key={group.key} style={{ marginBottom: '8px' }}>
|
||||
{groupBy !== 'none' && (
|
||||
<div
|
||||
style={{
|
||||
...dropdownStyles.groupHeader,
|
||||
backgroundColor: isExpanded
|
||||
? getThemeAwareColor(token.colorFillSecondary, token.colorFillTertiary)
|
||||
: dropdownStyles.groupHeader.backgroundColor,
|
||||
}}
|
||||
onClick={() => toggleGroupExpansion(group.key)}
|
||||
onMouseEnter={e => {
|
||||
e.currentTarget.style.backgroundColor = getThemeAwareColor(
|
||||
token.colorFillSecondary,
|
||||
token.colorFillTertiary
|
||||
);
|
||||
e.currentTarget.style.borderColor = getThemeAwareColor(
|
||||
token.colorBorder,
|
||||
token.colorBorderSecondary
|
||||
);
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
e.currentTarget.style.backgroundColor = isExpanded
|
||||
? getThemeAwareColor(token.colorFillSecondary, token.colorFillTertiary)
|
||||
: dropdownStyles.groupHeader.backgroundColor;
|
||||
e.currentTarget.style.borderColor = getThemeAwareColor(
|
||||
token.colorBorderSecondary,
|
||||
token.colorBorder
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Space>
|
||||
{isExpanded ? (
|
||||
<DownOutlined style={dropdownStyles.expandedToggleIcon} />
|
||||
) : (
|
||||
<RightOutlined style={dropdownStyles.toggleIcon} />
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
width: '12px',
|
||||
height: '12px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: group.color || processColor(undefined, token.colorPrimary),
|
||||
flexShrink: 0,
|
||||
border: `1px solid ${getThemeAwareColor('rgba(0,0,0,0.1)', 'rgba(255,255,255,0.2)')}`,
|
||||
}}
|
||||
/>
|
||||
<Text
|
||||
strong
|
||||
style={{
|
||||
color: getThemeAwareColor(token.colorText, token.colorTextBase),
|
||||
}}
|
||||
>
|
||||
{group.name}
|
||||
</Text>
|
||||
<Badge
|
||||
count={groupSelectedCount}
|
||||
size="small"
|
||||
style={{
|
||||
backgroundColor: getThemeAwareColor(token.colorPrimary, token.colorPrimaryActive),
|
||||
color: getThemeAwareColor('#fff', token.colorTextLightSolid),
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isExpanded && (
|
||||
<div style={{ paddingLeft: groupBy !== 'none' ? '24px' : '0' }}>
|
||||
{group.projects.map(project => (
|
||||
<div
|
||||
key={project.id}
|
||||
style={dropdownStyles.projectItem}
|
||||
onMouseEnter={e => {
|
||||
e.currentTarget.style.backgroundColor = getThemeAwareColor(
|
||||
token.colorFillAlter,
|
||||
token.colorFillQuaternary
|
||||
);
|
||||
e.currentTarget.style.borderColor = getThemeAwareColor(
|
||||
token.colorBorderSecondary,
|
||||
token.colorBorder
|
||||
);
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
e.currentTarget.style.borderColor = 'transparent';
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
onClick={e => e.stopPropagation()}
|
||||
checked={project.selected}
|
||||
onChange={e => handleCheckboxChange(project.id || '', e.target.checked)}
|
||||
>
|
||||
<Space>
|
||||
<div
|
||||
style={{
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: processColor(
|
||||
(project as any).color_code,
|
||||
token.colorPrimary
|
||||
),
|
||||
flexShrink: 0,
|
||||
border: `1px solid ${getThemeAwareColor('rgba(0,0,0,0.1)', 'rgba(255,255,255,0.2)')}`,
|
||||
}}
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
color: getThemeAwareColor(token.colorText, token.colorTextBase),
|
||||
}}
|
||||
>
|
||||
{project.name}
|
||||
</Text>
|
||||
</Space>
|
||||
</Checkbox>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Dropdown
|
||||
menu={undefined}
|
||||
placement="bottomLeft"
|
||||
trigger={['click']}
|
||||
open={dropdownVisible}
|
||||
dropdownRender={() => (
|
||||
<div
|
||||
style={{
|
||||
...dropdownStyles.dropdown,
|
||||
padding: '8px 0',
|
||||
maxHeight: '500px',
|
||||
width: '400px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
{/* Header with search and controls */}
|
||||
<div style={{ padding: '8px 12px', flexShrink: 0 }}>
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="small">
|
||||
{/* Search input */}
|
||||
<Input
|
||||
placeholder={searchPlaceholder}
|
||||
value={searchText}
|
||||
onChange={e => setSearchText(e.target.value)}
|
||||
prefix={
|
||||
<SearchOutlined
|
||||
style={{
|
||||
color: getThemeAwareColor(
|
||||
token.colorTextTertiary,
|
||||
token.colorTextQuaternary
|
||||
),
|
||||
}}
|
||||
/>
|
||||
}
|
||||
suffix={
|
||||
searchText && (
|
||||
<Tooltip title={clearTooltip}>
|
||||
<ClearOutlined
|
||||
onClick={clearSearch}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
color: getThemeAwareColor(
|
||||
token.colorTextTertiary,
|
||||
token.colorTextQuaternary
|
||||
),
|
||||
transition: 'color 0.2s ease',
|
||||
}}
|
||||
onMouseEnter={e => {
|
||||
e.currentTarget.style.color = getThemeAwareColor(
|
||||
token.colorTextSecondary,
|
||||
token.colorTextTertiary
|
||||
);
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
e.currentTarget.style.color = getThemeAwareColor(
|
||||
token.colorTextTertiary,
|
||||
token.colorTextQuaternary
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
onClick={e => e.stopPropagation()}
|
||||
/>
|
||||
|
||||
{/* Controls row */}
|
||||
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
|
||||
<Space size="small">
|
||||
<Select
|
||||
value={groupBy}
|
||||
onChange={setGroupBy}
|
||||
size="small"
|
||||
style={{ width: '120px' }}
|
||||
options={groupByOptions}
|
||||
/>
|
||||
|
||||
{groupBy !== 'none' && (
|
||||
<Space size="small">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
onClick={() => toggleAllGroups(true)}
|
||||
style={{
|
||||
color: getThemeAwareColor(
|
||||
token.colorTextSecondary,
|
||||
token.colorTextTertiary
|
||||
),
|
||||
}}
|
||||
>
|
||||
{expandAllText}
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
onClick={() => toggleAllGroups(false)}
|
||||
style={{
|
||||
color: getThemeAwareColor(
|
||||
token.colorTextSecondary,
|
||||
token.colorTextTertiary
|
||||
),
|
||||
}}
|
||||
>
|
||||
{collapseAllText}
|
||||
</Button>
|
||||
</Space>
|
||||
)}
|
||||
</Space>
|
||||
|
||||
<Tooltip title={showSelectedTooltip}>
|
||||
<Button
|
||||
type={showSelectedOnly ? 'primary' : 'text'}
|
||||
size="small"
|
||||
icon={<FilterOutlined />}
|
||||
onClick={() => setShowSelectedOnly(!showSelectedOnly)}
|
||||
style={
|
||||
!showSelectedOnly
|
||||
? {
|
||||
color: getThemeAwareColor(
|
||||
token.colorTextSecondary,
|
||||
token.colorTextTertiary
|
||||
),
|
||||
}
|
||||
: {}
|
||||
}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{/* Select All checkbox */}
|
||||
<div style={{ padding: '8px 12px', flexShrink: 0 }}>
|
||||
<Checkbox
|
||||
onClick={e => e.stopPropagation()}
|
||||
onChange={handleSelectAllChange}
|
||||
checked={allSelected}
|
||||
indeterminate={indeterminate}
|
||||
>
|
||||
<Space>
|
||||
<Text
|
||||
style={{
|
||||
color: getThemeAwareColor(token.colorText, token.colorTextBase),
|
||||
}}
|
||||
>
|
||||
{selectAllText}
|
||||
</Text>
|
||||
{selectedCount > 0 && (
|
||||
<Badge
|
||||
count={selectedCount}
|
||||
size="small"
|
||||
style={{
|
||||
backgroundColor: getThemeAwareColor(
|
||||
token.colorSuccess,
|
||||
token.colorSuccessActive
|
||||
),
|
||||
color: getThemeAwareColor('#fff', token.colorTextLightSolid),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Space>
|
||||
</Checkbox>
|
||||
</div>
|
||||
|
||||
<Divider style={{ margin: '8px 0', flexShrink: 0 }} />
|
||||
|
||||
{/* Projects list */}
|
||||
<div
|
||||
style={{
|
||||
overflowY: 'auto',
|
||||
flex: 1,
|
||||
padding: '0 12px',
|
||||
}}
|
||||
>
|
||||
{filteredProjects.length === 0 ? (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description={
|
||||
<Text
|
||||
style={{
|
||||
color: getThemeAwareColor(
|
||||
token.colorTextTertiary,
|
||||
token.colorTextQuaternary
|
||||
),
|
||||
}}
|
||||
>
|
||||
{searchText ? noProjectsText : noDataText}
|
||||
</Text>
|
||||
}
|
||||
style={{ margin: '20px 0' }}
|
||||
/>
|
||||
) : (
|
||||
groupedProjects.map(renderProjectGroup)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer with selection summary */}
|
||||
{selectedCount > 0 && (
|
||||
<>
|
||||
<Divider style={{ margin: '8px 0', flexShrink: 0 }} />
|
||||
<div
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
flexShrink: 0,
|
||||
backgroundColor: getThemeAwareColor(
|
||||
token.colorFillAlter,
|
||||
token.colorFillQuaternary
|
||||
),
|
||||
borderRadius: `0 0 ${token.borderRadius}px ${token.borderRadius}px`,
|
||||
borderTop: `1px solid ${getThemeAwareColor(token.colorBorderSecondary, token.colorBorder)}`,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
type="secondary"
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: getThemeAwareColor(token.colorTextTertiary, token.colorTextQuaternary),
|
||||
}}
|
||||
>
|
||||
{selectedCount} {projectsSelectedText}
|
||||
</Text>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
onOpenChange={visible => {
|
||||
setDropdownVisible(visible);
|
||||
if (!visible) {
|
||||
setSearchText('');
|
||||
setShowSelectedOnly(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Badge count={selectedCount} size="small" offset={[-5, 5]}>
|
||||
<Button loading={loadingProjects}>
|
||||
<Space>
|
||||
{t('projects')}
|
||||
<CaretDownFilled />
|
||||
</Space>
|
||||
</Button>
|
||||
</Badge>
|
||||
</Dropdown>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Projects;
|
||||
@@ -1,120 +0,0 @@
|
||||
import { CaretDownFilled } from '@/shared/antd-imports';
|
||||
import { Button, Checkbox, Divider, Dropdown, Input, theme } from '@/shared/antd-imports';
|
||||
import React, { useState } from 'react';
|
||||
import type { CheckboxChangeEvent } from 'antd/es/checkbox';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import {
|
||||
fetchReportingCategories,
|
||||
fetchReportingProjects,
|
||||
setSelectOrDeselectAllTeams,
|
||||
setSelectOrDeselectTeam,
|
||||
} from '@/features/reporting/time-reports/time-reports-overview.slice';
|
||||
|
||||
const Team: React.FC = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [selectAll, setSelectAll] = useState(true);
|
||||
const { t } = useTranslation('time-report');
|
||||
const [dropdownVisible, setDropdownVisible] = useState(false);
|
||||
const { token } = theme.useToken();
|
||||
|
||||
const { teams, loadingTeams } = useAppSelector(state => state.timeReportsOverviewReducer);
|
||||
|
||||
const filteredItems = teams.filter(item =>
|
||||
item.name?.toLowerCase().includes(searchText.toLowerCase())
|
||||
);
|
||||
|
||||
const handleCheckboxChange = async (key: string, checked: boolean) => {
|
||||
dispatch(setSelectOrDeselectTeam({ id: key, selected: checked }));
|
||||
await dispatch(fetchReportingCategories());
|
||||
await dispatch(fetchReportingProjects());
|
||||
};
|
||||
|
||||
const handleSelectAllChange = async (e: CheckboxChangeEvent) => {
|
||||
const isChecked = e.target.checked;
|
||||
setSelectAll(isChecked);
|
||||
dispatch(setSelectOrDeselectAllTeams(isChecked));
|
||||
await dispatch(fetchReportingCategories());
|
||||
await dispatch(fetchReportingProjects());
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<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
|
||||
placeholder={t('searchByName')}
|
||||
value={searchText}
|
||||
onChange={e => setSearchText(e.target.value)}
|
||||
onClick={e => e.stopPropagation()}
|
||||
/>
|
||||
</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,
|
||||
}}
|
||||
>
|
||||
{filteredItems.map(item => (
|
||||
<div
|
||||
key={item.id}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
onClick={e => e.stopPropagation()}
|
||||
checked={item.selected}
|
||||
onChange={e => handleCheckboxChange(item.id || '', e.target.checked)}
|
||||
>
|
||||
{item.name}
|
||||
</Checkbox>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
onOpenChange={visible => {
|
||||
setDropdownVisible(visible);
|
||||
if (!visible) {
|
||||
setSearchText('');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button loading={loadingTeams}>
|
||||
{t('teams')} <CaretDownFilled />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Team;
|
||||
@@ -1,36 +0,0 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import Team from './team';
|
||||
import Categories from './categories';
|
||||
import Projects from './projects';
|
||||
import Billable from './billable';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import {
|
||||
fetchReportingTeams,
|
||||
fetchReportingProjects,
|
||||
fetchReportingCategories,
|
||||
} from '@/features/reporting/time-reports/time-reports-overview.slice';
|
||||
|
||||
const TimeReportPageHeader: React.FC = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
await dispatch(fetchReportingTeams());
|
||||
await dispatch(fetchReportingCategories());
|
||||
await dispatch(fetchReportingProjects());
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [dispatch]);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<Team />
|
||||
<Categories />
|
||||
<Projects />
|
||||
<Billable />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TimeReportPageHeader;
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Card, Flex } from '@/shared/antd-imports';
|
||||
import TimeReportPageHeader from '@/pages/reporting/timeReports/page-header/time-report-page-header';
|
||||
import TimeReportPageHeader from '@/components/reporting/time-reports/page-header/TimeReportPageHeader';
|
||||
import ProjectTimeSheetChart, {
|
||||
ProjectTimeSheetChartRef,
|
||||
} from '@/pages/reporting/time-reports/project-time-sheet/project-time-sheet-chart';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDocumentTitle } from '@/hooks/useDoumentTItle';
|
||||
import TimeReportingRightHeader from './timeReportingRightHeader/TimeReportingRightHeader';
|
||||
import TimeReportingRightHeader from '@/components/reporting/time-reports/right-header/TimeReportingRightHeader';
|
||||
import { useRef } from 'react';
|
||||
|
||||
const ProjectsTimeReports = () => {
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Button, Checkbox, Dropdown, Space, Typography } from '@/shared/antd-imports';
|
||||
import { DownOutlined } from '@/shared/antd-imports';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import CustomPageHeader from '../../page-header/custom-page-header';
|
||||
import TimeWiseFilter from '../../../../components/reporting/time-wise-filter';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { setArchived } from '@/features/reporting/time-reports/time-reports-overview.slice';
|
||||
|
||||
interface headerState {
|
||||
title: string;
|
||||
exportType: Array<{ key: string; label: string }>;
|
||||
export: (key: string) => void;
|
||||
}
|
||||
|
||||
const TimeReportingRightHeader: React.FC<headerState> = ({
|
||||
title,
|
||||
exportType,
|
||||
export: exportFn,
|
||||
}) => {
|
||||
const { t } = useTranslation('time-report');
|
||||
const dispatch = useAppDispatch();
|
||||
const { archived } = useAppSelector(state => state.timeReportsOverviewReducer);
|
||||
|
||||
const menuItems = exportType.map(item => ({
|
||||
key: item.key,
|
||||
label: item.label,
|
||||
onClick: () => exportFn(item.key),
|
||||
}));
|
||||
|
||||
return (
|
||||
<CustomPageHeader
|
||||
title={title}
|
||||
children={
|
||||
<Space>
|
||||
<Button>
|
||||
<Checkbox checked={archived} onChange={e => dispatch(setArchived(e.target.checked))}>
|
||||
<Typography.Text>{t('includeArchivedProjects')}</Typography.Text>
|
||||
</Checkbox>
|
||||
</Button>
|
||||
<TimeWiseFilter />
|
||||
<Dropdown menu={{ items: menuItems }}>
|
||||
<Button type="primary" icon={<DownOutlined />} iconPosition="end">
|
||||
{t('export')}
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default TimeReportingRightHeader;
|
||||
@@ -56,7 +56,7 @@ const LanguageAndRegionSettings = () => {
|
||||
label: 'Deutsch',
|
||||
},
|
||||
{
|
||||
value: Language.ZH_CN,
|
||||
value: Language.ZH,
|
||||
label: '简体中文',
|
||||
},
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user