From fe578cfb1ce7b4a8e9ba7e7b2ca8bd0a6e003a30 Mon Sep 17 00:00:00 2001 From: Linh Huynh Date: Mon, 19 Jan 2026 17:00:28 -0800 Subject: [PATCH 1/8] feat: add materialInsights utility with calculation logic - Implement usage percentage calculation (Used / Bought) - Implement stock ratio calculation (Available / Bought) - Add stock health status determination (Critical/Low/Healthy/No-Data) - Add formatting utilities for floating point precision handling - Create tooltip generation functions for visual indicators - Include summary metrics calculation from material lists - All calculations include safe guards for zero/null values --- src/utils/materialInsights.js | 257 ++++++++++++++++++++++++++++++++++ 1 file changed, 257 insertions(+) create mode 100644 src/utils/materialInsights.js diff --git a/src/utils/materialInsights.js b/src/utils/materialInsights.js new file mode 100644 index 0000000000..410826c10a --- /dev/null +++ b/src/utils/materialInsights.js @@ -0,0 +1,257 @@ +/** + * Material Insights Utility + * Provides calculations and formatting for material usage analytics + * Single source of truth for all calculation logic + */ + +/** + * Format a number to 1-2 decimal places safely + * Handles floating point precision issues like 180.60000000000002 + * @param {number} value - The value to format + * @param {number} decimals - Number of decimal places (default: 2) + * @returns {number|null} Formatted number or null if value is not a number + */ +export const formatNumber = (value, decimals = 2) => { + if (typeof value !== 'number' || Number.isNaN(value)) { + return null; + } + return Math.round(value * Math.pow(10, decimals)) / Math.pow(10, decimals); +}; + +/** + * Calculate usage percentage (Used / Bought) + * @param {number} used - Amount used + * @param {number} bought - Amount bought + * @returns {number|null} Usage percentage or null if bought is 0 + */ +export const calculateUsagePercentage = (used, bought) => { + if (!bought || bought <= 0) { + return null; + } + const percentage = (used / bought) * 100; + return formatNumber(percentage, 2); +}; + +/** + * Get clamped usage percentage for UI display + * Ensures value doesn't exceed 100% for progress bar display + * @param {number} usagePercentage - The usage percentage to clamp + * @returns {number} Clamped percentage (0-100) + */ +export const getClampedUsagePercentage = (usagePercentage) => { + if (usagePercentage === null || usagePercentage === undefined) { + return 0; + } + return Math.min(100, Math.max(0, usagePercentage)); +}; + +/** + * Calculate stock ratio (Available / Bought) + * @param {number} available - Amount available + * @param {number} bought - Amount bought + * @returns {number|null} Stock ratio or null if bought is 0 + */ +export const calculateStockRatio = (available, bought) => { + if (!bought || bought <= 0) { + return null; + } + const ratio = available / bought; + return formatNumber(ratio, 2); +}; + +/** + * Get stock health status based on stock ratio + * @param {number} stockRatio - The stock ratio to evaluate + * @returns {string} Health status: 'healthy', 'low', 'critical', or 'no-data' + */ +export const getStockHealthStatus = (stockRatio) => { + if (stockRatio === null || stockRatio === undefined) { + return 'no-data'; + } + if (stockRatio <= 0.2) { + return 'critical'; + } + if (stockRatio <= 0.4) { + return 'low'; + } + return 'healthy'; +}; + +/** + * Get stock health color for UI display + * @param {string} status - Stock health status + * @returns {string} Color code: 'green', 'yellow', 'red', or 'gray' + */ +export const getStockHealthColor = (status) => { + switch (status) { + case 'healthy': + return 'green'; + case 'low': + return 'yellow'; + case 'critical': + return 'red'; + default: + return 'gray'; + } +}; + +/** + * Get stock health icon for UI display + * Returns a simple icon representation + * @param {string} status - Stock health status + * @returns {string} Icon symbol or emoji + */ +export const getStockHealthIcon = (status) => { + switch (status) { + case 'healthy': + return '✓'; // Checkmark + case 'low': + return '⚠'; // Warning + case 'critical': + return '✕'; // X mark + default: + return '○'; // Circle for no data + } +}; + +/** + * Get display label for stock health status + * @param {string} status - Stock health status + * @returns {string} Display label + */ +export const getStockHealthLabel = (status) => { + switch (status) { + case 'healthy': + return 'Healthy'; + case 'low': + return 'Low'; + case 'critical': + return 'Critical'; + default: + return 'No Data'; + } +}; + +/** + * Calculate all material insights for a single material + * @param {object} material - Material object with stockBought, stockUsed, stockAvailable, etc. + * @returns {object} Insights object containing all calculated values + */ +export const calculateMaterialInsights = (material) => { + const bought = material?.stockBought || 0; + const used = material?.stockUsed || 0; + const available = material?.stockAvailable || 0; + const wasted = material?.stockWasted || 0; + const hold = material?.stockHold || 0; + + const usagePct = calculateUsagePercentage(used, bought); + const usagePctClamped = getClampedUsagePercentage(usagePct); + const stockRatio = calculateStockRatio(available, bought); + const stockHealth = getStockHealthStatus(stockRatio); + const stockHealthColor = getStockHealthColor(stockHealth); + const stockHealthLabel = getStockHealthLabel(stockHealth); + + return { + bought, + used, + available, + wasted, + hold, + usagePct, + usagePctClamped, + stockRatio, + stockHealth, + stockHealthColor, + stockHealthLabel, + hasBoughtData: bought > 0, + }; +}; + +/** + * Calculate summary metrics from a list of materials + * @param {array} materials - Array of material objects + * @returns {object} Summary metrics + */ +export const calculateSummaryMetrics = (materials) => { + if (!materials || materials.length === 0) { + return { + totalMaterials: 0, + lowStockCount: 0, + lowStockPercentage: 0, + overUsageCount: 0, + overUsagePercentage: 0, + onHoldCount: 0, + usageThreshold: 80, + }; + } + + const total = materials.length; + let lowStockCount = 0; + let overUsageCount = 0; + let onHoldCount = 0; + + materials.forEach(material => { + const insights = calculateMaterialInsights(material); + + // Count low/critical stock + if (insights.stockHealth === 'low' || insights.stockHealth === 'critical') { + lowStockCount += 1; + } + + // Count over usage threshold (default 80%) + if (insights.usagePct !== null && insights.usagePct >= 80) { + overUsageCount += 1; + } + + // Count items on hold + if ((material?.stockHold || 0) > 0) { + onHoldCount += 1; + } + }); + + const lowStockPercentage = formatNumber((lowStockCount / total) * 100, 1); + const overUsagePercentage = formatNumber((overUsageCount / total) * 100, 1); + + return { + totalMaterials: total, + lowStockCount, + lowStockPercentage, + overUsageCount, + overUsagePercentage, + onHoldCount, + usageThreshold: 80, + }; +}; + +/** + * Generate tooltip text for stock health + * @param {object} material - Material object + * @returns {string} Tooltip text + */ +export const getStockHealthTooltip = (material) => { + const insights = calculateMaterialInsights(material); + + if (!insights.hasBoughtData) { + return 'No purchases recorded'; + } + + const ratio = insights.stockRatio !== null ? Math.round(insights.stockRatio * 100) : 'N/A'; + return `Available: ${insights.available} of ${insights.bought} units remaining\nStock ratio: ${ratio}%\nStatus: ${insights.stockHealthLabel}`; +}; + +/** + * Generate tooltip text for usage percentage + * @param {object} material - Material object + * @returns {string} Tooltip text + */ +export const getUsagePercentageTooltip = (material) => { + const bought = material?.stockBought || 0; + const used = material?.stockUsed || 0; + + if (!bought || bought <= 0) { + return 'No purchases recorded'; + } + + const usagePct = calculateUsagePercentage(used, bought); + return `Used: ${used} of ${bought} (${usagePct}%)`; +}; From f144dc85972dcc3487a8b6be03ddc8091fb8f9f4 Mon Sep 17 00:00:00 2001 From: Linh Huynh Date: Mon, 19 Jan 2026 18:18:26 -0800 Subject: [PATCH 2/8] feat: integrate material insights and visual indicators - MaterialSummaryPanel: displays 4 key metrics (total, low stock %, usage %, on hold) - ItemsTable: adds Stock Health Indicator and Usage % Bar columns - ItemListView: integrates summary panel above table - Components support dark mode, responsive design, and accessibility --- .../BMDashboard/ItemList/ItemListView.jsx | 27 +++-- .../BMDashboard/ItemList/ItemsTable.jsx | 39 ++++++-- .../MaterialList/MaterialSummaryPanel.jsx | 70 +++++++++++++ .../MaterialSummaryPanel.module.css | 99 +++++++++++++++++++ 4 files changed, 220 insertions(+), 15 deletions(-) create mode 100644 src/components/BMDashboard/MaterialList/MaterialSummaryPanel.jsx create mode 100644 src/components/BMDashboard/MaterialList/MaterialSummaryPanel.module.css diff --git a/src/components/BMDashboard/ItemList/ItemListView.jsx b/src/components/BMDashboard/ItemList/ItemListView.jsx index cf6451b90d..68200b47ba 100644 --- a/src/components/BMDashboard/ItemList/ItemListView.jsx +++ b/src/components/BMDashboard/ItemList/ItemListView.jsx @@ -7,8 +7,14 @@ import BMError from '../shared/BMError'; import SelectForm from './SelectForm'; import SelectItem from './SelectItem'; import ItemsTable from './ItemsTable'; +import MaterialSummaryPanel from '../MaterialList/MaterialSummaryPanel'; import styles from './ItemListView.module.css'; +/** + * ItemListView Component + * Main container for displaying filtered items with summary metrics and data table + * Handles filtering, time selection, and layout of material list page + */ export function ItemListView({ itemType, items, errors, UpdateItemModal, dynamicColumns }) { const [filteredItems, setFilteredItems] = useState(items); const [selectedProject, setSelectedProject] = useState('all'); @@ -105,14 +111,17 @@ export function ItemListView({ itemType, items, errors, UpdateItemModal, dynamic {filteredItems && ( - + <> + + + )} @@ -135,6 +144,8 @@ ItemListView.propTypes = { stockBought: PropTypes.number, stockUsed: PropTypes.number, stockWasted: PropTypes.number, + stockHold: PropTypes.number, + productId: PropTypes.string, }), ).isRequired, errors: PropTypes.shape({ diff --git a/src/components/BMDashboard/ItemList/ItemsTable.jsx b/src/components/BMDashboard/ItemList/ItemsTable.jsx index 259715d79c..d000406cef 100644 --- a/src/components/BMDashboard/ItemList/ItemsTable.jsx +++ b/src/components/BMDashboard/ItemList/ItemsTable.jsx @@ -5,8 +5,17 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faSortDown, faSort, faSortUp } from '@fortawesome/free-solid-svg-icons'; import RecordsModal from './RecordsModal'; import MaterialUsageChart from '../MaterialUsage/MaterialUsageChart'; +import StockHealthIndicator from '../MaterialList/StockHealthIndicator'; +import UsagePercentageBar from '../MaterialList/UsagePercentageBar'; import styles from './ItemListView.module.css'; +/** + * ItemsTable Component + * Renders data table with columns including new visual indicators: + * - Stock Health Indicator: color-coded stock health status + * - Usage % Bar: visual progress bar for material usage + * Supports sorting, filtering, and modal interactions + */ export default function ItemsTable({ selectedProject, selectedItem, @@ -141,9 +150,15 @@ export default function ItemsTable({ ) : ( Name )} - {dynamicColumns.map(({ label }) => ( - {label} - ))} + PID + Measurement + Bought + Used + Usage % + Available + Stock Health + Wasted + Hold Usage Record Updates Purchases @@ -157,9 +172,19 @@ export default function ItemsTable({ {el.project?.name} {el.itemType?.name} - {dynamicColumns.map(({ label, key }) => ( - {getNestedValue(el, key)} - ))} + {el.productId || 'N/A'} + {el.itemType?.unit || 'N/A'} + {el.stockBought} + {el.stockUsed} + + + + {el.stockAvailable} + + + + {el.stockWasted} + {el.stockHold} - - - + {children} {filteredItems && ( <> - + {isMaterialsView && ( + + )} )} @@ -159,10 +157,12 @@ ItemListView.propTypes = { key: PropTypes.string.isRequired, }), ).isRequired, + children: PropTypes.node, }; ItemListView.defaultProps = { errors: {}, + children: null, }; export default ItemListView; diff --git a/src/components/BMDashboard/ItemList/ItemsTable.jsx b/src/components/BMDashboard/ItemList/ItemsTable.jsx index d000406cef..0b250b683c 100644 --- a/src/components/BMDashboard/ItemList/ItemsTable.jsx +++ b/src/components/BMDashboard/ItemList/ItemsTable.jsx @@ -23,6 +23,7 @@ export default function ItemsTable({ UpdateItemModal, dynamicColumns, darkMode = false, + itemType, }) { const [sortedData, setData] = useState(filteredItems); const [modal, setModal] = useState(false); @@ -59,19 +60,16 @@ export default function ItemsTable({ const handleViewRecordsClick = (data, type) => { if (type === 'UsageRecord') { - // For UsageRecord, show the chart directly const projectId = data.project?._id || data.projectId; if (projectId) { setChartProjectId(projectId); setShowChartModal(true); } else { - // If no project ID, fall back to the regular modal setModal(true); setRecord(data); setRecordType(type); } } else { - // For other record types, show the regular modal setModal(true); setRecord(data); setRecordType(type); @@ -111,10 +109,16 @@ export default function ItemsTable({ setData(newSortedData); }; + const isMaterialsView = itemType === 'Materials'; + const getNestedValue = (obj, path) => { + if (!path) return null; + if (path === 'product id') return obj.productId; return path.split('.').reduce((acc, part) => (acc ? acc[part] : null), obj); }; + const emptyStateColSpan = 4 + dynamicColumns.length + (isMaterialsView ? 4 : 2); + return ( <> {/* Regular Records Modal for Update and Purchase records */} @@ -124,6 +128,7 @@ export default function ItemsTable({ record={record} setRecord={setRecord} recordType={recordType} + itemType={itemType} /> {/* Direct Chart Modal for Usage Records */} @@ -150,16 +155,12 @@ export default function ItemsTable({ ) : ( Name )} - PID - Measurement - Bought - Used - Usage % - Available - Stock Health - Wasted - Hold - Usage Record + {dynamicColumns.map(({ label, key }) => ( + {label} + ))} + {isMaterialsView && Usage %} + {isMaterialsView && Stock Health} + {isMaterialsView && Usage Record} Updates Purchases @@ -172,36 +173,31 @@ export default function ItemsTable({ {el.project?.name} {el.itemType?.name} - {el.productId || 'N/A'} - {el.itemType?.unit || 'N/A'} - {el.stockBought} - {el.stockUsed} - - - - {el.stockAvailable} - - - - {el.stockWasted} - {el.stockHold} - - - - + {dynamicColumns.map(({ label, key }) => ( + {getNestedValue(el, key) ?? 'N/A'} + ))} + {isMaterialsView && ( + + + + )} + {isMaterialsView && ( + + + + )} + {isMaterialsView && ( + + + + )} ) : isEmptyData ? ( -
-

No material data available

+
+

+ No material data available +

) : (
-
+
))} @@ -195,7 +230,6 @@ export default function MaterialUsageChart({ projectId, toggle, darkMode = false
-
)} diff --git a/src/components/BMDashboard/MaterialUsage/MaterialUsageChart.module.css b/src/components/BMDashboard/MaterialUsage/MaterialUsageChart.module.css index e2267557a7..369bf9b8e2 100644 --- a/src/components/BMDashboard/MaterialUsage/MaterialUsageChart.module.css +++ b/src/components/BMDashboard/MaterialUsage/MaterialUsageChart.module.css @@ -1,5 +1,32 @@ /* stylelint-disable no-descending-specificity */ +/* ========================================================================== + REACTSTRAP DARK MODE GLOBAL OVERRIDES + ========================================================================== */ +:global(.bg-dark-modal) { + background-color: #1a1d23 !important; + border-color: #2d3748 !important; +} + +:global(.bg-dark-modal .modal-header) { + background-color: #1a1d23 !important; + border-bottom: 1px solid #2d3748 !important; + color: #fff !important; +} + +:global(.bg-dark-modal .close) { + color: #cbd5e0 !important; + text-shadow: none !important; +} + +:global(.bg-dark-modal .close:hover) { + color: #fff !important; +} + +/* ========================================================================== + BASE STYLES + ========================================================================== */ + /* Modal Container */ .materialChartModal :global(.modal-dialog) { max-width: 800px; @@ -12,34 +39,28 @@ box-shadow: 0 4px 20px rgb(0 0 0 / 15%); } -/* Header */ +/* Header Base */ .materialChartHeader { - background: #f8f9fa; - color: #212529; - border-bottom: 1px solid #dee2e6; padding: 1rem 1.5rem; font-size: 1.25rem; font-weight: 600; } .materialChartHeader :global(.close) { - color: #6c757d; - opacity: 0.7; font-size: 1.5rem; + opacity: 0.7; } .materialChartHeader :global(.close):hover { opacity: 1; - color: #495057; } -/* Body */ +/* Body Base */ .materialChartBody { padding: 0; display: flex; flex-direction: column; min-height: 500px; - background: #fff; } /* Main Chart Container */ @@ -56,14 +77,14 @@ flex: 1; position: relative; min-height: 350px; - background: #fff; border-radius: 8px; padding: 1rem; - border: 1px solid #e9ecef; display: flex; align-items: center; justify-content: center; overflow: visible; + border-width: 1px; + border-style: solid; } /* Center Label */ @@ -74,7 +95,6 @@ transform: translate(-50%, -50%); text-align: center; z-index: 10; - background: #fff; padding: 1rem; border-radius: 50%; width: 100px; @@ -83,21 +103,19 @@ flex-direction: column; align-items: center; justify-content: center; - box-shadow: 0 2px 8px rgb(0 0 0 / 10%); - border: 1px solid #f1f3f4; + border-width: 1px; + border-style: solid; } .centerLabelValue { font-size: 1.1rem; font-weight: 700; - color: #2c3e50; margin: 0; line-height: 1.2; } .centerLabelSubtitle { font-size: 0.75rem; - color: #6c757d; margin-top: 0.25rem; font-weight: 500; } @@ -109,9 +127,9 @@ gap: 1.5rem; flex-wrap: wrap; padding: 1rem; - background: #f8f9fa; border-radius: 6px; - border: 1px solid #e9ecef; + border-width: 1px; + border-style: solid; } .legendItem { @@ -119,9 +137,9 @@ align-items: center; gap: 0.5rem; padding: 0.4rem 0.8rem; - background: #fff; border-radius: 4px; - border: 1px solid #dee2e6; + border-width: 1px; + border-style: solid; transition: all 0.2s ease; } @@ -146,29 +164,11 @@ .legendText { font-size: 0.9rem; font-weight: 500; - color: #495057; white-space: nowrap; } /* Loading States */ -.chartLoadingContainer { - flex: 1; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 1rem; - padding: 2rem; -} - -.chartLoadingText { - font-size: 1rem; - color: #6c757d; - font-weight: 500; -} - -/* Error States */ -.chartErrorContainer { +.statusContainer { flex: 1; display: flex; flex-direction: column; @@ -178,99 +178,204 @@ padding: 2rem; } -.chartErrorText { - font-size: 1rem; - color: #dc3545; - text-align: center; - margin: 0; - font-weight: 500; - max-width: 400px; - line-height: 1.5; -} - .chartRetryButton { - background: #007bff; - color: white; - border: none; padding: 0.5rem 1.5rem; border-radius: 4px; font-weight: 500; cursor: pointer; transition: all 0.2s ease; font-size: 0.9rem; -} - -.chartRetryButton:hover { - background: #0056b3; - transform: translateY(-1px); - box-shadow: 0 2px 4px rgb(0 123 255 / 30%); + border: none; } .chartRetryButton:active { transform: translateY(0); } -/* Empty State */ -.chartEmptyContainer { - flex: 1; - display: flex; - align-items: center; - justify-content: center; - padding: 2rem; + +/* ========================================================================== + DYNAMIC THEME CLASSES (ADDED FIXES) + ========================================================================== */ + +/* --- DARK MODE (#1a1d23) --- */ +.modalContentDark { + background-color: #1a1d23 !important; + border-color: #2d3748 !important; +} + +.headerDark { + background-color: #1a1d23 !important; + border-bottom: 1px solid #2d3748 !important; + color: #fff !important; /* Keeps wrapper white */ +} + +/* Pierce Reactstrap's internal heading tag */ +.headerDark :global(.modal-title) { + color: #fff !important; + filter: none !important; +} + +.headerDark :global(.close) { + color: #cbd5e0 !important; +} + +.headerDark :global(.close):hover { + color: #fff !important; +} + +.bodyDark { + background-color: #1a1d23; +} + +.textDark { + color: #fff; +} + +.subTextDark { + color: #a0aec0; +} + +.wrapperDark { + background-color: #1a1d23; + border-color: #2d3748; +} + +.centerLabelDark { + background-color: #1a1d23; + border-color: #2d3748; +} + +.legendAreaDark { + background-color: #1a1d23; + border-color: #2d3748; +} + +.legendItemDark { + border-color: #2d3748; + color: #e2e8f0; +} + +.errorTextDark { + color: #fc8181; +} + +.btnDark { + background-color: #4a5568; + color: #fff; +} + +.btnDark:hover { + background-color: #2d3748; +} + +/* --- LIGHT MODE --- */ +.modalContentLight { + background-color: #fff !important; } -.chartEmptyText { - font-size: 1rem; +.headerLight { + background-color: #f8f9fa !important; + color: #212529 !important; + border-bottom: 1px solid #dee2e6 !important; +} + +.headerLight :global(.close) { color: #6c757d; - margin: 0; - font-weight: 500; } -/* Responsive Design */ +.bodyLight { + background-color: #fff; +} + +.textLight { + color: #212529; +} + +.subTextLight { + color: #6c757d; +} + +.wrapperLight { + background-color: #fff; + border-color: #dee2e6; +} + +.centerLabelLight { + background-color: #fff; + border-color: #dee2e6; +} + +.legendAreaLight { + background-color: #fff; + border-color: #dee2e6; +} + +.legendItemLight { + border-color: #dee2e6; + color: #212529; +} + +.errorTextLight { + color: #dc3545; +} + +.btnLight { + background-color: #007bff; + color: #fff; +} + +.btnLight:hover { + background-color: #0056b3; +} + + +/* ========================================================================== + RESPONSIVE DESIGN + ========================================================================== */ @media (width <= 768px) { .materialChartModal :global(.modal-dialog) { max-width: 95%; margin: 0.5rem auto; } - + .materialChartHeader { padding: 0.75rem 1rem; font-size: 1.1rem; } - + .chartMainContainer { padding: 1rem; gap: 1rem; } - + .pieChartWrapper { min-height: 300px; padding: 0.5rem; } - + .centerLabel { width: 80px; height: 80px; padding: 0.75rem; } - + .centerLabelValue { font-size: 0.9rem; } - + .centerLabelSubtitle { font-size: 0.65rem; } - + .chartLegend { gap: 1rem; padding: 0.75rem; } - + .legendItem { padding: 0.3rem 0.6rem; } - + .legendText { font-size: 0.8rem; } @@ -281,151 +386,56 @@ margin: 0.25rem auto; max-width: 98%; } - + .materialChartBody { min-height: 450px; } - + .pieChartWrapper { min-height: 280px; padding: 0.25rem; } - + .centerLabel { width: 80px; height: 80px; padding: 0.6rem; } - + .centerLabelValue { font-size: 0.9rem; } - + .centerLabelSubtitle { font-size: 0.65rem; } - + .chartLegend { flex-direction: column; align-items: center; gap: 0.8rem; padding: 0.75rem; } - + .legendItem { width: 100%; max-width: 200px; justify-content: center; padding: 0.5rem 0.75rem; } - + .legendText { font-size: 0.8rem; } - - .chartLoadingText, - .chartErrorText, - .chartEmptyText { - font-size: 0.9rem; - } - - .chartRetryButton { - padding: 0.4rem 1.25rem; - font-size: 0.85rem; - } } -/* Dark Mode */ -.darkMode :global(.modal-content) { - background-color: #1a1a1a; - border-color: #333; -} - -.darkMode .materialChartHeader { - background: #2d3748; - color: #fff; - border-bottom-color: #4a5568; -} - -.darkMode .materialChartHeader :global(.close) { - color: #cbd5e0; -} - -.darkMode .materialChartHeader :global(.close):hover { - color: #fff; -} - -.darkMode .materialChartBody { - background: #1a202c; -} - -.darkMode .pieChartWrapper { - background: #2d3748; - border-color: #4a5568; -} - -.darkMode .centerLabel { - background: #2d3748; - border-color: #4a5568; - box-shadow: 0 2px 8px rgb(0 0 0 / 30%); -} - -.darkMode .centerLabelValue { - color: #fff; -} - -.darkMode .centerLabelSubtitle { - color: #cbd5e0; -} - -.darkMode .chartLegend { - background: #2d3748; - border-color: #4a5568; -} - -.darkMode .legendItem { - background: #4a5568; - border-color: #718096; -} - -.darkMode .legendText { - color: #e2e8f0; -} - -.darkMode .chartLoadingText { - color: #a0aec0; -} - -.darkMode .chartErrorText { - color: #fc8181; -} - -.darkMode .chartEmptyText { - color: #a0aec0; -} - -.darkMode .chartRetryButton { - background: #3182ce; -} - -.darkMode .chartRetryButton:hover { - background: #2c5aa0; -} - -/* Accessibility */ -.chartRetryButton:focus { - outline: 2px solid #007bff; - outline-offset: 2px; -} - -/* Reduced motion support */ @media (prefers-reduced-motion: reduce) { .legendItem, .legendColor, .chartRetryButton { transition: none; } - + .legendItem:hover, .chartRetryButton:hover { transform: none;