diff --git a/src/components/BMDashboard/CostPrediction/CostPredictionPage.jsx b/src/components/BMDashboard/CostPrediction/CostPredictionPage.jsx new file mode 100644 index 0000000000..fe4669390a --- /dev/null +++ b/src/components/BMDashboard/CostPrediction/CostPredictionPage.jsx @@ -0,0 +1,639 @@ +import { useState, useEffect, Fragment } from 'react'; +import PropTypes from 'prop-types'; +import { + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + Legend, + ResponsiveContainer, + ReferenceLine, +} from 'recharts'; +import moment from 'moment'; +import Select from 'react-select'; +import { getProjectCosts, getProjectIds } from '../../../services/projectCostTrackingService'; +import { useSelector } from 'react-redux'; +import ReactTooltip from 'react-tooltip'; +import { Info } from 'lucide-react'; +import styles from './CostPredictionPage.module.css'; +import { + DARK, + LIGHT, + getSingleSelectStyles, + getMultiSelectStyles, + getAxisTickFill, + getGridStroke, +} from '../../../utils/bmChartStyles'; + +// Cost category options +const costOptions = [ + { value: 'Labor', label: 'Labor Cost' }, + { value: 'Materials', label: 'Materials Cost' }, + { value: 'Equipment', label: 'Equipment Cost' }, + { value: 'Total', label: 'Total Cost' }, +]; + +// Custom dot component for predicted values - consolidated to reduce duplication +function PredictedDot({ cx, cy, payload, category, costColors, size = 4 }) { + if (!payload || !payload[`${category}Predicted`]) return null; + return ( + + ); +} + +PredictedDot.propTypes = { + cx: PropTypes.number, + cy: PropTypes.number, + payload: PropTypes.object, + category: PropTypes.string.isRequired, + costColors: PropTypes.object.isRequired, + size: PropTypes.number, +}; + +// Define line colors +const costColors = { + Labor: '#4589FF', + Materials: '#FF6A00', + Equipment: '#8A2BE2', + Total: '#3CB371', +}; + +// Create specific dot components for common categories +function createDotComponent(category) { + function DotComponent(props) { + return ( + + ); + } + DotComponent.propTypes = { + cx: PropTypes.number, + cy: PropTypes.number, + payload: PropTypes.object, + }; + return DotComponent; +} + +// Pre-defined dot components for different categories +const LaborDot = createDotComponent('Labor'); +const MaterialsDot = createDotComponent('Materials'); +const EquipmentDot = createDotComponent('Equipment'); +const TotalDot = createDotComponent('Total'); + +// Calculate last predicted values for reference lines +const getLastPredictedValues = costData => { + const lastValues = {}; + + if (!costData || !costData.predicted) { + return lastValues; + } + + Object.keys(costData.predicted).forEach(category => { + const predictedItems = costData.predicted[category]; + if (predictedItems && predictedItems.length > 0) { + // Get the last predicted value for this category + const lastPredicted = predictedItems[predictedItems.length - 1]; + lastValues[category] = lastPredicted.cost; + } + }); + + return lastValues; +}; + +// Process data for chart - modify this function to connect actual and predicted data +const processDataForChart = costData => { + const processedData = []; + + if (!costData || !costData.actual) { + return processedData; + } + + // Get all dates from both actual and predicted data + const allDates = new Set(); + const actualCategories = Object.keys(costData.actual); + const predictedCategories = costData.predicted ? Object.keys(costData.predicted) : []; + + // Collect all dates + actualCategories.forEach(category => { + costData.actual[category].forEach(costItem => { + const dateStr = moment(costItem.date).format('MMM YYYY'); + allDates.add(dateStr); + }); + }); + + if (costData.predicted) { + predictedCategories.forEach(category => { + costData.predicted[category].forEach(costItem => { + const dateStr = moment(costItem.date).format('MMM YYYY'); + allDates.add(dateStr); + }); + }); + } + + // Sort dates chronologically + const sortedDates = Array.from(allDates).sort((a, b) => + moment(a, 'MMM YYYY').diff(moment(b, 'MMM YYYY')), + ); + + // Create data points for each date + sortedDates.forEach(dateStr => { + const dataPoint = { date: dateStr }; + + // Add actual data values + actualCategories.forEach(category => { + const costItem = costData.actual[category].find( + costEntry => moment(costEntry.date).format('MMM YYYY') === dateStr, + ); + if (costItem) { + dataPoint[category] = costItem.cost; + } + }); + + // Add predicted data values + if (costData.predicted) { + predictedCategories.forEach(category => { + const costItem = costData.predicted[category].find( + costEntry => moment(costEntry.date).format('MMM YYYY') === dateStr, + ); + if (costItem) { + dataPoint[`${category}Predicted`] = costItem.cost; + } + }); + } + + // For the last actual data point of each category, also add it as the first predicted point + if (costData.predicted) { + actualCategories.forEach(category => { + const actualItems = costData.actual[category]; + if (actualItems && actualItems.length > 0) { + const lastActualItem = actualItems[actualItems.length - 1]; + const lastActualDateStr = moment(lastActualItem.date).format('MMM YYYY'); + + if (dateStr === lastActualDateStr) { + dataPoint[`${category}Predicted`] = lastActualItem.cost; + } + } + }); + } + + processedData.push(dataPoint); + }); + + return processedData; +}; + +// Shared marker component for legend and tooltip (diamond for predicted, circle for actual) +function ChartMarker({ color, isPredicted }) { + return isPredicted ? ( + + ) : ( + + ); +} + +ChartMarker.propTypes = { + color: PropTypes.string.isRequired, + isPredicted: PropTypes.bool.isRequired, +}; + +// Map dataKey base names to display labels +const COST_LABELS = { + Labor: 'Labor Cost', + Materials: 'Materials Cost', + Equipment: 'Equipment Cost', + Total: 'Total Cost', +}; + +// Custom tooltip component +function CustomTooltip({ active, payload, label, currency, darkMode }) { + if (!active || !payload || !payload.length) { + return null; + } + + const textColor = darkMode ? DARK.text : LIGHT.text; + + // Check if any payload entry is predicted data + const hasActualData = payload.some(entry => !entry.dataKey.includes('Predicted')); + const hasPredictedData = payload.some(entry => entry.dataKey.includes('Predicted')); + + // If both actual and predicted exist, prioritize showing "Actual" + const displayType = hasActualData ? 'Actual' : hasPredictedData ? 'Predicted' : 'Actual'; + // Filter payload: if actual data exists, only show actual data; otherwise show predicted data + const filteredPayload = hasActualData + ? payload.filter(entry => !entry.dataKey.includes('Predicted')) + : payload; + + return ( +
+

