diff --git a/src/components/BMDashboard/WeeklyProjectSummary/Financials/ExpenseBarChart.jsx b/src/components/BMDashboard/WeeklyProjectSummary/Financials/ExpenseBarChart.jsx index f1c4594a55..4db242b9d3 100644 --- a/src/components/BMDashboard/WeeklyProjectSummary/Financials/ExpenseBarChart.jsx +++ b/src/components/BMDashboard/WeeklyProjectSummary/Financials/ExpenseBarChart.jsx @@ -1,9 +1,217 @@ -import { BarChart, Bar, XAxis, YAxis, LabelList, ResponsiveContainer } from 'recharts'; +import { + BarChart, + Bar, + XAxis, + YAxis, + LabelList, + ResponsiveContainer, + Tooltip, + Cell, +} from 'recharts'; import { useState, useEffect } from 'react'; +import PropTypes from 'prop-types'; const categories = ['Plumbing', 'Electrical', 'Structural', 'Mechanical']; const projects = ['Project A', 'Project B', 'Project C']; +const rawData = [ + { + projectId: 'Project A', + category: 'Plumbing', + plannedCost: 1000, + actualCost: 1200, + date: '2025-04-01', + }, + { + projectId: 'Project A', + category: 'Electrical', + plannedCost: 1500, + actualCost: 1300, + date: '2025-04-01', + }, + { + projectId: 'Project B', + category: 'Plumbing', + plannedCost: 1100, + actualCost: 1050, + date: '2025-04-02', + }, + { + projectId: 'Project B', + category: 'Structural', + plannedCost: 2200, + actualCost: 2150, + date: '2025-04-02', + }, + { + projectId: 'Project C', + category: 'Mechanical', + plannedCost: 1300, + actualCost: 1350, + date: '2025-04-03', + }, + { + projectId: 'Project C', + category: 'Electrical', + plannedCost: 1400, + actualCost: 1600, + date: '2025-04-03', + }, +]; + +const isDateMatch = (entryDate, startDate, endDate) => { + if (startDate && entryDate < startDate) return false; + if (endDate && entryDate > endDate) return false; + return true; +}; + +const isProjectMatch = (entryProject, projectId) => projectId === '' || entryProject === projectId; +const isCategoryMatch = (entryCategory, categoryFilter) => + categoryFilter === 'ALL' || entryCategory === categoryFilter; + +const getFilteredAndAggregatedData = (startDate, endDate, projectId, categoryFilter) => { + const filtered = rawData.filter( + entry => + isDateMatch(entry.date, startDate, endDate) && + isProjectMatch(entry.projectId, projectId) && + isCategoryMatch(entry.category, categoryFilter), + ); + + const aggregated = {}; + filtered.forEach(entry => { + const key = entry.projectId; + if (!aggregated[key]) { + aggregated[key] = { project: key, planned: 0, actual: 0 }; + } + aggregated[key].planned += entry.plannedCost; + aggregated[key].actual += entry.actualCost; + }); + + return Object.values(aggregated).map(item => { + item.variance = item.actual - item.planned; + const percent = item.planned === 0 ? 0 : ((item.variance / item.planned) * 100).toFixed(1); + item.variancePercent = percent > 0 ? `+${percent}%` : `${percent}%`; + item.varianceLabel = item.variance > 0 ? `+$${item.variance}` : `-$${Math.abs(item.variance)}`; + return item; + }); +}; + +const getTheme = darkMode => { + const mode = darkMode ? 'dark' : 'light'; + return { + inputBg: { dark: '#2d3748', light: '#fff' }[mode], + inputText: { dark: '#f8fafc', light: '#0f172a' }[mode], + inputBorder: { dark: '1px solid #475569', light: '1px solid #cbd5e1' }[mode], + labelColor: { dark: '#e2e8f0', light: '#334155' }[mode], + titleColor: { dark: '#f8fafc', light: '#1e293b' }[mode], + legendColor: { dark: '#cbd5e1', light: '#334155' }[mode], + emptyTextColor: { dark: '#94a3b8', light: '#64748b' }[mode], + axisStroke: { dark: '#64748b', light: '#94a3b8' }[mode], + axisTick: { dark: '#cbd5e1', light: '#475569' }[mode], + cursorFill: { dark: 'rgba(255, 255, 255, 0.05)', light: 'rgba(0, 0, 0, 0.05)' }[mode], + tooltipBg: { dark: '#1e293b', light: '#ffffff' }[mode], + tooltipBorder: { dark: '1px solid #334155', light: '1px solid #e2e8f0' }[mode], + tooltipText: { dark: '#f8fafc', light: '#0f172a' }[mode], + tooltipHeaderBorder: { dark: '1px solid #475569', light: '1px solid #f1f5f9' }[mode], + tooltipHeaderColor: { dark: '#94a3b8', light: '#64748b' }[mode], + overBudget: { dark: '#ff5252', light: '#EA4335' }[mode], + underBudget: { dark: '#4ade80', light: '#34A853' }[mode], + }; +}; + +const VarianceLabel = props => { + const { x, y, width, value, darkMode } = props; + const isOver = value?.toString().startsWith('+'); + const theme = getTheme(darkMode); + + return ( + + {value} + + ); +}; + +VarianceLabel.propTypes = { + x: PropTypes.number, + y: PropTypes.number, + width: PropTypes.number, + value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + darkMode: PropTypes.bool, +}; + +const CustomTooltip = ({ active, payload, label, darkMode }) => { + if (!active || !payload?.length) return null; + + const chartData = payload[0].payload; + const isOverBudget = chartData.variance > 0; + const theme = getTheme(darkMode); + + return ( +
+

+ {label} +

+

+ Planned: ${chartData.planned.toLocaleString()} +

+

+ Actual: ${chartData.actual.toLocaleString()} +

+

+ Variance: {chartData.variance > 0 ? '+' : ''}${chartData.variance.toLocaleString()} ( + {chartData.variancePercent}) +

+
+ ); +}; + +CustomTooltip.propTypes = { + active: PropTypes.bool, + payload: PropTypes.arrayOf( + PropTypes.shape({ + payload: PropTypes.shape({ + planned: PropTypes.number, + actual: PropTypes.number, + variance: PropTypes.number, + variancePercent: PropTypes.string, + }), + }), + ), + label: PropTypes.string, + darkMode: PropTypes.bool, +}; + export default function ExpenseBarChart({ darkMode }) { const [projectId, setProjectId] = useState(''); const [categoryFilter, setCategoryFilter] = useState('ALL'); @@ -12,109 +220,59 @@ export default function ExpenseBarChart({ darkMode }) { const [data, setData] = useState([]); const [errorMessage, setErrorMessage] = useState(''); - // Dark Mode Styles for Inputs/Selects + const theme = getTheme(darkMode); + + const today = new Date(); + const localTodayString = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart( + 2, + '0', + )}-${String(today.getDate()).padStart(2, '0')}`; + + const labelGroupStyle = { + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-start', + gap: '0.4rem', + color: theme.labelColor, + fontWeight: '600', + fontSize: '0.85rem', + width: '100%', + }; + const inputStyle = { - marginLeft: '0.3rem', width: '100%', - padding: '4px', + padding: '8px 10px', borderRadius: '4px', - backgroundColor: darkMode ? '#333' : '#fff', - color: darkMode ? '#eee' : '#000', - border: darkMode ? '1px solid #555' : '1px solid #ccc', + backgroundColor: theme.inputBg, + color: theme.inputText, + border: theme.inputBorder, outline: 'none', - }; - - const labelStyle = { - minWidth: '150px', - color: darkMode ? '#bbb' : '#555', + boxSizing: 'border-box', + colorScheme: darkMode ? 'dark' : 'light', }; useEffect(() => { - async function fetchData() { - try { - const rawData = [ - { - projectId: 'Project A', - category: 'Plumbing', - plannedCost: 1000, - actualCost: 1200, - date: '2025-04-01', - }, - { - projectId: 'Project A', - category: 'Electrical', - plannedCost: 1500, - actualCost: 1300, - date: '2025-04-01', - }, - { - projectId: 'Project B', - category: 'Plumbing', - plannedCost: 1100, - actualCost: 1050, - date: '2025-04-02', - }, - { - projectId: 'Project B', - category: 'Structural', - plannedCost: 2200, - actualCost: 2150, - date: '2025-04-02', - }, - { - projectId: 'Project C', - category: 'Mechanical', - plannedCost: 1300, - actualCost: 1350, - date: '2025-04-03', - }, - { - projectId: 'Project C', - category: 'Electrical', - plannedCost: 1400, - actualCost: 1600, - date: '2025-04-03', - }, - ]; - - const filtered = rawData.filter(entry => { - const entryDate = new Date(entry.date); - const start = startDate ? new Date(startDate) : null; - const end = endDate ? new Date(endDate) : null; - const dateMatch = (!start || entryDate >= start) && (!end || entryDate <= end); - const projectMatch = projectId === '' || entry.projectId === projectId; - const categoryMatch = categoryFilter === 'ALL' || entry.category === categoryFilter; - return dateMatch && projectMatch && categoryMatch; - }); - - const aggregated = {}; - filtered.forEach(entry => { - const key = entry.projectId; - if (!aggregated[key]) { - aggregated[key] = { project: key, planned: 0, actual: 0 }; - } - aggregated[key].planned += entry.plannedCost; - aggregated[key].actual += entry.actualCost; - }); - - setData(Object.values(aggregated)); - } catch (error) { - setErrorMessage('Something went wrong while loading chart data.'); - } + try { + const processedData = getFilteredAndAggregatedData( + startDate, + endDate, + projectId, + categoryFilter, + ); + setData(processedData); + } catch (error) { + setErrorMessage('Something went wrong while loading chart data.'); } - fetchData(); }, [projectId, categoryFilter, startDate, endDate]); return ( -
-
-

+
+
+

Planned vs Actual Cost

{errorMessage && ( -
+
{errorMessage}
)} @@ -122,16 +280,14 @@ export default function ExpenseBarChart({ darkMode }) {
-
- {/* Legend */}
- + {' '} Planned - + {' '} - Actual + Actual (Over Budget) + + + {' '} + Actual (Under Budget)
-
- - - - - - + {data.length === 0 && !errorMessage ? ( +
+ No data available for the selected filters. +
+ ) : ( + + + -
- - `$${value.toLocaleString()}`} /> - -
-
+ } + cursor={{ fill: theme.cursorFill }} + wrapperStyle={{ backgroundColor: 'transparent', outline: 'none' }} + contentStyle={{ backgroundColor: 'transparent', border: 'none' }} + /> + + `$${val.toLocaleString()}`} + /> + + + } + /> + {data.map(entry => ( + 0 ? theme.overBudget : theme.underBudget} + /> + ))} + + + + )}
); } + +ExpenseBarChart.propTypes = { + darkMode: PropTypes.bool, +}; diff --git a/src/components/BMDashboard/WeeklyProjectSummary/WeeklyProjectSummary.jsx b/src/components/BMDashboard/WeeklyProjectSummary/WeeklyProjectSummary.jsx index bdcb33cfa3..901dc54ff9 100644 --- a/src/components/BMDashboard/WeeklyProjectSummary/WeeklyProjectSummary.jsx +++ b/src/components/BMDashboard/WeeklyProjectSummary/WeeklyProjectSummary.jsx @@ -351,10 +351,9 @@ function WeeklyProjectSummary() { key: 'Financials', className: 'large', content: ( -
-
📊 Card
+
- +
@@ -422,7 +421,7 @@ function WeeklyProjectSummary() { }), }, ], - [quantityOfMaterialsUsedData], + [quantityOfMaterialsUsedData, darkMode], ); const handleSaveAsPDF = async () => {