diff --git a/src/components/BMDashboard/WeeklyProjectSummary/ActualVsPlannedCost/ActualVsPlannedCost.jsx b/src/components/BMDashboard/WeeklyProjectSummary/ActualVsPlannedCost/ActualVsPlannedCost.jsx index 1b583f57ab9..fb3d308fd95 100644 --- a/src/components/BMDashboard/WeeklyProjectSummary/ActualVsPlannedCost/ActualVsPlannedCost.jsx +++ b/src/components/BMDashboard/WeeklyProjectSummary/ActualVsPlannedCost/ActualVsPlannedCost.jsx @@ -92,7 +92,14 @@ VarianceCard.propTypes = { }).isRequired, }; -function buildChartContent({ loading, isFiltering, hasData, chartDataWithVariance, darkMode }) { +function buildChartContent({ + loading, + isFiltering, + error, + hasData, + chartDataWithVariance, + darkMode, +}) { if (loading || isFiltering) { return (
- - Planned: {point.plannedCost ?? 'N/A'} -
state.theme.darkMode); const theme = getTheme(darkMode); - // filter state - const [selectedProjectId, setSelectedProjectId] = useState(''); const [dateRange, setDateRange] = useState({ start: '', end: '' }); // 'YYYY-MM-DD' const legendItems = [ - { label: 'Planned Cost', color: SERIES_COLORS.planned, type: 'circle' }, { label: 'Actual Cost', color: SERIES_COLORS.actual, type: 'circle' }, { label: 'Predicted Cost', color: SERIES_COLORS.predicted, type: 'dash' }, ]; - // Falls back to the projectId prop whenever nothing is explicitly selected, so the chart still loads data by default - const effectiveProjectId = selectedProjectId || projectId; - useEffect(() => { + let cancelled = false; + const fetchData = async () => { try { setLoading(true); - const [costsResponse, predictionsResponse] = await Promise.all([ - projectCostService.getProjectCosts(effectiveProjectId), - projectCostService.getProjectPredictions(effectiveProjectId), - ]); - - const costsData = costsResponse.data.costs; - const predictionsData = predictionsResponse.data.predictions; - - const predictionsMap = predictionsData.reduce((acc, pred) => { - acc[pred.month] = pred.predictedCost; - return acc; - }, {}); - - const combinedData = costsData.map(cost => ({ - ...cost, - predictedCost: predictionsMap[cost.month] || null, - })); - - setChartData(combinedData.length ? combinedData : buildSampleData(effectiveProjectId)); - setError(null); - } catch { - // Backend has no data for this project (common on reviewer machines): - // show sample data so the chart is still visible to everyone. Data is fixed per-project so switching the filter still moves the chart. - setChartData(buildSampleData(effectiveProjectId)); setError(null); + const costData = await getProjectCosts(projectId); + if (!costData?.actual || typeof costData.actual !== 'object') { + throw new Error('Cost tracking data is unavailable for this project.'); + } + + const selectedCategories = + category === 'Overall' ? null : [category === 'Labour' ? 'Labor' : category]; + const months = new Set(); + const dataCategories = Object.keys(costData.actual); + const predictedDataCategories = Object.keys(costData.predicted || {}); + const matchesSelectedCategory = key => + !selectedCategories || + selectedCategories.some(selected => selected.toLowerCase() === key.toLowerCase()); + const actualCategories = dataCategories.filter(matchesSelectedCategory); + const predictedCategories = predictedDataCategories.filter(matchesSelectedCategory); + + [ + ...actualCategories.map(key => costData.actual[key]), + ...predictedCategories.map(key => costData.predicted[key]), + ].forEach(items => { + items.forEach(item => { + const date = new Date(item.date); + if (!Number.isNaN(date.getTime())) { + months.add(toMonthKey(date)); + } + }); + }); + + const sortedMonths = [...months].sort(); + const monthKeys = []; + if (sortedMonths.length > 0) { + const [firstYear, firstMonth] = sortedMonths[0].split('-').map(Number); + const [lastYear, lastMonth] = sortedMonths[sortedMonths.length - 1] + .split('-') + .map(Number); + const currentMonth = new Date(firstYear, firstMonth - 1, 1); + const lastMonthDate = new Date(lastYear, lastMonth - 1, 1); + + while (currentMonth <= lastMonthDate) { + monthKeys.push(toMonthKey(currentMonth)); + currentMonth.setMonth(currentMonth.getMonth() + 1); + } + } + + const getMonthlyTotal = (categoryData, categories, year, month) => { + let hasRecords = false; + const total = categories.reduce( + (sum, categoryName) => + sum + + (categoryData[categoryName] || []).reduce((categoryTotal, item) => { + const date = new Date(item.date); + if (date.getFullYear() !== year || date.getMonth() !== month) { + return categoryTotal; + } + hasRecords = true; + return categoryTotal + (Number(item.cost) || 0); + }, 0), + 0, + ); + return hasRecords ? total : null; + }; + + const combinedData = monthKeys.map(monthKey => { + const [year, month] = monthKey.split('-').map(Number); + return { + month: monthKey, + actualCost: getMonthlyTotal(costData.actual, actualCategories, year, month - 1), + predictedCost: costData.predicted + ? getMonthlyTotal(costData.predicted, predictedCategories, year, month - 1) + : null, + }; + }); + + if (!cancelled) setChartData(combinedData); + } catch (fetchError) { + if (!cancelled) { + setChartData([]); + setError( + fetchError?.response?.data?.message || + fetchError.message || + 'Failed to load cost tracking data.', + ); + } } finally { - setLoading(false); + if (!cancelled) setLoading(false); } }; - if (effectiveProjectId) fetchData(); - }, [effectiveProjectId]); + if (projectId) fetchData(); + + return () => { + cancelled = true; + }; + }, [projectId, category]); const filteredData = useMemo(() => { if (!dateRange.start && !dateRange.end) { return chartData.slice(-DEFAULT_WINDOW_MONTHS); } return chartData.filter(d => { - const pointDate = new Date(d.month); + const [year, month] = d.month.split('-').map(Number); + const pointDate = new Date(year, month - 1, 1); if (dateRange.start) { const s = new Date(dateRange.start); @@ -355,12 +372,10 @@ function CostPredictionChart({ projectId }) { // Reset handler const handleReset = () => { - setSelectedProjectId(''); setDateRange({ start: '', end: '' }); }; - const hasActiveFilters = - selectedProjectId !== '' || dateRange.start !== '' || dateRange.end !== ''; + const hasActiveFilters = dateRange.start !== '' || dateRange.end !== ''; const filterStyles = { bar: { @@ -405,38 +420,19 @@ function CostPredictionChart({ projectId }) { ); } - const currentMonth = new Date().toLocaleString('default', { - month: 'long', - year: 'numeric', - }); + const currentMonthKey = toMonthKey(new Date()); return (
+ Monthly actual and predicted costs for this project and category. The chart at left shows + project expense totals, so its values are not directly comparable to an individual month. +
No expenditure projects found.
Select a project to view cost tracking.