diff --git a/src/components/BMDashboard/RentalChart/ReturnedLateChart.jsx b/src/components/BMDashboard/RentalChart/ReturnedLateChart.jsx index 2b6bfd9636..cfbced79ae 100644 --- a/src/components/BMDashboard/RentalChart/ReturnedLateChart.jsx +++ b/src/components/BMDashboard/RentalChart/ReturnedLateChart.jsx @@ -23,6 +23,49 @@ import ChartDataLabels from 'chartjs-plugin-datalabels'; // are not affected by the datalabels plugin by default. ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend); +const PROJECT_COLORS = [ + { background: 'rgba(37, 99, 235, 0.82)', border: '#1d4ed8' }, + { background: 'rgba(5, 150, 105, 0.82)', border: '#047857' }, + { background: 'rgba(217, 119, 6, 0.82)', border: '#b45309' }, + { background: 'rgba(220, 38, 38, 0.82)', border: '#b91c1c' }, + { background: 'rgba(124, 58, 237, 0.82)', border: '#7c3aed' }, + { background: 'rgba(8, 145, 178, 0.82)', border: '#0e7490' }, + { background: 'rgba(190, 24, 93, 0.82)', border: '#be185d' }, + { background: 'rgba(101, 163, 13, 0.82)', border: '#4d7c0f' }, +]; + +const DARK_PROJECT_COLORS = [ + { background: 'rgba(96, 165, 250, 0.88)', border: '#bfdbfe' }, + { background: 'rgba(52, 211, 153, 0.88)', border: '#a7f3d0' }, + { background: 'rgba(251, 191, 36, 0.88)', border: '#fde68a' }, + { background: 'rgba(248, 113, 113, 0.88)', border: '#fecaca' }, + { background: 'rgba(167, 139, 250, 0.88)', border: '#ddd6fe' }, + { background: 'rgba(34, 211, 238, 0.88)', border: '#a5f3fc' }, + { background: 'rgba(244, 114, 182, 0.88)', border: '#fbcfe8' }, + { background: 'rgba(163, 230, 53, 0.88)', border: '#d9f99d' }, +]; + +function getProjectColor(index, darkMode) { + const palette = darkMode ? DARK_PROJECT_COLORS : PROJECT_COLORS; + return palette[index % palette.length]; +} + +function getArrayPayload(payload) { + if (Array.isArray(payload)) return payload; + if (Array.isArray(payload?.data)) return payload.data; + if (Array.isArray(payload?.results)) return payload.results; + return []; +} + +function getRequestErrorMessage(error, fallbackMessage) { + return ( + error?.response?.data?.error || + error?.response?.data?.message || + error?.message || + fallbackMessage + ); +} + export default function ReturnedLateChart() { const chartRef = useRef(null); const [loading, setLoading] = useState(true); @@ -40,8 +83,35 @@ export default function ReturnedLateChart() { const [selectedToolDetail, setSelectedToolDetail] = useState(null); const [detailOpen, setDetailOpen] = useState(false); const [detailLoading, setDetailLoading] = useState(false); + const [hiddenProjects, setHiddenProjects] = useState([]); const darkMode = useSelector(state => state.theme.darkMode); const [sortOption, setSortOption] = useState('DESC'); + const isMultiProjectView = selectedProject === 'All'; + const visibleDatasets = useMemo( + () => + chartData.datasets.map(dataset => ({ + ...dataset, + hidden: Boolean(dataset.projectId) && hiddenProjects.includes(dataset.projectId), + })), + [chartData.datasets, hiddenProjects], + ); + const maxChartValue = Math.max( + 0, + ...visibleDatasets + .filter(dataset => !dataset.hidden) + .flatMap(dataset => dataset.data.filter(value => value != null)), + ); + const legendItems = useMemo( + () => + chartData.datasets.map(dataset => ({ + projectId: dataset.projectId || dataset.label, + label: dataset.label, + backgroundColor: dataset.backgroundColor, + borderColor: dataset.borderColor, + hidden: Boolean(dataset.projectId) && hiddenProjects.includes(dataset.projectId), + })), + [chartData.datasets, hiddenProjects], + ); const sortToolsData = data => { const sorted = [...data]; @@ -66,32 +136,43 @@ export default function ReturnedLateChart() { const fetchInitial = async () => { try { setLoading(true); - try { - const projectsRes = await axios.get(ENDPOINTS.BM_TOOLS_RETURNED_LATE_PROJECTS, { - headers: { - Authorization: localStorage.getItem('token'), - }, - }); - if (projectsRes.data && projectsRes.data.success) { - const projects = projectsRes.data.data || []; - setAvailableProjects(projects); - } - const toolsRes = await axios.get(ENDPOINTS.BM_TOOLS_RETURNED_LATE, { - headers: { - Authorization: localStorage.getItem('token'), - }, - }); - if (toolsRes.data && toolsRes.data.success && toolsRes.data.data) { - const data = toolsRes.data.data || []; - setRawToolsData(data); - const tools = Array.from(new Set(data.map(d => d.toolName))).filter(Boolean); - setAvailableTools(tools.map(t => ({ label: t, value: t }))); - } - } catch (e) { - setError('Failed to fetch initial data'); + const headers = { + Authorization: localStorage.getItem('token'), + }; + const [projectsResult, toolsResult] = await Promise.allSettled([ + axios.get(ENDPOINTS.BM_TOOLS_RETURNED_LATE_PROJECTS, { headers }), + axios.get(ENDPOINTS.BM_TOOLS_RETURNED_LATE, { headers }), + ]); + + let didLoadAnyData = false; + + if (projectsResult.status === 'fulfilled') { + const projects = getArrayPayload(projectsResult.value.data); + setAvailableProjects(projects); + didLoadAnyData = didLoadAnyData || projects.length > 0; + } + + if (toolsResult.status === 'fulfilled') { + const data = getArrayPayload(toolsResult.value.data); + const tools = Array.from(new Set(data.map(d => d.toolName))).filter(Boolean); + setAvailableTools(tools.map(t => ({ label: t, value: t }))); + didLoadAnyData = didLoadAnyData || data.length > 0; + } + + if (!didLoadAnyData) { + const projectError = + projectsResult.status === 'rejected' + ? getRequestErrorMessage(projectsResult.reason, 'Failed to load projects') + : null; + const toolsError = + toolsResult.status === 'rejected' + ? getRequestErrorMessage(toolsResult.reason, 'Failed to load chart data') + : null; + + setError(projectError || toolsError || 'Failed to fetch initial data'); } } catch (e) { - setError('Error loading dashboard data'); + setError(getRequestErrorMessage(e, 'Error loading dashboard data')); } finally { setLoading(false); } @@ -122,24 +203,92 @@ export default function ReturnedLateChart() { Authorization: localStorage.getItem('token'), }, }); - const data = (res.data && (res.data.data || res.data)) || []; + const data = getArrayPayload(res.data); const normalized = data.map(item => ({ toolName: item.toolName || item.toolNameName || item.name || '', percentLate: Number(item.percentLate || item.percent || item.value || 0), + projectId: item.projectId || '', + projectName: item.projectName || '', + totalReturns: Number(item.totalReturns || 0), + lateReturns: Number(item.lateReturns || 0), })); + setRawToolsData(normalized); const sortedData = sortToolsData(normalized); + const uniqueToolNames = [...new Set(sortedData.map(item => item.toolName))]; + const projectNameById = availableProjects.reduce((acc, project) => { + acc[project.projectId] = project.projectName; + return acc; + }, {}); - setChartData({ - labels: sortedData.map(item => item.toolName), - datasets: [ - { - label: '% Returned Late', - data: sortedData.map(item => item.percentLate), - backgroundColor: 'rgba(53,162,235,0.7)', - }, - ], - }); + if (selectedProject === 'All') { + const projectNames = [ + ...new Map( + sortedData + .filter(item => item.projectId) + .map(item => [ + item.projectId, + item.projectName || projectNameById[item.projectId] || 'Unknown Project', + ]), + ).entries(), + ]; + + const datasets = projectNames.length + ? projectNames.map(([projectId, projectName], index) => { + const projectColor = getProjectColor(index, darkMode); + const projectDataMap = sortedData + .filter(item => item.projectId === projectId) + .reduce((acc, item) => { + acc[item.toolName] = item.percentLate; + return acc; + }, {}); + + return { + label: projectName, + projectId, + data: uniqueToolNames.map(toolName => projectDataMap[toolName] ?? null), + backgroundColor: projectColor.background, + borderColor: projectColor.border, + borderWidth: 1, + }; + }) + : [ + { + label: '% Returned Late', + projectId: '', + data: uniqueToolNames.map( + toolName => + sortedData.find(item => item.toolName === toolName)?.percentLate ?? 0, + ), + backgroundColor: getProjectColor(0, darkMode).background, + borderColor: getProjectColor(0, darkMode).border, + borderWidth: 1, + }, + ]; + + setChartData({ + labels: uniqueToolNames, + datasets, + }); + } else { + setChartData({ + labels: uniqueToolNames, + datasets: [ + { + label: + availableProjects.find(project => project.projectId === selectedProject) + ?.projectName || '% Returned Late', + projectId: selectedProject, + data: uniqueToolNames.map( + toolName => sortedData.find(item => item.toolName === toolName)?.percentLate ?? 0, + ), + backgroundColor: getProjectColor(0, darkMode).background, + borderColor: getProjectColor(0, darkMode).border, + borderWidth: 1, + }, + ], + }); + } } catch (err) { const errorMessage = err.response?.data?.error || @@ -152,21 +301,36 @@ export default function ReturnedLateChart() { } }; fetchData(); - }, [selectedProject, dateRange, selectedTools, sortOption]); + }, [availableProjects, darkMode, selectedProject, dateRange, selectedTools, sortOption]); + + useEffect(() => { + if (!isMultiProjectView) { + setHiddenProjects([]); + return; + } + + const validProjectIds = new Set( + chartData.datasets.map(dataset => dataset.projectId).filter(Boolean), + ); + setHiddenProjects(prev => prev.filter(projectId => validProjectIds.has(projectId))); + }, [chartData.datasets, isMultiProjectView]); const handleBarClick = useCallback( (event, elements) => { if (!elements || !elements.length) return; - const index = elements[0].index; + const { index, datasetIndex } = elements[0]; const toolName = chartData.labels[index]; - - const toolDetail = rawToolsData.find(t => t.toolName === toolName); + const dataset = chartData.datasets[datasetIndex]; + const projectId = dataset?.projectId || ''; + const toolDetail = rawToolsData.find( + t => t.toolName === toolName && (!projectId || t.projectId === projectId), + ); setSelectedToolDetail(toolDetail || null); setDetailOpen(true); }, - [chartData.labels, rawToolsData], + [chartData.datasets, chartData.labels, rawToolsData], ); const options = useMemo(() => { @@ -181,7 +345,9 @@ export default function ReturnedLateChart() { intersect: true, }, plugins: { - legend: { display: false }, + legend: { + display: false, + }, title: { display: false, }, @@ -195,9 +361,24 @@ export default function ReturnedLateChart() { }, tooltip: { callbacks: { + title(tooltipItems) { + return tooltipItems[0]?.label || ''; + }, label(context) { const v = context.parsed.y; - return `${v}%`; + const label = context.dataset.label; + return `${label}: ${v}%`; + }, + afterLabel(context) { + const toolDetail = rawToolsData.find( + item => + item.toolName === context.label && + (!context.dataset.projectId || item.projectId === context.dataset.projectId), + ); + + if (!toolDetail) return ''; + + return `Late ${toolDetail.lateReturns} / ${toolDetail.totalReturns} returns`; }, }, }, @@ -226,11 +407,21 @@ export default function ReturnedLateChart() { color: textColor, callback: v => `${v}%`, }, - max: Math.max(...(chartData.datasets[0]?.data || [0])) * 1.15, + max: maxChartValue > 0 ? maxChartValue * 1.15 : 100, }, }, }; - }, [chartData, darkMode, handleBarClick]); + }, [darkMode, handleBarClick, maxChartValue, rawToolsData]); + + const toggleProjectVisibility = projectId => { + if (!projectId || !isMultiProjectView) return; + + setHiddenProjects(prev => + prev.includes(projectId) ? prev.filter(id => id !== projectId) : [...prev, projectId], + ); + }; + + const multiProjectLegendVisible = isMultiProjectView && legendItems.length > 1; const handleProjectChange = e => setSelectedProject(e.target.value); const handleStartDateChange = date => @@ -244,7 +435,44 @@ export default function ReturnedLateChart() { return (
-

