diff --git a/package-lock.json b/package-lock.json index 878b1eb97c..800b4c879a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,6 @@ "dependencies": { "@babel/runtime": "^7.27.6", "@babel/runtime-corejs3": "^7.27.6", - "@changey/react-leaflet-markercluster": "^4.0.0-rc1", "@date-io/dayjs": "^3.2.0", "@emotion/react": "^11.8.1", "@emotion/styled": "^11.14.0", @@ -85,13 +84,14 @@ "react-dom": "^18.3.1", "react-icons": "^4.12.0", "react-leaflet": "4.2.1", + "react-leaflet-cluster": "^3.1.1", "react-multi-select-component": "^4.0.2", "react-phone-input-2": "^2.14.0", "react-redux": "^7.2.0", "react-router": "^5.3.4", "react-router-dom": "^5.2.0", "react-router-hash-link": "^2.3.1", - "react-select": "^5.7.2", + "react-select": "^5.10.1", "react-spinners": "^0.15.0", "react-sticky": "^6.0.3", "react-table": "^7.8.0", @@ -2306,23 +2306,6 @@ "node": ">= 4.0.0" } }, - "node_modules/@changey/react-leaflet-markercluster": { - "version": "4.0.0-rc1", - "resolved": "https://registry.npmjs.org/@changey/react-leaflet-markercluster/-/react-leaflet-markercluster-4.0.0-rc1.tgz", - "integrity": "sha512-gS1lEQiQwyeI6Y6Wuxuqqffwywm7giQw4tbcqtJP8zyT5bc3AzW2/EVJGwWORYo/PLDdDnvOrpI+lUJy2UA5MQ==", - "license": "MIT", - "dependencies": { - "@react-leaflet/core": "^2.0.0", - "leaflet": "^1.8.0", - "leaflet.markercluster": "^1.5.3", - "react-leaflet": "^4.0.0" - }, - "peerDependencies": { - "leaflet": "^1.8.0", - "leaflet.markercluster": "^1.5.3", - "react-leaflet": "^4.0.0" - } - }, "node_modules/@csstools/color-helpers": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.0.2.tgz", @@ -12543,6 +12526,21 @@ "react-dom": "^18.0.0" } }, + "node_modules/react-leaflet-cluster": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/react-leaflet-cluster/-/react-leaflet-cluster-3.1.1.tgz", + "integrity": "sha512-g39QohKNrmIwHNlL0KTv+a9PYYWDtcRAsnHHytJyRhIgjxGxs4/SXe/Zr4kP8IZoEzxbqCMkJqUsFBEZJqKp2g==", + "license": "SEE LICENSE IN ", + "dependencies": { + "leaflet.markercluster": "^1.5.3" + }, + "peerDependencies": { + "leaflet": "^1.8.0", + "react": "^18.2.0 || ^19.0.0", + "react-dom": "^18.2.0 || ^19.0.0", + "react-leaflet": "^4.0.0" + } + }, "node_modules/react-lifecycles-compat": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", diff --git a/package.json b/package.json index 441569cd9f..27222acf63 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,6 @@ "dependencies": { "@babel/runtime": "^7.27.6", "@babel/runtime-corejs3": "^7.27.6", - "@changey/react-leaflet-markercluster": "^4.0.0-rc1", "@date-io/dayjs": "^3.2.0", "@emotion/react": "^11.8.1", "@emotion/styled": "^11.14.0", @@ -82,6 +81,7 @@ "react-dom": "^18.3.1", "react-icons": "^4.12.0", "react-leaflet": "4.2.1", + "react-leaflet-cluster": "^3.1.1", "react-multi-select-component": "^4.0.2", "react-phone-input-2": "^2.14.0", "react-redux": "^7.2.0", diff --git a/src/components/BMDashboard/WeeklyProjectSummary/Financials/CostPredictionChart.jsx b/src/components/BMDashboard/WeeklyProjectSummary/Financials/CostPredictionChart.jsx new file mode 100644 index 0000000000..259595aa7f --- /dev/null +++ b/src/components/BMDashboard/WeeklyProjectSummary/Financials/CostPredictionChart.jsx @@ -0,0 +1,770 @@ +import { useState, useEffect, Fragment } from 'react'; +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 './CostPredictionChart.module.css'; + +// 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 - extracted to avoid nested component definition +function PredictedDot({ cx, cy, payload, category, costColors }) { + if (!payload || !payload[`${category}Predicted`]) return null; + return ( + + ); +} + +// Custom dot component for full page predicted values - extracted to avoid nested component definition +function FullPagePredictedDot({ cx, cy, payload, category, costColors }) { + if (!payload || !payload[`${category}Predicted`]) return null; + return ( + + ); +} + +// Define line colors +const costColors = { + Labor: '#4589FF', + Materials: '#FF6A00', + Equipment: '#8A2BE2', + Total: '#3CB371', +}; + +// Dot rendering functions for Labor and Materials - fixed components +const renderLaborDot = props => ( + +); + +const renderMaterialsDot = props => ( + +); + +// Create specific dot renderers for common categories +function createStaticDotRenderer(category) { + return function DotRenderer(props) { + return ( + + ); + }; +} + +// Pre-defined dot renderers for different categories +// eslint-disable-next-line testing-library/render-result-naming-convention +const laborUtils = createStaticDotRenderer('Labor'); +// eslint-disable-next-line testing-library/render-result-naming-convention +const materialsUtils = createStaticDotRenderer('Materials'); +// eslint-disable-next-line testing-library/render-result-naming-convention +const equipmentUtils = createStaticDotRenderer('Equipment'); +// eslint-disable-next-line testing-library/render-result-naming-convention +const totalUtils = createStaticDotRenderer('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; +}; + +// Custom tooltip component +function CustomTooltip({ active, payload, label, currency }) { + if (!active || !payload || !payload.length) { + return null; + } + + // 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'); + let costLabel = ''; + const baseDataKey = entry.dataKey.replace('Predicted', ''); + + if (baseDataKey === 'Labor') costLabel = 'Labor Cost'; + else if (baseDataKey === 'Materials') costLabel = 'Materials Cost'; + else if (baseDataKey === 'Equipment') costLabel = 'Equipment Cost'; + else if (baseDataKey === 'Total') costLabel = 'Total Cost'; + + return ( +
+ {isPredicted ? ( + // Solid diamond shape for predicted data + + ) : ( + // Solid circle for actual data + + )} + + {costLabel}: + + + {`${currency}${entry.value.toLocaleString()}`} + +
+ ); + })} +
+ ); +} + +function CostPredictionChart({ 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 renderer by name + const getDotRenderer = category => { + switch (category) { + case 'Labor': + return laborUtils; + case 'Materials': + return materialsUtils; + case 'Equipment': + return equipmentUtils; + case 'Total': + return totalUtils; + default: + return laborUtils; + } + }; + + // Apply dark mode styles to document body when in dark mode + useEffect(() => { + if (darkMode) { + document.body.classList.add('dark-mode-body'); + } else { + document.body.classList.remove('dark-mode-body'); + } + + // Add dark mode CSS for chart + if (!document.getElementById('dark-mode-styles-cost-prediction')) { + const styleElement = document.createElement('style'); + styleElement.id = 'dark-mode-styles-cost-prediction'; + styleElement.innerHTML = ` + .dark-mode-body .recharts-wrapper, + .dark-mode-body .recharts-surface { + background-color: #1e2736 !important; + } + .dark-mode-body .recharts-cartesian-grid-horizontal line, + .dark-mode-body .recharts-cartesian-grid-vertical line { + stroke: #364156 !important; + } + .dark-mode-body .recharts-text { + fill: #e0e0e0 !important; + } + .dark-mode-body .recharts-default-legend { + background-color: #1e2736 !important; + } + .dark-mode-body .recharts-tooltip-wrapper { + background-color: transparent !important; + } + .dark-mode-body .cost-prediction-chart-container { + background-color: #1e2736 !important; + color: #e0e0e0 !important; + } + `; + document.head.appendChild(styleElement); + } + + return () => { + // Cleanup + 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

+ + +
+ +
+ ({ + ...baseStyles, + backgroundColor: '#2c3344', + borderColor: '#364156', + }), + menu: baseStyles => ({ + ...baseStyles, + backgroundColor: '#2c3344', + }), + option: (baseStyles, state) => ({ + ...baseStyles, + backgroundColor: state.isFocused ? '#364156' : '#2c3344', + color: '#e0e0e0', + }), + singleValue: baseStyles => ({ + ...baseStyles, + color: '#e0e0e0', + }), + } + : {} + } + /> +
+ +
+ {loading &&
Loading...
} + {error &&
{error}
} + + {!loading && !error && data.length > 0 && ( +
+ + + + + `${currency}${value}`} + width={50} + /> + } /> + { + const { payload } = props; + return ( +
+ {payload.map((entry, index) => { + const isPredicted = entry.value.includes('Predicted'); + return ( +
+ {isPredicted ? ( + // Solid diamond shape for predicted +
+ ) : ( + // Solid circle for actual +
+ )} + {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 +
+
+
+ ); +} + +export default CostPredictionChart; diff --git a/src/components/BMDashboard/WeeklyProjectSummary/Financials/CostPredictionChart.module.css b/src/components/BMDashboard/WeeklyProjectSummary/Financials/CostPredictionChart.module.css new file mode 100644 index 0000000000..5fcf95d48a --- /dev/null +++ b/src/components/BMDashboard/WeeklyProjectSummary/Financials/CostPredictionChart.module.css @@ -0,0 +1,174 @@ +/* Chart Title Container */ +.chartTitleContainer { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 15px; + padding: 0 10px; +} + +.costPredictionChartTitle { + margin: 0; + font-size: 18px; + font-weight: 600; + color: var(--text-color); + flex: 1; +} + +.costPredictionChartInfoButton { + background: none; + border: none; + cursor: pointer; + padding: 4px; + border-radius: 4px; + color: var(--text-color); + opacity: 0.7; + transition: opacity 0.2s ease; +} + +.costPredictionChartInfoButton:hover { + opacity: 1; +} + +.costPredictionChartTooltip { + 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; +} + +.costPredictionCard { + /* Additional styles for cost prediction specific styling */ +} + +/* Chart Wrapper - protects from external styles */ +.costPredictionWrapper { + width: 100%; + height: 100%; + padding: 10px; + box-sizing: border-box; + position: relative; +} + +/* Override global financial-big class styles */ +:global(.weekly-project-summary-card.financial-big) .costPredictionWrapper { + padding: 10px !important; + width: 100% !important; +} + +/* Chart Container */ +.costPredictionChartContainer { + position: relative; + height: 280px; + width: 100%; + padding: 0 10px; + margin-bottom: 5px; + background-color: transparent; +} + +/* 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) .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) .costPredictionChartTitle { + color: #e0e0e0; +} + +:global(.dark-mode) .costPredictionChartInfoButton { + color: #e0e0e0; +} + +:global(.dark-mode) .costPredictionChartInfoButton:hover { + color: #ffffff; +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .dropdownContainer { + flex-direction: column; + } + + .dropdownItem { + min-width: 100%; + } + + .costPredictionChartTitle { + font-size: 16px; + } + + .costPredictionChartContainer { + height: 250px; + } +} + +@media (max-width: 480px) { + .chartTitleContainer { + flex-direction: column; + align-items: flex-start; + gap: 8px; + } + + .costPredictionChartContainer { + height: 200px; + } +} \ No newline at end of file diff --git a/src/components/BMDashboard/WeeklyProjectSummary/Financials/ExpectedVsActualBarChart.css b/src/components/BMDashboard/WeeklyProjectSummary/Financials/ExpectedVsActualBarChart.css deleted file mode 100644 index a3212368fc..0000000000 --- a/src/components/BMDashboard/WeeklyProjectSummary/Financials/ExpectedVsActualBarChart.css +++ /dev/null @@ -1,12 +0,0 @@ -.chart-container { - width: 100%; - min-height: 250px; - max-height: 400px; - padding: 0.5rem; - box-sizing: border-box; - overflow-x: auto; -} - -.recharts-wrapper text { - font-size: 11px; -} diff --git a/src/components/BMDashboard/WeeklyProjectSummary/Financials/ExpenseBarChart.jsx b/src/components/BMDashboard/WeeklyProjectSummary/Financials/ExpenseBarChart.jsx index cdb816e463..3ba55e9b46 100644 --- a/src/components/BMDashboard/WeeklyProjectSummary/Financials/ExpenseBarChart.jsx +++ b/src/components/BMDashboard/WeeklyProjectSummary/Financials/ExpenseBarChart.jsx @@ -1,5 +1,7 @@ import { BarChart, Bar, XAxis, YAxis, LabelList, ResponsiveContainer } from 'recharts'; import { useState, useEffect } from 'react'; +import { useSelector } from 'react-redux'; +import styles from './ExpenseBarChart.module.css'; const categories = ['Plumbing', 'Electrical', 'Structural', 'Mechanical']; const projects = ['Project A', 'Project B', 'Project C']; @@ -11,6 +13,7 @@ export default function ExpenseBarChart() { const [endDate, setEndDate] = useState(''); const [data, setData] = useState([]); const [errorMessage, setErrorMessage] = useState(''); + const darkMode = useSelector(state => state.theme.darkMode); useEffect(() => { async function fetchData() { @@ -89,34 +92,58 @@ export default function ExpenseBarChart() { fetchData(); }, [projectId, categoryFilter, startDate, endDate]); + // Apply dark mode styles to document body when in dark mode + useEffect(() => { + if (darkMode) { + document.body.classList.add('dark-mode-body'); + } else { + document.body.classList.remove('dark-mode-body'); + } + + // Add dark mode CSS for chart + if (!document.getElementById('dark-mode-styles-expense-chart')) { + const styleElement = document.createElement('style'); + styleElement.id = 'dark-mode-styles-expense-chart'; + styleElement.innerHTML = ` + .dark-mode-body .recharts-wrapper, + .dark-mode-body .recharts-surface { + background-color: #1e2736 !important; + } + .dark-mode-body .recharts-cartesian-grid-horizontal line, + .dark-mode-body .recharts-cartesian-grid-vertical line { + stroke: #364156 !important; + } + .dark-mode-body .recharts-text { + fill: #e0e0e0 !important; + } + .dark-mode-body .expense-bar-chart-container { + background-color: #1e2736 !important; + color: #e0e0e0 !important; + } + `; + document.head.appendChild(styleElement); + } + + return () => { + // Cleanup + document.body.classList.remove('dark-mode-body'); + }; + }, [darkMode]); + return ( -
-
-

Planned vs Actual Cost

- {errorMessage && ( -
- {errorMessage} -
- )} +
+
+

Planned vs Actual Cost

+ {errorMessage &&
{errorMessage}
}
-
-