+ {label} +

+

+ {displayType} +

+ {filteredPayload.map(entry => { + const isPredicted = entry.dataKey.includes('Predicted'); + const baseDataKey = entry.dataKey.replace('Predicted', ''); + const costLabel = COST_LABELS[baseDataKey] || baseDataKey; + + return ( +
+ + + {costLabel}: + + + {`${currency}${entry.value.toLocaleString()}`} + +
+ ); + })} +
+ ); +} + +CustomTooltip.propTypes = { + active: PropTypes.bool, + payload: PropTypes.array, + label: PropTypes.string, + currency: PropTypes.string.isRequired, + darkMode: PropTypes.bool, +}; + +function CostPredictionPage({ projectId }) { + const [data, setData] = useState([]); + const [selectedCosts, setSelectedCosts] = useState(['Labor', 'Materials']); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [currency] = useState('$'); + const [availableProjects, setAvailableProjects] = useState([]); + const [selectedProject, setSelectedProject] = useState(null); + const [lastPredictedValues, setLastPredictedValues] = useState({}); + const darkMode = useSelector(state => state.theme.darkMode); + + useEffect(() => { + const fetchProjects = async () => { + try { + const projectIds = await getProjectIds(); + setAvailableProjects(projectIds.map(id => ({ value: id, label: id }))); + if (projectIds.length > 0) { + const initialProject = projectId || projectIds[0]; + setSelectedProject({ value: initialProject, label: initialProject }); + } + } catch { + setError('Failed to load projects'); + } + }; + fetchProjects(); + }, [projectId]); + + useEffect(() => { + const fetchData = async () => { + if (!selectedProject) return; + setLoading(true); + try { + const costData = await getProjectCosts(selectedProject.value); + setData(processDataForChart(costData)); + setLastPredictedValues(getLastPredictedValues(costData)); + setLoading(false); + } catch { + setError('Failed to load cost data'); + setLoading(false); + } + }; + fetchData(); + }, [selectedProject]); + + const handleCostChange = selected => { + const selectedValues = selected ? selected.map(option => option.value) : []; + setSelectedCosts(selectedValues); + }; + const handleProjectChange = selected => setSelectedProject(selected); + + // Pick the right dot component by name + const getDotRenderer = category => { + switch (category) { + case 'Labor': + return LaborDot; + case 'Materials': + return MaterialsDot; + case 'Equipment': + return EquipmentDot; + case 'Total': + return TotalDot; + default: + return LaborDot; + } + }; + + // Toggle dark-mode body class for global Recharts overrides in the CSS module + useEffect(() => { + document.body.classList.toggle('dark-mode-body', darkMode); + return () => document.body.classList.remove('dark-mode-body'); + }, [darkMode]); + + return ( +
+ {/* ReactTooltip moved outside wrapper for better positioning */} + +
Chart Overview
+
This chart compares planned vs actual costs across different categories.
+
    +
  • Solid lines represent actual costs for each category.
  • +
  • Dashed lines with diamond markers represent predicted/planned costs.
  • +
  • Hover over lines to view exact cost values.
  • +
  • + The dropdown filters allow you to: +
      +
    • Select specific cost categories (multi-select).
    • +
    • Pick a specific project.
    • +
    +
  • +
  • + Color coding: +
      +
    • + Blue – Labor costs +
    • +
    • + Orange – Materials costs +
    • +
    • + Purple – Equipment costs +
    • +
    • + Green – Total costs +
    • +
    +
  • +
+
+ +
+
+

Planned v Actual Costs Tracking

+ +
+ +
+ selectedCosts.includes(option.value))} + onChange={handleCostChange} + placeholder="All Cost Categories" + classNamePrefix="custom-select" + className={`${styles.dropdownItem} ${styles.multiSelect}`} + menuPosition="fixed" + closeMenuOnSelect={false} + hideSelectedOptions={false} + styles={getMultiSelectStyles(darkMode)} + /> +
+ +
+ {loading &&
Loading...
} + {error &&
{error}
} + + {!loading && !error && data.length > 0 && ( +
+ + + + + `${currency}${value}`} + width={50} + /> + } /> + { + const legendTextColor = getAxisTickFill(darkMode); + return ( +
+ {props.payload.map((entry, index) => { + const isPredicted = entry.value.includes('Predicted'); + return ( +
+ + {entry.value} +
+ ); + })} +
+ ); + }} + /> + + {/* Reference Lines for Last Predicted Values */} + {(selectedCosts.length > 0 + ? selectedCosts + : ['Labor', 'Materials'] + ).map(category => + lastPredictedValues[category] ? ( + + ) : null, + )} + + {/* Dynamically render lines based on selected costs */} + {(selectedCosts.length > 0 ? selectedCosts : ['Labor', 'Materials']).map( + category => ( + + {/* Actual cost line */} + + {/* Predicted cost line */} + + + ), + )} +
+
+
+ )} + + {!loading && !error && data.length === 0 && ( +
+

No data available

+
+ )} +
+ + {/* Fixed label below chart */} +
+ πŸ“Š Actual Costs + vs + πŸ“ˆ Predicted Costs +
+
+
+ ); +} + +CostPredictionPage.propTypes = { + projectId: PropTypes.string, +}; + +export default CostPredictionPage; diff --git a/src/components/BMDashboard/CostPrediction/CostPredictionPage.module.css b/src/components/BMDashboard/CostPrediction/CostPredictionPage.module.css new file mode 100644 index 0000000000..b9caaede0f --- /dev/null +++ b/src/components/BMDashboard/CostPrediction/CostPredictionPage.module.css @@ -0,0 +1,247 @@ +/* Cost Prediction Page Styles */ +.costPredictionPage { + padding: 20px; + background-color: var(--card-bg); + min-height: 100vh; + color: var(--text-color); +} + +/* Chart Title Container */ +.chartTitleContainer { + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 15px; + padding: 0 10px; + position: relative; +} + +.costPredictionTitle { + margin: 0; + font-size: 18px; + font-weight: 600; + color: var(--text-color); + text-align: center; +} + +.costPredictionInfoButton { + background: none; + border: none; + cursor: pointer; + padding: 4px; + border-radius: 4px; + color: var(--text-color); + opacity: 0.7; + transition: opacity 0.2s ease; + position: absolute; + right: 10px; +} + +.costPredictionInfoButton:hover { + opacity: 1; +} + +.costPredictionTooltip { + max-width: 300px; + font-size: 12px; + line-height: 1.4; + z-index: 1000 !important; + position: fixed !important; +} + +/* Dropdown Container */ +.dropdownContainer { + display: flex; + gap: 10px; + margin-bottom: 15px; + padding: 0 10px; + flex-wrap: wrap; +} + +.dropdownItem { + flex: 1; + min-width: 120px; +} + +.multiSelect { + min-width: 150px; +} + +/* Chart Wrapper - protects from external styles */ +.costPredictionWrapper { + width: 100%; + height: 100%; + padding: 10px; + box-sizing: border-box; + position: relative; + background-color: var(--card-bg); + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +/* Chart Container */ +.costPredictionChartContainer { + position: relative; + height: 500px; + width: 100%; + padding: 0 10px; + margin-bottom: 5px; + background-color: transparent; +} + +/* Chart Legend */ +.chartLegend { + width: 100%; + text-align: center; + font-size: 14px; + margin-top: 2px; + color: var(--text-color); + display: flex; + justify-content: center; + gap: 6px; + flex-wrap: wrap; + align-items: center; +} + +.costChartLoading, +.costChartError, +.costChartEmpty { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + color: var(--text-color); + font-size: 16px; +} + +.costChartError { + color: #ff4d4f; +} + +/* Chart Tooltip Styling */ +.costPredictionChartContainer :global(.recharts-tooltip-wrapper) { + z-index: 1000 !important; +} + +.costPredictionChartContainer :global(.recharts-default-tooltip) { + background-color: var(--card-bg) !important; + border: 1px solid var(--button-hover) !important; + border-radius: 4px !important; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15) !important; + padding: 8px !important; +} + +.costPredictionChartContainer :global(.recharts-tooltip-label) { + color: var(--text-color) !important; + font-weight: bold !important; + margin-bottom: 4px !important; +} + +.costPredictionChartContainer :global(.recharts-tooltip-item) { + color: var(--text-color) !important; + padding: 2px 0 !important; +} + +.costPredictionChartContainer :global(.recharts-tooltip-item-name) { + color: var(--text-color) !important; +} + +.costPredictionChartContainer :global(.recharts-tooltip-item-value) { + color: var(--text-color) !important; + font-weight: bold !important; +} + +/* Dark mode styles */ +:global(.dark-mode) .costPredictionPage { + background-color: #1e2736; + color: #e0e0e0; +} + +:global(.dark-mode) .costPredictionWrapper { + background-color: #2c3344; +} + +:global(.dark-mode) .costPredictionChartContainer { + background-color: #1e2736; + color: #e0e0e0; +} + +:global(.dark-mode) .costPredictionChartContainer :global(.recharts-default-tooltip) { + background-color: #2c3344 !important; + border-color: #364156 !important; + color: #e0e0e0 !important; +} + +:global(.dark-mode) .costPredictionTitle { + color: #e0e0e0; +} + +:global(.dark-mode) .costPredictionInfoButton { + color: #e0e0e0; +} + +:global(.dark-mode) .costPredictionInfoButton:hover { + color: #ffffff; +} + +/* Dark-mode body overrides for Recharts (replaces injected