diff --git a/src/components/BMDashboard/UtilizationChart/ExportReportButton.jsx b/src/components/BMDashboard/UtilizationChart/ExportReportButton.jsx new file mode 100644 index 0000000000..8d767e528f --- /dev/null +++ b/src/components/BMDashboard/UtilizationChart/ExportReportButton.jsx @@ -0,0 +1,71 @@ +import { useState } from 'react'; +import axios from 'axios'; +import { toast } from 'react-toastify'; +import { ENDPOINTS } from '../../../utils/URL'; +import { EXPORT_FORMATS } from './constants'; +import styles from './UtilizationChart.module.css'; + +function ExportReportButton({ tool, project, startDate, endDate }) { + const [exportingFormat, setExportingFormat] = useState(null); + + const handleExport = async format => { + setExportingFormat(format); + try { + const params = { + format, + tool, + project, + ...(startDate && { startDate: startDate.toISOString() }), + ...(endDate && { endDate: endDate.toISOString() }), + }; + const response = await axios.get(ENDPOINTS.BM_TOOL_UTILIZATION_EXPORT, { + params, + headers: { Authorization: localStorage.getItem('token') }, + responseType: 'blob', + }); + + const contentDisposition = response.headers['content-disposition'] || ''; + const filenameMatch = contentDisposition.match(/filename="?([^"]+)"?/); + const filename = filenameMatch ? filenameMatch[1] : `tool-utilization-report.${format}`; + + const url = window.URL.createObjectURL(response.data); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + link.remove(); + window.URL.revokeObjectURL(url); + } catch { + toast.error(`Failed to export ${format.toUpperCase()} report.`); + } finally { + setExportingFormat(null); + } + }; + + return ( +
+ Export Report: + + +
+ ); +} + +export default ExportReportButton; diff --git a/src/components/BMDashboard/UtilizationChart/ForecastModeToggle.jsx b/src/components/BMDashboard/UtilizationChart/ForecastModeToggle.jsx new file mode 100644 index 0000000000..62fcf8f6ab --- /dev/null +++ b/src/components/BMDashboard/UtilizationChart/ForecastModeToggle.jsx @@ -0,0 +1,51 @@ +import { useRef } from 'react'; +import { FORECAST_MODE_LABELS } from './constants'; +import styles from './UtilizationChart.module.css'; + +const MODES = Object.entries(FORECAST_MODE_LABELS); + +function ForecastModeToggle({ value, onChange }) { + const buttonRefs = useRef([]); + + const handleKeyDown = (e, index) => { + const count = MODES.length; + let nextIndex = -1; + + if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { + e.preventDefault(); + nextIndex = (index + 1) % count; + } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { + e.preventDefault(); + nextIndex = (index - 1 + count) % count; + } + + if (nextIndex !== -1) { + onChange(MODES[nextIndex][0]); + buttonRefs.current[nextIndex]?.focus(); + } + }; + + return ( +
+ {MODES.map(([mode, label], index) => ( + + ))} +
+ ); +} + +export default ForecastModeToggle; diff --git a/src/components/BMDashboard/UtilizationChart/InsightsSummaryBar.jsx b/src/components/BMDashboard/UtilizationChart/InsightsSummaryBar.jsx new file mode 100644 index 0000000000..3703ee850d --- /dev/null +++ b/src/components/BMDashboard/UtilizationChart/InsightsSummaryBar.jsx @@ -0,0 +1,32 @@ +import styles from './UtilizationChart.module.css'; + +function InsightsSummaryBar({ summary }) { + if (!summary) return null; + + return ( +
+
+ {summary.totalToolTypes} + Tool Types +
+
+ {summary.averageUtilization}% + Avg Utilization +
+
+ {summary.normal} + Normal +
+
+ {summary.underUtilized} + Under-utilized +
+
+ {summary.overUtilized} + Over-utilized +
+
+ ); +} + +export default InsightsSummaryBar; diff --git a/src/components/BMDashboard/UtilizationChart/MaintenanceAlertPanel.jsx b/src/components/BMDashboard/UtilizationChart/MaintenanceAlertPanel.jsx new file mode 100644 index 0000000000..53f3a924f3 --- /dev/null +++ b/src/components/BMDashboard/UtilizationChart/MaintenanceAlertPanel.jsx @@ -0,0 +1,42 @@ +import { URGENCY_STYLES } from './constants'; +import styles from './UtilizationChart.module.css'; + +function MaintenanceAlertPanel({ alerts }) { + const sortedAlerts = [...alerts].sort((a, b) => + a.urgency === 'high' && b.urgency !== 'high' ? -1 : 1, + ); + + return ( +
+

+ Maintenance Alerts +

+ {sortedAlerts.length === 0 ? ( +

No maintenance alerts.

+ ) : ( + + )} +
+ ); +} + +export default MaintenanceAlertPanel; diff --git a/src/components/BMDashboard/UtilizationChart/RecommendationPanel.jsx b/src/components/BMDashboard/UtilizationChart/RecommendationPanel.jsx new file mode 100644 index 0000000000..64d2bd3f73 --- /dev/null +++ b/src/components/BMDashboard/UtilizationChart/RecommendationPanel.jsx @@ -0,0 +1,37 @@ +import { TRAFFIC_LIGHT_COLORS } from './constants'; +import styles from './UtilizationChart.module.css'; + +function RecommendationPanel({ recommendations }) { + return ( +
+

+ Utilization Recommendations +

+ {recommendations.length === 0 ? ( +

No recommendations available.

+ ) : ( + + )} +
+ ); +} + +export default RecommendationPanel; diff --git a/src/components/BMDashboard/UtilizationChart/ResourceBalancingPanel.jsx b/src/components/BMDashboard/UtilizationChart/ResourceBalancingPanel.jsx new file mode 100644 index 0000000000..8183d78e84 --- /dev/null +++ b/src/components/BMDashboard/UtilizationChart/ResourceBalancingPanel.jsx @@ -0,0 +1,35 @@ +import styles from './UtilizationChart.module.css'; + +function ResourceBalancingPanel({ suggestions }) { + return ( +
+

+ Resource Balancing +

+ {suggestions.length === 0 ? ( +

Resources are balanced. No action needed.

+ ) : ( + + )} +
+ ); +} + +export default ResourceBalancingPanel; diff --git a/src/components/BMDashboard/UtilizationChart/UtilizationChart.jsx b/src/components/BMDashboard/UtilizationChart/UtilizationChart.jsx index 6b7332fe96..faa8ede5b2 100644 --- a/src/components/BMDashboard/UtilizationChart/UtilizationChart.jsx +++ b/src/components/BMDashboard/UtilizationChart/UtilizationChart.jsx @@ -1,200 +1,356 @@ -/* eslint-disable */ -/* prettier-ignore */ -import { useState, useEffect } from 'react'; -import { Chart as ChartJS, BarElement, CategoryScale, LinearScale, Tooltip, Title } from 'chart.js'; +import { useState, useEffect, useMemo, useCallback } from 'react'; +import { + Chart as ChartJS, + BarElement, + CategoryScale, + LinearScale, + Tooltip, + Title, + Legend, +} from 'chart.js'; import { Bar } from 'react-chartjs-2'; import DatePicker from 'react-datepicker'; import 'react-datepicker/dist/react-datepicker.css'; import axios from 'axios'; -import styles from './UtilizationChart.module.css'; import { useSelector } from 'react-redux'; -import { ENDPOINTS } from '../../../utils/URL'; +import { toast } from 'react-toastify'; import ChartDataLabels from 'chartjs-plugin-datalabels'; +import { ENDPOINTS } from '../../../utils/URL'; +import { FORECAST_MODES, TRAFFIC_LIGHT_COLORS } from './constants'; +import { useUtilizationData } from './hooks/useUtilizationData'; +import { useUtilizationInsights } from './hooks/useUtilizationInsights'; +import ForecastModeToggle from './ForecastModeToggle'; +import InsightsSummaryBar from './InsightsSummaryBar'; +import RecommendationPanel from './RecommendationPanel'; +import MaintenanceAlertPanel from './MaintenanceAlertPanel'; +import ResourceBalancingPanel from './ResourceBalancingPanel'; +import ExportReportButton from './ExportReportButton'; +import styles from './UtilizationChart.module.css'; -ChartJS.register(BarElement, CategoryScale, LinearScale, Tooltip, Title, ChartDataLabels); +ChartJS.register(BarElement, CategoryScale, LinearScale, Tooltip, Title, Legend, ChartDataLabels); + +const getBarColor = trafficLight => TRAFFIC_LIGHT_COLORS[trafficLight] || '#94a3b8'; function UtilizationChart() { - const [toolsData, setToolsData] = useState([]); + const [forecastMode, setForecastMode] = useState(FORECAST_MODES.HISTORICAL); const [startDate, setStartDate] = useState(null); const [endDate, setEndDate] = useState(null); const [toolFilter, setToolFilter] = useState('ALL'); const [projectFilter, setProjectFilter] = useState('ALL'); - const [error, setError] = useState(null); const [toolTypes, setToolTypes] = useState([]); const [projects, setProjects] = useState([]); const darkMode = useSelector(state => state.theme.darkMode); - const fetchChartData = async () => { - try { - const response = await axios.get(`${process.env.REACT_APP_APIENDPOINT}/tools/utilization`, { - params: { - startDate, - endDate, - tool: toolFilter, - project: projectFilter, - }, - headers: { - Authorization: localStorage.getItem('token'), - }, - }); - setToolsData(response.data); - } catch (err) { - setError('Failed to load utilization data.'); - } - }; + const { toolsData, loading: chartLoading, error: chartError, fetchData } = useUtilizationData(); + const { + insights, + loading: insightsLoading, + error: insightsError, + fetchInsights, + } = useUtilizationInsights(); - const fetchFilterData = async () => { + const fetchFilterData = useCallback(async () => { try { const [toolTypesResponse, projectsResponse] = await Promise.all([ axios.get(ENDPOINTS.BM_TOOL_TYPES, { - headers: { - Authorization: localStorage.getItem('token'), - }, + headers: { Authorization: localStorage.getItem('token') }, }), - axios.get(ENDPOINTS.BM_PROJECTS + 'Names', { - headers: { - Authorization: localStorage.getItem('token'), - }, + axios.get(`${ENDPOINTS.BM_PROJECTS}Names`, { + headers: { Authorization: localStorage.getItem('token') }, }), ]); setToolTypes(toolTypesResponse.data); setProjects(projectsResponse.data); - } catch (err) { - setError('Failed to load filter options. Please try refreshing the page.'); + } catch { + toast.error('Failed to load filter options. Please try refreshing the page.'); } - }; + }, []); useEffect(() => { fetchFilterData(); - fetchChartData(); - }, []); + fetchData({ tool: 'ALL', project: 'ALL', mode: FORECAST_MODES.HISTORICAL }); + fetchInsights({ tool: 'ALL', project: 'ALL' }); + }, [fetchFilterData, fetchData, fetchInsights]); + + const buildParams = useCallback( + () => ({ + tool: toolFilter, + project: projectFilter, + ...(startDate && { startDate: startDate.toISOString() }), + ...(endDate && { endDate: endDate.toISOString() }), + }), + [toolFilter, projectFilter, startDate, endDate], + ); const handleApplyClick = () => { - fetchChartData(); + const params = buildParams(); + fetchData({ ...params, mode: forecastMode }); + fetchInsights(params); }; - const chartData = { - labels: toolsData.map(tool => tool.name), - datasets: [ - { - label: 'Utilization (%)', - data: toolsData.map(tool => tool.utilizationRate), - backgroundColor: darkMode ? '#007bff' : '#a0e7e5', - borderRadius: 6, - }, - ], - }; + const handleForecastModeChange = useCallback( + newMode => { + setForecastMode(newMode); + const params = buildParams(); + fetchData({ ...params, mode: newMode }); + }, + [buildParams, fetchData], + ); - const options = { - indexAxis: 'y', - responsive: true, - plugins: { - legend: { - labels: { color: darkMode ? '#ffffff' : '#333' }, - }, - datalabels: { - color: darkMode ? '#ffffff' : '#333', - anchor: 'end', - align: 'end', - font: { - size: 12, - }, - formatter: (_, context) => { - const tool = toolsData[context.dataIndex]; - return `${tool.downtime} hrs`; + const warningMessage = useMemo(() => toolsData.find(row => row.warning)?.warning || null, [ + toolsData, + ]); + + const chartAnnouncement = useMemo(() => { + if (chartLoading) return 'Loading utilization data.'; + if (chartError) return `Error: ${chartError}`; + if (toolsData.length === 0) return 'No utilization data available.'; + return `Chart updated. Showing ${toolsData.length} tool types.`; + }, [chartLoading, chartError, toolsData]); + + const chartData = useMemo( + () => ({ + labels: toolsData.map(tool => tool.name), + datasets: [ + { + label: 'Utilization (%)', + data: toolsData.map(tool => tool.utilizationRate), + backgroundColor: toolsData.map(tool => getBarColor(tool.classification?.trafficLight)), + borderRadius: 6, }, + ...(forecastMode !== FORECAST_MODES.HISTORICAL + ? [ + { + label: 'Predicted Utilization (%)', + data: toolsData.map(tool => tool.forecast?.predictedRate ?? null), + backgroundColor: toolsData.map(tool => + tool.forecast?.predictedClassification + ? `${ + TRAFFIC_LIGHT_COLORS[tool.forecast.predictedClassification.trafficLight] + }80` + : '#94a3b880', + ), + borderRadius: 6, + borderWidth: 2, + borderColor: toolsData.map(tool => + tool.forecast?.predictedClassification + ? TRAFFIC_LIGHT_COLORS[tool.forecast.predictedClassification.trafficLight] + : '#94a3b8', + ), + }, + ] + : []), + ], + }), + [toolsData, forecastMode], + ); + + const trafficLightPlugin = useMemo( + () => ({ + id: 'trafficLightIndicators', + afterDraw: chart => { + const { ctx } = chart; + const yAxis = chart.scales.y; + toolsData.forEach((tool, index) => { + const yPos = yAxis.getPixelForTick(index); + const color = getBarColor(tool.classification?.trafficLight); + ctx.beginPath(); + ctx.arc(yAxis.left - 12, yPos, 5, 0, 2 * Math.PI); + ctx.fillStyle = color; + ctx.fill(); + }); }, - tooltip: { - callbacks: { - label: context => { + }), + [toolsData], + ); + + const options = useMemo( + () => ({ + indexAxis: 'y', + responsive: true, + layout: { padding: { left: 20 } }, + plugins: { + legend: { + labels: { color: darkMode ? '#ffffff' : '#333' }, + }, + datalabels: { + color: darkMode ? '#ffffff' : '#333', + anchor: 'end', + align: 'end', + font: { size: 12 }, + formatter: (value, context) => { + if (context.datasetIndex === 1) { + const tool = toolsData[context.dataIndex]; + return tool?.forecast ? `\u2192 ${tool.forecast.predictedRate}%` : ''; + } const tool = toolsData[context.dataIndex]; - return `Utilization: ${tool.utilizationRate}%, Downtime: ${tool.downtime} hrs`; + if (!tool) return ''; + const classLabel = tool.classification?.label || ''; + return classLabel + ? `${tool.downtime} hrs \u00B7 ${classLabel}` + : `${tool.downtime} hrs`; }, }, - footerColor: 'white', - }, - }, - scales: { - x: { - max: 100, - title: { - display: true, - text: 'Time (%)', - color: darkMode ? '#ffffff' : '#333', + tooltip: { + callbacks: { + label: context => { + const tool = toolsData[context.dataIndex]; + if (!tool) return ''; + if (context.datasetIndex === 0) { + return `Utilization: ${tool.utilizationRate}% (${tool.classification?.label || + 'N/A'})`; + } + if (context.datasetIndex === 1 && tool.forecast) { + return `Predicted: ${tool.forecast.predictedRate}% (${tool.forecast.confidence} confidence)`; + } + return ''; + }, + afterLabel: context => { + const tool = toolsData[context.dataIndex]; + if (context.datasetIndex === 0 && tool) { + return `Downtime: ${tool.downtime} hrs`; + } + return ''; + }, + }, }, - ticks: { color: darkMode ? '#ffffff' : '#333' }, - grid: { color: darkMode ? '#c7c7c7ff' : '#bebebeff' }, }, - y: { - ticks: { - autoSkip: false, - color: darkMode ? '#ffffff' : '#333', + scales: { + x: { + max: 100, + title: { + display: true, + text: 'Time (%)', + color: darkMode ? '#ffffff' : '#333', + }, + ticks: { color: darkMode ? '#ffffff' : '#333' }, + grid: { color: darkMode ? '#c7c7c7' : '#bebebe' }, + }, + y: { + ticks: { + autoSkip: false, + color: darkMode ? '#ffffff' : '#333', + }, + grid: { color: darkMode ? '#c7c7c7' : '#bebebe' }, }, - grid: { color: darkMode ? '#c7c7c7ff' : '#bebebeff' }, }, - }, - }; + }), + [darkMode, toolsData], + ); return (
-

Utilization Chart

- - {error ? ( -
{error}
- ) : ( - <> -
- - - - - setStartDate(date)} - placeholderText="Start Date" - maxDate={endDate || '' || new Date()} - className={styles.datepickerWrapper} - /> - - setEndDate(date)} - placeholderText="End Date" - minDate={startDate || ''} - maxDate={new Date()} - className={styles.datepickerWrapper} - /> - - -
- - - +

Tool Utilization Analysis

+ +
+ + + + + setStartDate(date)} + placeholderText="Start Date" + maxDate={endDate || new Date()} + className={styles.datepickerWrapper} + aria-label="Start date" + /> + + setEndDate(date)} + placeholderText="End Date" + minDate={startDate || undefined} + maxDate={new Date()} + className={styles.datepickerWrapper} + aria-label="End date" + /> + + +
+ + + + {forecastMode === FORECAST_MODES.FORECAST_FULL && warningMessage && ( +
+ {warningMessage} +
+ )} + + {!insightsLoading && insights.summary && } + + {chartLoading && ( +
+
+ Loading utilization data... +
+ )} + + {!chartLoading && chartError && ( +
+ {chartError} +
)} + + {!chartLoading && !chartError && toolsData.length === 0 && ( +
+ No utilization data available for the selected filters. +
+ )} + + {!chartLoading && !chartError && toolsData.length > 0 && ( +
+ +
+ )} + +
+ {chartAnnouncement} +
+ + {!insightsLoading && !insightsError && ( +
+ + + +
+ )} + +
); } diff --git a/src/components/BMDashboard/UtilizationChart/UtilizationChart.module.css b/src/components/BMDashboard/UtilizationChart/UtilizationChart.module.css index 43ca101cb8..dae7b95cfc 100644 --- a/src/components/BMDashboard/UtilizationChart/UtilizationChart.module.css +++ b/src/components/BMDashboard/UtilizationChart/UtilizationChart.module.css @@ -1,12 +1,19 @@ +/* ============ BASE CONTAINER & VARIABLES ============ */ .utilizationChartContainer { padding: 2rem; - max-width: 1000px; + max-width: 1200px; margin: 0 auto; background-color: var(--bg-color, #fff); color: var(--text-color, #333); border-radius: 10px; box-shadow: var(--shadow, 0 2px 10px rgb(0 0 0 / 10%)); min-height: 100vh; + + --card-bg: #f9fafb; + --text-secondary: #6b7280; + --hover-bg: #f3f4f6; + --panel-bg: #fff; + --panel-border: #e5e7eb; } .chartTitle { @@ -15,6 +22,7 @@ color: var(--title-color, #333); } +/* ============ FILTERS ============ */ .filters { display: flex; flex-wrap: wrap; @@ -44,7 +52,6 @@ border-color: var(--focus-border-color, #3b82f6); } -/* Force light dropdown in light mode (override any global dark select/option styles) */ .utilizationChartContainer:not(.darkMode) .select { background-color: #fff !important; color: #000 !important; @@ -60,7 +67,6 @@ color: #999; } -/* Force light calendar when app is in light mode (datepicker renders in portal, so scope by body) */ :global(body:not(.dark-mode)) :global(.react-datepicker) { background-color: #fff; color: #333; @@ -102,11 +108,379 @@ background-color: var(--button-hover, #333); } +/* ============ ERROR / EMPTY / LOADING STATES ============ */ .utilizationChartError { color: var(--error-color, red); text-align: center; } +.emptyMessage { + text-align: center; + color: var(--text-secondary, #6b7280); + padding: 2rem; + font-style: italic; +} + +.loadingContainer { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 3rem 0; +} + +.spinner { + width: 36px; + height: 36px; + border: 4px solid var(--panel-border, #e5e7eb); + border-top-color: var(--button-bg, #000); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.srOnly { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border: 0; +} + +/* ============ FORECAST MODE TOGGLE ============ */ +.forecastToggle { + display: flex; + border: 1px solid var(--border-color, #ccc); + border-radius: 6px; + overflow: hidden; + margin-bottom: 1.5rem; + justify-content: center; +} + +.toggleButton { + padding: 0.5rem 1rem; + font-size: 0.875rem; + border: none; + background-color: var(--input-bg, #fff); + color: var(--text-color, #333); + cursor: pointer; + transition: background-color 0.2s ease, color 0.2s ease; + white-space: nowrap; +} + +.toggleButton:not(:last-child) { + border-right: 1px solid var(--border-color, #ccc); +} + +.toggleButtonActive { + background-color: var(--button-bg, #000); + color: var(--button-text, #fff); + font-weight: 600; +} + +.toggleButton:focus-visible { + outline: 2px solid var(--focus-border-color, #3b82f6); + outline-offset: -2px; +} + +.toggleButton:hover:not(.toggleButtonActive) { + background-color: var(--hover-bg, #f3f4f6); +} + +/* ============ WARNING BANNER ============ */ +.warningBanner { + background-color: #fef3c7; + border: 1px solid #fde68a; + border-radius: 6px; + padding: 0.75rem 1rem; + margin-bottom: 1rem; + color: #92400e; + font-size: 0.875rem; +} + +/* ============ SUMMARY BAR ============ */ +.summaryBar { + display: flex; + gap: 1rem; + margin-bottom: 1.5rem; + flex-wrap: wrap; +} + +.summaryCard { + flex: 1; + min-width: 120px; + padding: 1rem; + border-radius: 8px; + background-color: var(--card-bg, #f9fafb); + border: 1px solid var(--panel-border, #e5e7eb); + text-align: center; +} + +.summaryValue { + display: block; + font-size: 1.5rem; + font-weight: 700; + color: var(--text-color, #111); +} + +.summaryLabel { + display: block; + font-size: 0.75rem; + color: var(--text-secondary, #6b7280); + margin-top: 0.25rem; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.summaryCardGreen { + border-bottom: 3px solid #22c55e; +} + +.summaryCardYellow { + border-bottom: 3px solid #eab308; +} + +.summaryCardRed { + border-bottom: 3px solid #ef4444; +} + +/* ============ INSIGHTS PANELS GRID ============ */ +.insightsPanelsGrid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1rem; + margin-top: 1.5rem; +} + +/* ============ SHARED PANEL STYLES ============ */ +.insightsPanel { + background-color: var(--panel-bg, #fff); + border: 1px solid var(--panel-border, #e5e7eb); + border-radius: 8px; + padding: 1rem; +} + +.panelTitle { + font-size: 1rem; + font-weight: 600; + margin-bottom: 0.75rem; + color: var(--text-color, #333); +} + +.emptyPanel { + color: var(--text-secondary, #6b7280); + font-size: 0.875rem; + font-style: italic; +} + +/* ============ RECOMMENDATION PANEL ============ */ +.recommendationList { + list-style: none; + padding: 0; + margin: 0; +} + +.recommendationItem { + display: flex; + gap: 0.75rem; + align-items: flex-start; + padding: 0.75rem 0; + border-bottom: 1px solid var(--panel-border, #e5e7eb); +} + +.recommendationItem:last-child { + border-bottom: none; +} + +.trafficDot { + display: inline-block; + width: 10px; + height: 10px; + border-radius: 50%; + flex-shrink: 0; + margin-top: 0.35rem; +} + +.toolName { + font-size: 0.875rem; + color: var(--text-color, #333); +} + +.classificationBadge { + display: inline-block; + padding: 2px 8px; + border-radius: 12px; + font-size: 0.75rem; + font-weight: 600; + margin-left: 0.5rem; +} + +.classificationBadge[data-level='green'] { + background-color: #dcfce7; + color: #166534; +} + +.classificationBadge[data-level='yellow'] { + background-color: #fef9c3; + color: #854d0e; +} + +.classificationBadge[data-level='red'] { + background-color: #fee2e2; + color: #991b1b; +} + +.actionText { + font-size: 0.8125rem; + color: var(--text-secondary, #6b7280); + margin: 0.25rem 0; +} + +.rateText { + font-size: 0.75rem; + color: var(--text-secondary, #6b7280); +} + +/* ============ MAINTENANCE ALERTS ============ */ +.alertList { + list-style: none; + padding: 0; + margin: 0; +} + +.alertItem { + padding: 0.75rem; + border-radius: 6px; + border-left: 4px solid; + margin-bottom: 0.5rem; +} + +.alertItem[data-urgency='high'] { + border-left-color: #ef4444; + background-color: #fef2f2; +} + +.alertItem[data-urgency='medium'] { + border-left-color: #f59e0b; + background-color: #fffbeb; +} + +.alertHeader { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.35rem; +} + +.urgencyBadge { + display: inline-block; + padding: 2px 8px; + border-radius: 4px; + font-size: 0.6875rem; + font-weight: 700; + color: #fff; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.alertMessage { + font-size: 0.8125rem; + color: var(--text-secondary, #6b7280); + margin: 0; +} + +/* ============ RESOURCE BALANCING ============ */ +.balancingList { + list-style: none; + padding: 0; + margin: 0; +} + +.balancingItem { + padding: 0.75rem 0; + border-bottom: 1px solid var(--panel-border, #e5e7eb); +} + +.balancingItem:last-child { + border-bottom: none; +} + +.suggestionText { + font-size: 0.875rem; + font-weight: 600; + color: var(--text-color, #333); + margin: 0 0 0.25rem; +} + +.balancingDetail { + display: flex; + gap: 0.75rem; + font-size: 0.8125rem; + color: var(--text-color, #333); + margin-bottom: 0.25rem; +} + +.fromTool, +.toTool { + font-size: 0.8125rem; +} + +.rationaleText { + font-size: 0.75rem; + color: var(--text-secondary, #6b7280); + margin: 0; + font-style: italic; +} + +/* ============ EXPORT SECTION ============ */ +.exportSection { + display: flex; + align-items: center; + gap: 0.5rem; + margin-top: 1.5rem; + justify-content: flex-end; +} + +.exportLabel { + font-size: 0.875rem; + color: var(--text-secondary, #6b7280); +} + +.exportButton { + padding: 0.5rem 1rem; + font-size: 0.875rem; + border-radius: 5px; + border: 1px solid var(--border-color, #ccc); + background-color: var(--input-bg, #fff); + color: var(--text-color, #333); + cursor: pointer; + transition: background-color 0.2s ease; +} + +.exportButton:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.exportButton:focus-visible { + outline: 2px solid var(--focus-border-color, #3b82f6); + outline-offset: -2px; +} + +.exportButton:hover:not(:disabled) { + background-color: var(--button-bg, #000); + color: var(--button-text, #fff); +} + /* ============ DARK MODE ============ */ .utilizationChartContainer.darkMode { --bg-color: #1b2a41; @@ -120,13 +494,17 @@ --button-hover: #f5b13a; --shadow: 0 2px 10px rgb(255 255 255 / 10%); --error-color: #ff6b6b; + --card-bg: #2b3e59; + --text-secondary: #94a3b8; + --hover-bg: #334155; + --panel-bg: #253342; + --panel-border: #4a5a77; } .utilizationChartContainer.darkMode .datepickerWrapper::placeholder { color: #fff; } -/* React-DatePicker Dark Mode Dropdown Styling */ .utilizationChartContainer.darkMode :global(.react-datepicker) { background-color: #333; color: #e0e0e0; @@ -154,3 +532,80 @@ background-color: #e8a71c; color: #000; } + +.darkMode .warningBanner { + background-color: #713f12; + border-color: #92400e; + color: #fef08a; +} + +.darkMode .classificationBadge[data-level='green'] { + background-color: #14532d; + color: #bbf7d0; +} + +.darkMode .classificationBadge[data-level='yellow'] { + background-color: #713f12; + color: #fef08a; +} + +.darkMode .classificationBadge[data-level='red'] { + background-color: #7f1d1d; + color: #fecaca; +} + +.darkMode .alertItem[data-urgency='high'] { + border-left-color: #ef4444; + background-color: #451a1a; +} + +.darkMode .alertItem[data-urgency='medium'] { + border-left-color: #f59e0b; + background-color: #3b2f10; +} + +/* ============ RESPONSIVE ============ */ +@media (width <= 1024px) { + .insightsPanelsGrid { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (width <= 768px) { + .insightsPanelsGrid { + grid-template-columns: 1fr; + } + + .summaryBar { + gap: 0.5rem; + } + + .summaryCard { + min-width: 80px; + } + + .filters { + flex-direction: column; + align-items: stretch; + } +} + +@media (width <= 576px) { + .utilizationChartContainer { + padding: 1rem; + } + + .forecastToggle { + flex-direction: column; + } + + .toggleButton:not(:last-child) { + border-right: none; + border-bottom: 1px solid var(--border-color, #ccc); + } + + .exportSection { + flex-direction: column; + align-items: stretch; + } +} diff --git a/src/components/BMDashboard/UtilizationChart/constants.js b/src/components/BMDashboard/UtilizationChart/constants.js new file mode 100644 index 0000000000..961a7f3bbe --- /dev/null +++ b/src/components/BMDashboard/UtilizationChart/constants.js @@ -0,0 +1,45 @@ +export const FORECAST_MODES = { + HISTORICAL: 'historical', + FORECAST_30: 'forecast30', + FORECAST_FULL: 'forecastFull', +}; + +export const FORECAST_MODE_LABELS = { + [FORECAST_MODES.HISTORICAL]: 'Historical View', + [FORECAST_MODES.FORECAST_30]: 'Next 30 Days', + [FORECAST_MODES.FORECAST_FULL]: 'Full Project Schedule', +}; + +export const TRAFFIC_LIGHT_COLORS = { + green: '#22c55e', + yellow: '#eab308', + red: '#ef4444', +}; + +export const TRAFFIC_LIGHT_LABELS = { + green: 'Normal', + yellow: 'Under-utilized', + red: 'Over-utilized', +}; + +export const CLASSIFICATION_LABELS = { + UNDER: 'Under-utilized', + NORMAL: 'Normal', + OVER: 'Over-utilized', +}; + +export const URGENCY_STYLES = { + high: { color: '#ef4444', label: 'HIGH' }, + medium: { color: '#f59e0b', label: 'MEDIUM' }, +}; + +export const CONFIDENCE_STYLES = { + low: { color: '#ef4444', label: 'Low confidence' }, + medium: { color: '#f59e0b', label: 'Medium confidence' }, + high: { color: '#22c55e', label: 'High confidence' }, +}; + +export const EXPORT_FORMATS = { + PDF: 'pdf', + CSV: 'csv', +}; diff --git a/src/components/BMDashboard/UtilizationChart/hooks/useUtilizationData.js b/src/components/BMDashboard/UtilizationChart/hooks/useUtilizationData.js new file mode 100644 index 0000000000..75c572a9bf --- /dev/null +++ b/src/components/BMDashboard/UtilizationChart/hooks/useUtilizationData.js @@ -0,0 +1,48 @@ +import { useState, useCallback, useRef } from 'react'; +import axios, { isCancel } from 'axios'; +import { ENDPOINTS } from '../../../../utils/URL'; + +export function useUtilizationData() { + const [toolsData, setToolsData] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const abortControllerRef = useRef(null); + + const fetchData = useCallback(async (params = {}) => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + const controller = new AbortController(); + abortControllerRef.current = controller; + + setLoading(true); + setError(null); + try { + const response = await axios.get(ENDPOINTS.BM_TOOL_UTILIZATION, { + params: { + tool: params.tool, + project: params.project, + startDate: params.startDate, + endDate: params.endDate, + mode: params.mode, + }, + headers: { Authorization: localStorage.getItem('token') }, + signal: controller.signal, + }); + setToolsData(response.data); + } catch (err) { + if (isCancel(err)) return; + const message = + err.response?.status === 400 && err.response?.data?.error + ? err.response.data.error + : 'Failed to load utilization data.'; + setError(message); + } finally { + if (!controller.signal.aborted) { + setLoading(false); + } + } + }, []); + + return { toolsData, loading, error, fetchData }; +} diff --git a/src/components/BMDashboard/UtilizationChart/hooks/useUtilizationInsights.js b/src/components/BMDashboard/UtilizationChart/hooks/useUtilizationInsights.js new file mode 100644 index 0000000000..c7bad7a6b4 --- /dev/null +++ b/src/components/BMDashboard/UtilizationChart/hooks/useUtilizationInsights.js @@ -0,0 +1,54 @@ +import { useState, useCallback, useRef } from 'react'; +import axios, { isCancel } from 'axios'; +import { ENDPOINTS } from '../../../../utils/URL'; + +const INITIAL_INSIGHTS = { + recommendations: [], + maintenanceAlerts: [], + resourceBalancing: [], + summary: null, +}; + +export function useUtilizationInsights() { + const [insights, setInsights] = useState(INITIAL_INSIGHTS); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const abortControllerRef = useRef(null); + + const fetchInsights = useCallback(async (params = {}) => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + const controller = new AbortController(); + abortControllerRef.current = controller; + + setLoading(true); + setError(null); + try { + const response = await axios.get(ENDPOINTS.BM_TOOL_UTILIZATION_INSIGHTS, { + params: { + tool: params.tool, + project: params.project, + startDate: params.startDate, + endDate: params.endDate, + }, + headers: { Authorization: localStorage.getItem('token') }, + signal: controller.signal, + }); + setInsights(response.data); + } catch (err) { + if (isCancel(err)) return; + const message = + err.response?.status === 400 && err.response?.data?.error + ? err.response.data.error + : 'Failed to load insights data.'; + setError(message); + } finally { + if (!controller.signal.aborted) { + setLoading(false); + } + } + }, []); + + return { insights, loading, error, fetchInsights }; +} diff --git a/src/utils/URL.js b/src/utils/URL.js index 6ab4ebf4e7..89a68907d1 100644 --- a/src/utils/URL.js +++ b/src/utils/URL.js @@ -347,6 +347,9 @@ export const ENDPOINTS = { BM_TOOL_AVAILABILITY: (toolId = '', projectId = '') => `${APIEndpoint}/tools/availability?toolId=${toolId}&projectId=${projectId}`, BM_LOG_TOOLS: `${APIEndpoint}/bm/tools/log`, + BM_TOOL_UTILIZATION: `${APIEndpoint}/tools/utilization`, + BM_TOOL_UTILIZATION_INSIGHTS: `${APIEndpoint}/tools/utilization/insights`, + BM_TOOL_UTILIZATION_EXPORT: `${APIEndpoint}/tools/utilization/export`, BM_EQUIPMENT_BY_ID: singleEquipmentId => `${APIEndpoint}/bm/equipment/${singleEquipmentId}`, BM_EQUIPMENT_STATUS_UPDATE: (equipmentId) => `${APIEndpoint}/bm/equipment/${equipmentId}/status`, BM_EQUIPMENTS: `${APIEndpoint}/bm/equipments`,