Percent of Tools Returned Late

+
+

Percent of Tools Returned Late

+ {multiProjectLegendVisible && ( +
+

+ Click legend items to show or hide project bars. +

+
+ {legendItems.map(item => ( + + ))} +
+
+ )} +
)} {!loading && !error && chartData.labels.length > 0 && ( - + )}
{detailOpen && ( @@ -404,8 +637,13 @@ export default function ReturnedLateChart() { {!detailLoading && selectedToolDetail && !selectedToolDetail.error && ( <>

{selectedToolDetail.toolName}

-

Total Checkouts: {selectedToolDetail.totalCheckouts ?? '—'}

+ {selectedToolDetail.projectName &&

Project: {selectedToolDetail.projectName}

} +

Total Returns: {selectedToolDetail.totalReturns ?? '—'}

Late Returns: {selectedToolDetail.percentLate}%

+

+ Return Summary: {selectedToolDetail.lateReturns ?? 0} /{' '} + {selectedToolDetail.totalReturns ?? 0} +

Average Delay:{' '} {selectedToolDetail.avgDelayDays != null diff --git a/src/components/BMDashboard/RentalChart/ReturnedLateChart.module.css b/src/components/BMDashboard/RentalChart/ReturnedLateChart.module.css index 79f1f896e2..2b6da5de3a 100644 --- a/src/components/BMDashboard/RentalChart/ReturnedLateChart.module.css +++ b/src/components/BMDashboard/RentalChart/ReturnedLateChart.module.css @@ -2,6 +2,14 @@ padding: 20px; } +.returned-late-header-row { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 16px; + flex-wrap: wrap; +} + .returned-late-chart h1 { margin-bottom: 20px; color: #333; @@ -9,6 +17,75 @@ font-weight: bold; } +.returned-late-legend-hint { + margin: 4px 0 10px; + color: #475569; + font-size: 14px; + font-weight: 500; +} + +.returned-late-legend-block { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 6px; + max-width: min(100%, 720px); +} + +.returned-late-legend { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 10px; +} + +.returned-late-legend-item { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + border: 1px solid #cbd5e1; + border-radius: 999px; + background: #fff; + color: #1f2937; + cursor: pointer; + transition: + border-color 0.2s ease, + background-color 0.2s ease, + color 0.2s ease, + opacity 0.2s ease, + transform 0.2s ease; +} + +.returned-late-legend-item:hover { + border-color: #94a3b8; + transform: translateY(-1px); +} + +.returned-late-legend-item:focus-visible { + outline: 3px solid #2563eb; + outline-offset: 2px; +} + +.returned-late-legend-item-hidden { + opacity: 0.55; + background: #f8fafc; +} + +.returned-late-legend-swatch { + width: 14px; + height: 14px; + border: 2px solid transparent; + border-radius: 4px; + flex-shrink: 0; +} + +.returned-late-legend-label { + font-size: 13px; + font-weight: 600; + line-height: 1.2; +} + .returned-late-filters { display: flex; gap: 20px; @@ -95,7 +172,7 @@ } :global(.dark.rmsc .dropdown-heading) { - background-color: #2a3f5f; + background-color: #2a3f5f; color: #fff; border: 1px solid #555; border-radius: 4px; @@ -127,7 +204,7 @@ .returned-late-detail-backdrop { position: fixed; inset: 0; - background: rgba(0, 0, 0, 0.25); + background: rgb(0 0 0 / 25%); z-index: 2147483646; } @@ -139,7 +216,7 @@ height: 100vh; padding: 20px; z-index: 2147483647; - box-shadow: -4px 0 10px rgba(0, 0, 0, 0.4); + box-shadow: -4px 0 10px rgb(0 0 0 / 40%); background: #fff; color: #111; } @@ -157,11 +234,36 @@ cursor: pointer; color: inherit; } + :global(html.dark-mode) .returned-late-chart h1, :global(body.dark-mode) .returned-late-chart h1 { color: #fff; } +:global(html.dark-mode) .returned-late-legend-hint, +:global(body.dark-mode) .returned-late-legend-hint { + color: #cbd5e1; +} + +:global(html.dark-mode) .returned-late-legend-item, +:global(body.dark-mode) .returned-late-legend-item { + background: #0f172a; + color: #f8fafc; + border-color: #475569; +} + +:global(html.dark-mode) .returned-late-legend-item:hover, +:global(body.dark-mode) .returned-late-legend-item:hover { + border-color: #94a3b8; + background: #172033; +} + +:global(html.dark-mode) .returned-late-legend-item-hidden, +:global(body.dark-mode) .returned-late-legend-item-hidden { + background: #172033; + color: #cbd5e1; +} + :global(html.dark-mode) .returned-late-filter-label, :global(body.dark-mode) .returned-late-filter-label { color: #fff; @@ -222,3 +324,22 @@ :global(body.dark-mode) .returned-late-tools-select :global(.select-item:hover) { background-color: #243b64; } + +@media screen and (width <= 768px) { + .returned-late-chart h1 { + margin-bottom: 8px; + } + + .returned-late-legend-hint { + margin-top: 0; + } + + .returned-late-legend-block { + align-items: stretch; + width: 100%; + } + + .returned-late-legend { + justify-content: flex-start; + } +}