diff --git a/src/components/BMDashboard/WeeklyProjectSummary/Financials/CostVarianceTrendGraph.jsx b/src/components/BMDashboard/WeeklyProjectSummary/Financials/CostVarianceTrendGraph.jsx new file mode 100644 index 0000000000..82a9133a23 --- /dev/null +++ b/src/components/BMDashboard/WeeklyProjectSummary/Financials/CostVarianceTrendGraph.jsx @@ -0,0 +1,393 @@ +import { useState, useEffect, useMemo } from 'react'; +import PropTypes from 'prop-types'; +import { + BarChart, + Bar, + XAxis, + YAxis, + ResponsiveContainer, + Tooltip, + Cell, + ReferenceLine, +} from 'recharts'; +import moment from 'moment'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import Select from 'react-select'; +import { useSelector } from 'react-redux'; +import styles from './CostVarianceTrendGraph.module.css'; + +const MOCK_RAW_DATA = [ + ['Project A', 'Plumbing', 1000, 1200, '2026-05-01'], + ['Project A', 'Electrical', 1500, 1300, '2026-05-02'], + ['Project B', 'Plumbing', 1100, 1050, '2026-05-03'], + ['Project B', 'Structural', 2200, 2150, '2026-05-04'], + ['Project C', 'Mechanical', 1300, 1800, '2026-05-05'], + ['Project A', 'Structural', 900, 1400, '2026-05-08'], + ['Project B', 'Electrical', 2000, 1600, '2026-05-09'], + ['Project C', 'Plumbing', 800, 750, '2026-05-10'], + ['Project A', 'Mechanical', 2500, 2400, '2026-05-11'], + ['Project C', 'Electrical', 1800, 2100, '2026-05-12'], + ['Project B', 'Structural', 3000, 3500, '2026-05-15'], + ['Project B', 'Mechanical', 1500, 1400, '2026-05-16'], + ['Project A', 'Plumbing', 1200, 1100, '2026-05-17'], + ['Project C', 'Structural', 4000, 4200, '2026-05-18'], + ['Project A', 'Electrical', 1700, 1650, '2026-05-19'], + ['Project B', 'Plumbing', 1300, 1100, '2026-05-22'], + ['Project C', 'Mechanical', 2100, 2500, '2026-05-23'], + ['Project A', 'Structural', 2800, 2800, '2026-05-24'], + ['Project B', 'Electrical', 1900, 1750, '2026-05-25'], + ['Project C', 'Plumbing', 1500, 1600, '2026-05-26'], +]; + +const MOCK_DB = MOCK_RAW_DATA.map(([projectId, category, plannedCost, actualCost, date]) => ({ + projectId, + category, + plannedCost, + actualCost, + date, +})); + +const aggregateTrendData = (data, projectFilter, categoryFilter, dateRange) => { + if (!Array.isArray(data)) return []; + + const filtered = data.filter(entry => { + const entryDate = moment(entry.date); + if (dateRange.startDate && entryDate.isBefore(moment(dateRange.startDate).startOf('day'))) + return false; + if (dateRange.endDate && entryDate.isAfter(moment(dateRange.endDate).endOf('day'))) + return false; + if (projectFilter !== 'ALL' && entry.projectId !== projectFilter) return false; + if (categoryFilter !== 'ALL' && entry.category !== categoryFilter) return false; + return true; + }); + + const groupedByDate = {}; + filtered.forEach(entry => { + const key = entry.date; + if (!groupedByDate[key]) { + groupedByDate[key] = { date: key, planned: 0, actual: 0 }; + } + groupedByDate[key].planned += entry.plannedCost; + groupedByDate[key].actual += entry.actualCost; + }); + + return Object.values(groupedByDate) + .map(item => ({ ...item, variance: item.actual - item.planned })) + .sort((a, b) => new Date(a.date) - new Date(b.date)); +}; + +const getOptionBackgroundColor = (darkMode, isSelected, isFocused) => { + if (isSelected && darkMode) return '#e8a71c'; + if (isSelected && !darkMode) return '#0d55b3'; + if (isFocused && darkMode) return '#3a506b'; + if (isFocused && !darkMode) return '#f0f0f0'; + return darkMode ? '#253342' : '#fff'; +}; + +const getOptionColor = (darkMode, isSelected) => { + if (isSelected) return darkMode ? '#000' : '#fff'; + return darkMode ? '#ffffff' : '#000'; +}; + +const generateSelectStyles = darkMode => { + const bgMain = darkMode ? '#253342' : '#fff'; + const borderMain = darkMode ? '#2d4059' : '#ccc'; + const textMain = darkMode ? '#ffffff' : '#000'; + const textMuted = darkMode ? '#94a3b8' : '#999'; + + return { + control: base => ({ + ...base, + minHeight: '38px', + width: '100%', + fontSize: '13px', + backgroundColor: bgMain, + borderColor: borderMain, + color: textMain, + boxShadow: 'none', + borderRadius: '6px', + '&:hover': { borderColor: borderMain }, + }), + valueContainer: base => ({ ...base, padding: '2px 8px', color: textMain }), + input: base => ({ ...base, margin: '0px', padding: '0px', color: textMain }), + indicatorSeparator: base => ({ ...base, backgroundColor: borderMain }), + dropdownIndicator: base => ({ + ...base, + color: textMuted, + padding: '4px', + ':hover': { color: textMain }, + }), + singleValue: base => ({ ...base, color: textMain, fontSize: '13px' }), + menu: base => ({ + ...base, + backgroundColor: bgMain, + border: `1px solid ${borderMain}`, + borderRadius: '6px', + boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)', + zIndex: 9999, + }), + menuList: base => ({ ...base, backgroundColor: bgMain, borderRadius: '6px' }), + option: (base, state) => ({ + ...base, + backgroundColor: getOptionBackgroundColor(darkMode, state.isSelected, state.isFocused), + color: getOptionColor(darkMode, state.isSelected), + cursor: 'pointer', + padding: '8px 12px', + fontSize: '13px', + ':active': { backgroundColor: darkMode ? '#3a506b' : '#e0e0e0' }, + }), + }; +}; + +const CustomTooltip = ({ active, payload, label, darkMode }) => { + if (!active || !payload?.length) return null; + + const chartData = payload[0].payload; + const isOverBudget = chartData.variance > 0; + + let varianceClass = ''; + if (isOverBudget) { + varianceClass = darkMode ? styles.varianceOverDark : styles.varianceOverLight; + } else { + varianceClass = darkMode ? styles.varianceUnderDark : styles.varianceUnderLight; + } + + return ( +
+
+ Date: {moment(label).format('MMM DD, YYYY')} +
+
+ Planned: ${chartData.planned.toLocaleString()} +
+
+ Actual: ${chartData.actual.toLocaleString()} +
+
+ Variance: {chartData.variance > 0 ? '+' : ''}${chartData.variance.toLocaleString()} +
+
+ ); +}; + +CustomTooltip.propTypes = { + active: PropTypes.bool, + payload: PropTypes.array, + label: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + darkMode: PropTypes.bool, +}; + +const formatTickDate = val => moment(val).format('MMM DD'); +const formatTickValue = value => `$${value}`; + +const buildDropdownOptions = (data, key) => { + const uniqueValues = [...new Set(data.map(item => item[key]))]; + return [{ label: 'ALL', value: 'ALL' }, ...uniqueValues.map(val => ({ label: val, value: val }))]; +}; + +const getSelectedOption = (options, value) => options.find(option => option.value === value); + +const getCellColor = (variance, darkMode) => { + if (variance > 0) return darkMode ? '#ff4444' : '#e74c3c'; + return darkMode ? '#4ade80' : '#2ecc71'; +}; + +export default function CostVarianceTrendGraph() { + const [initialLoading, setInitialLoading] = useState(true); + const [data, setData] = useState([]); + + const darkMode = useSelector(state => state.theme.darkMode); + const textColor = darkMode ? '#ffffff' : '#666'; + + const [projectId, setProjectId] = useState('ALL'); + const [categoryFilter, setCategoryFilter] = useState('ALL'); + const [dateRange, setDateRange] = useState({ startDate: null, endDate: null }); + + useEffect(() => { + const timer = setTimeout(() => { + setData(MOCK_DB); + setInitialLoading(false); + }, 300); + return () => clearTimeout(timer); + }, []); + + const chartData = useMemo(() => aggregateTrendData(data, projectId, categoryFilter, dateRange), [ + data, + projectId, + categoryFilter, + dateRange, + ]); + + const projectOptions = useMemo(() => buildDropdownOptions(data, 'projectId'), [data]); + const categoryOptions = useMemo(() => buildDropdownOptions(data, 'category'), [data]); + + const selectStyles = useMemo(() => generateSelectStyles(darkMode), [darkMode]); + + const handleStartDateChange = date => setDateRange(prev => ({ ...prev, startDate: date })); + const handleEndDateChange = date => setDateRange(prev => ({ ...prev, endDate: date })); + + const handleProjectChange = selected => setProjectId(selected ? selected.value : 'ALL'); + const handleCategoryChange = selected => setCategoryFilter(selected ? selected.value : 'ALL'); + + const selectedProject = getSelectedOption(projectOptions, projectId); + const selectedCategory = getSelectedOption(categoryOptions, categoryFilter); + + const containerClass = `${styles.varianceContainer} ${darkMode ? styles.darkMode : ''}`; + const dateInputClass = `${styles.dateInput} ${darkMode ? styles.darkDateInput : ''}`; + const calendarClass = darkMode ? 'paid-labor-cost-dark-calendar' : ''; + const tooltipCursorFill = darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)'; + + if (initialLoading) { + return ( +
+

Cost Variance Trend

+
Loading trend data...
+
+ ); + } + + return ( +
+

Cost Variance Trend

+ +
+
+
Project
+ +
+
+
Date Range
+
+
+ +
+ to +
+ +
+
+
+
+ +
+ + + Over Budget Risk (+ Variance) + + + + Budget Savings (- Variance) + +
+ +
+
+ {chartData.length === 0 ? ( +
No data available for the selected filters.
+ ) : ( + + + + + } + cursor={{ fill: tooltipCursorFill }} + /> + + + {chartData.map(entry => ( + + ))} + + + + )} +
+
+
+ ); +} diff --git a/src/components/BMDashboard/WeeklyProjectSummary/Financials/CostVarianceTrendGraph.module.css b/src/components/BMDashboard/WeeklyProjectSummary/Financials/CostVarianceTrendGraph.module.css new file mode 100644 index 0000000000..d7f4d28260 --- /dev/null +++ b/src/components/BMDashboard/WeeklyProjectSummary/Financials/CostVarianceTrendGraph.module.css @@ -0,0 +1,180 @@ +.varianceContainer { + width: 100%; + display: flex; + flex-direction: column; + height: 100%; +} + +.varianceTitle { + text-align: center; + margin-bottom: 24px; + font-weight: 700; + color: #1e293b; +} + +.darkMode .varianceTitle { + color: #f8fafc; +} + +.varianceLoading { + text-align: center; + padding: 40px; + font-weight: 500; + color: #64748b; +} + +.darkMode .varianceLoading { + color: #94a3b8; +} + +.filtersGrid { + display: grid; + grid-template-columns: 1fr 1fr 2.5fr; + gap: 16px; + margin-bottom: 24px; + width: 100%; +} + +@media (max-width: 900px) { + .filtersGrid { + grid-template-columns: 1fr; + } +} + +.filterGroup { + display: flex; + flex-direction: column; + gap: 6px; +} + +.filterLabel { + font-size: 13px; + font-weight: 600; + color: #334155; +} + +.darkMode .filterLabel { + color: #cbd5e1; +} + +.dateRangeFlex { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + width: 100%; +} + +.datePickerWrapper { + flex: 1; + width: 100%; + min-width: 0; +} + +.datePickerWrapper > div { + width: 100%; +} + +.dateInput { + width: 100%; + padding: 8px 30px 8px 12px; + border-radius: 6px; + border: 1px solid #ccc; + font-size: 13px; + box-sizing: border-box; + height: 38px; + outline: none; +} + +.dateInput:focus { + border-color: #4285F4; +} + +.darkDateInput { + background-color: #253342; + color: #fff; + border-color: #2d4059; + color-scheme: dark; +} + +.darkDateInput:focus { + border-color: #3a506b; +} + +.dateSeparator { + flex: 0 0 auto; + font-size: 13px; + font-weight: 500; + color: #64748b; + text-align: center; +} + +.darkMode .dateSeparator { + color: #94a3b8; +} + +.chartWrapper { + width: 100%; + min-height: 350px; + margin-bottom: 12px; + flex-grow: 1; +} + +.chartContainer { + height: 100%; + width: 100%; + position: relative; +} + +.emptyState { + display: flex; + justify-content: center; + align-items: center; + height: 100%; + color: #64748b; + font-style: italic; + font-size: 14px; + background-color: #f8f9fa; + border-radius: 8px; + border: 1px dashed #cbd5e1; +} + +.darkMode .emptyState { + color: #94a3b8; + background-color: #1e293b; + border-color: #334155; +} + +.legendContainer { + display: flex; + justify-content: center; + gap: 24px; + font-size: 13px; + font-weight: 500; + margin-bottom: 16px; + color: #334155; + flex-wrap: wrap; +} + +.darkMode .legendContainer { + color: #cbd5e1; +} + +.legendItem { + display: flex; + align-items: center; + gap: 8px; +} + +.legendDot { + width: 12px; + height: 12px; + border-radius: 50%; + display: inline-block; +} + +.varianceOverLight { color: #e74c3c !important; } +.varianceOverDark { color: #ff4444 !important; } + +.varianceUnderLight { color: #2ecc71 !important; } +.varianceUnderDark { color: #4ade80 !important; } \ No newline at end of file diff --git a/src/components/BMDashboard/WeeklyProjectSummary/WeeklyProjectSummary.jsx b/src/components/BMDashboard/WeeklyProjectSummary/WeeklyProjectSummary.jsx index 68108ad932..0d0681c9c4 100644 --- a/src/components/BMDashboard/WeeklyProjectSummary/WeeklyProjectSummary.jsx +++ b/src/components/BMDashboard/WeeklyProjectSummary/WeeklyProjectSummary.jsx @@ -17,6 +17,7 @@ import IssuesBreakdownChart from './IssuesBreakdownChart'; import InjuryCategoryBarChart from './GroupedBarGraphInjurySeverity/InjuryCategoryBarChart'; import ToolsHorizontalBarChart from './Tools/ToolsHorizontalBarChart'; import ExpenseBarChart from './Financials/ExpenseBarChart'; +import CostVarianceTrendGraph from './Financials/CostVarianceTrendGraph'; import CostBreakDown from './Financials/CostBreakDown/CostBreakDown'; import ActualVsPlannedCost from './ActualVsPlannedCost/ActualVsPlannedCost'; import TotalMaterialCostPerProject from './TotalMaterialCostPerProject/TotalMaterialCostPerProject'; @@ -351,12 +352,45 @@ function WeeklyProjectSummary() { key: 'Financials', className: 'large', content: ( -
-
📊 Card
-
- +
+ {/* Top Left: Planned vs Actual Cost */} +
+
-
+ + {/* Top Right: Cost Variance Trend */} +
+ +
+ + {/* Bottom: Cost Breakdown Pie Chart (Spans across both columns) */} +