diff --git a/.husky/pre-commit b/.husky/pre-commit index 9bfb8f99e4..5be0087ca7 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -3,7 +3,7 @@ export NVM_DIR="$HOME/.nvm" [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" # Use Node 20 if available (or install if needed) -if [ -f .nvmrc ]; then +if command -v nvm >/dev/null 2>&1 && [ -f .nvmrc ]; then nvm use 20 2>/dev/null || nvm install 20 && nvm use 20 fi diff --git a/.husky/pre-push b/.husky/pre-push index 073388af8b..66434ed163 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -2,26 +2,24 @@ # Load nvm if available export NVM_DIR="$HOME/.nvm" [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" -# Use Node 20 if available (or install if needed) + +# Use Node 20 if available if command -v nvm >/dev/null 2>&1 && [ -f .nvmrc ]; then nvm use 20 2>/dev/null || nvm install 20 && nvm use 20 fi -if git rev-parse --abbrev-ref @{upstream} >/dev/null 2>&1; then - changed_files=$(git diff --name-only --diff-filter=d @{upstream}...HEAD) -else - echo "ℹ️ No upstream branch set. Skipping pre-push checks." - exit 0 -fi +changed_files=$( + git log --first-parent --no-merges --format= --name-only @{upstream}..HEAD 2>/dev/null | + grep -v '^$' | + sort -u +) -# Skip checks if any outgoing commit is a merge commit -if git log @{upstream}..HEAD --merges --oneline | grep -q .; then - echo "ℹ️ Outgoing commits include a merge commit. Skipping lint/test checks." - exit 0 +if [ -z "$changed_files" ]; then + changed_files=$(git diff --name-only HEAD~1..HEAD 2>/dev/null) fi if [ -z "$changed_files" ]; then - echo "ℹ️ No outgoing changes to validate. Skipping pre-push checks." + echo "No outgoing changes to validate. Skipping pre-push checks." exit 0 fi @@ -32,7 +30,7 @@ if git log @{upstream}..HEAD --merges --oneline | grep -q .; then fi if ! printf '%s\n' "$changed_files" | grep -qvE '^\.husky/'; then - echo "ℹ️ Only Husky hook changes detected. Skipping full test and lint checks." + echo "Only Husky hook changes detected. Skipping full test and lint checks." exit 0 fi @@ -48,12 +46,12 @@ lint_status=0 style_status=0 if [ -n "$js_files" ]; then - echo "🧪 Running related tests before push..." - echo "$js_files" | tr ' ' '\n' | xargs npx vitest related --run + echo "Running related tests before push..." + printf '%s\n' "$js_files" | xargs -r -n 25 npx vitest related --run test_status=$? - echo "🧹 Running ESLint on changed files before push..." - echo "$js_files" | tr ' ' '\n' | xargs npx eslint + echo "Running ESLint on changed files before push..." + printf '%s\n' "$js_files" | xargs -r -n 50 npx eslint lint_status=$? fi @@ -66,8 +64,8 @@ fi # Block push if either fails if [ $test_status -ne 0 ] || [ $lint_status -ne 0 ] || [ $style_status -ne 0 ]; then - echo "❌ Push blocked: changed-file validation failed." + echo "Push blocked: changed-file validation failed." exit 1 fi -echo "✅ All checks passed. Proceeding with push..." +echo "All checks passed. Proceeding with push..." diff --git a/src/App.module.css b/src/App.module.css index bdceb7cf6e..cf3466304b 100644 --- a/src/App.module.css +++ b/src/App.module.css @@ -24,7 +24,19 @@ button { background-color: #fff; } +html { + background-color: #fff; +} +body { + background-color: #fff; +} + +/* Dark mode background for the page itself */ +body.dark-mode, +body.bm-dashboard-dark { + background-color: #1a1d23 !important; +} /* Dark mode override for #root */ body.dark-mode #root, diff --git a/src/components/BMDashboard/ItemList/ItemListView.jsx b/src/components/BMDashboard/ItemList/ItemListView.jsx index 677ae90af4..5aaa1e01c5 100644 --- a/src/components/BMDashboard/ItemList/ItemListView.jsx +++ b/src/components/BMDashboard/ItemList/ItemListView.jsx @@ -8,6 +8,7 @@ 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'; export function ItemListView({ @@ -25,6 +26,7 @@ export function ItemListView({ const [isError, setIsError] = useState(false); const [selectedTime, setSelectedTime] = useState(new Date()); + const isMaterialsView = itemType === 'Materials'; const [searchQuery, setSearchQuery] = useState(''); const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' }); const [currentPage, setCurrentPage] = useState(1); @@ -169,22 +171,26 @@ export function ItemListView({ {items && (
- - setSelectedTime(date)} - showTimeSelect - timeFormat="HH:mm" - timeIntervals={15} - dateFormat="yyyy-MM-dd HH:mm:ss" - placeholderText="Select date and time" - inputId="itemListTime" - className={darkMode ? styles.darkDatePickerInput : styles.lightDatePickerInput} - calendarClassName={darkMode ? styles.darkDatePicker : styles.lightDatePicker} - popperClassName={ - darkMode ? styles.darkDatePickerPopper : styles.lightDatePickerPopper - } - /> +
+ +
+ setSelectedTime(date)} + showTimeSelect + timeFormat="HH:mm" + timeIntervals={15} + dateFormat="yyyy-MM-dd HH:mm:ss" + placeholderText="Select date and time" + inputId="itemListTime" + className={darkMode ? styles.darkDatePickerInput : styles.lightDatePickerInput} + calendarClassName={darkMode ? styles.darkDatePicker : styles.lightDatePicker} + popperClassName={ + darkMode ? styles.darkDatePickerPopper : styles.lightDatePickerPopper + } + /> +
+
@@ -249,15 +255,15 @@ export function ItemListView({ )} -
{totalItems} {totalItems === 1 ? 'material' : 'materials'} found
- {children} - - {filteredItems && ( + <> + {isMaterialsView && ( + + )} - )} + ); @@ -287,7 +293,8 @@ ItemListView.propTypes = { itemType: PropTypes.string.isRequired, items: PropTypes.arrayOf( PropTypes.shape({ - id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + name: PropTypes.string, itemType: PropTypes.shape({ name: PropTypes.string, unit: PropTypes.string, @@ -301,6 +308,7 @@ ItemListView.propTypes = { stockUsed: PropTypes.number, stockWasted: PropTypes.number, stockHold: PropTypes.number, + productId: PropTypes.string, }), ).isRequired, errors: PropTypes.shape({ diff --git a/src/components/BMDashboard/ItemList/ItemListView.module.css b/src/components/BMDashboard/ItemList/ItemListView.module.css index 47d65ea217..094411ebfd 100644 --- a/src/components/BMDashboard/ItemList/ItemListView.module.css +++ b/src/components/BMDashboard/ItemList/ItemListView.module.css @@ -9,6 +9,9 @@ table td { max-width: 1536px; margin: 1rem auto; padding: 0 1rem; + min-height: 100vh; + background-color: #fff; + color: #1f2937; } .itemsTableContainer { @@ -16,6 +19,17 @@ table td { max-height: 600px; border: 1px solid #e5e7eb; border-radius: 6px; + background-color: #fff; +} + +.itemsTableContainer table { + background-color: #fff; + color: #1f2937; + margin-bottom: 0; +} + +.itemsTableContainer table tbody tr { + background-color: #fff; } .items_list_container section { @@ -243,9 +257,7 @@ table td { .darkMode { color: #e8edf4; - background-color: #0d1117 !important; - -/* color: #fff !important; */ + background-color: #1a1d23 !important; min-height: 100vh; padding-bottom: 2rem; } @@ -268,6 +280,25 @@ table td { border-bottom: 1px solid #3f5269; } +/* Dark mode container/table base (overrides the light defaults above) */ +.darkTableWrapper { + background-color: #1f2e45; +} + +.darkTableWrapper table, +.itemsTableContainer .darkTable { + background-color: #1f2e45; + color: #e8edf4; +} + +.itemsTableContainer .darkTable tbody tr:nth-child(odd) { + background-color: #1f2e45; +} + +.itemsTableContainer .darkTable tbody tr:nth-child(even) { + background-color: #23375b; +} + :global(.dark-mode) .items_table_container { background-color: transparent !important; } @@ -371,31 +402,31 @@ table td { border: 1px solid #3f5269 !important; } -.darkDatePicker .react-datepicker__header { - background-color: #2f4157; - border-color: #3f5269; +.darkDatePicker :global(.react-datepicker__header) { + background-color: #2f4157 !important; + border-color: #3f5269 !important; } -.darkDatePicker .react-datepicker__time-header { +.darkDatePicker :global(.react-datepicker__time-header), +.darkDatePicker :global(.react-datepicker-time__header) { color: #f2f6ff !important; background-color: #2f4157 !important; - border-bottom: 1px solid #3f5269 !important; } -.darkDatePicker .react-datepicker__current-month, -.darkDatePicker .react-datepicker-year-header { +.darkDatePicker :global(.react-datepicker__current-month), +.darkDatePicker :global(.react-datepicker-year-header) { color: #f2f6ff !important; } -.darkDatePicker .react-datepicker__day-name, -.darkDatePicker .react-datepicker__day, -.darkDatePicker .react-datepicker__time-name { - color: #e8edf4; +.darkDatePicker :global(.react-datepicker__day-name), +.darkDatePicker :global(.react-datepicker__day), +.darkDatePicker :global(.react-datepicker__time-name) { + color: #e8edf4 !important; } -.darkDatePicker .react-datepicker__day--selected, -.darkDatePicker .react-datepicker__day--keyboard-selected, -.darkDatePicker .react-datepicker__time-list-item--selected { +.darkDatePicker :global(.react-datepicker__day--selected), +.darkDatePicker :global(.react-datepicker__day--keyboard-selected), +.darkDatePicker :global(.react-datepicker__time-list-item--selected) { background-color: #468ef9 !important; color: #fff !important; border-color: #468ef9 !important; @@ -567,3 +598,8 @@ table td { min-width: 0; } } + +.inlineField { + margin-top: -9px; +} + diff --git a/src/components/BMDashboard/ItemList/ItemsTable.jsx b/src/components/BMDashboard/ItemList/ItemsTable.jsx index a880ebadbd..7693563982 100644 --- a/src/components/BMDashboard/ItemList/ItemsTable.jsx +++ b/src/components/BMDashboard/ItemList/ItemsTable.jsx @@ -1,10 +1,14 @@ import { useState } from 'react'; import PropTypes from 'prop-types'; -import { Table, Button, Badge } from 'reactstrap'; +import { Table, Button } from 'reactstrap'; import { BiPencil } from 'react-icons/bi'; 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'; const rowsPerPageOptions = [25, 50, 100]; @@ -40,6 +44,10 @@ export default function ItemsTable({ const [recordType, setRecordType] = useState(''); const [updateModal, setUpdateModal] = useState(false); const [updateRecord, setUpdateRecord] = useState(null); + const [showChartModal, setShowChartModal] = useState(false); + const [chartProjectId, setChartProjectId] = useState(null); + + const isMaterialsView = itemType === 'Materials'; const handleEditRecordsClick = (selectedEl, type) => { if (type === 'Update') { @@ -49,14 +57,28 @@ export default function ItemsTable({ }; const handleViewRecordsClick = (data, type) => { + if (type === 'UsageRecord') { + const projectId = data.project?._id || data.projectId; + + if (projectId) { + setChartProjectId(projectId); + setShowChartModal(true); + return; + } + } + setModal(true); setRecord(data); setRecordType(type); }; - const getNestedValue = (obj, path) => - path ? path.split('.').reduce((acc, part) => (acc ? acc[part] : null), obj) : null; + const getNestedValue = (obj, path) => { + if (!path) return null; + if (path === 'product id') return obj.productId ?? 'N/A'; + return path.split('.').reduce((acc, part) => (acc ? acc[part] : null), obj); + }; + const emptyStateColSpan = 4 + dynamicColumns.length + (isMaterialsView ? 3 : 0); const getIconFor = key => { if (!sortConfig?.key || sortConfig.key !== key) return faSort; return sortConfig.direction === 'asc' ? faSortUp : faSortDown; @@ -92,9 +114,35 @@ export default function ItemsTable({ recordType={recordType} itemType={itemType} /> + {showChartModal && chartProjectId && ( + setShowChartModal(false)} + darkMode={darkMode} + /> + )} {UpdateItemModal && ( )} + {darkMode && ( + + )}
@@ -128,9 +176,13 @@ export default function ItemsTable({ ); })} - + {isMaterialsView && } + {isMaterialsView && } + {isMaterialsView && ( + + )} - + + {filteredItems && filteredItems.length > 0 ? ( filteredItems.map(el => ( - - - - {(dynamicColumns || []).map(({ label, key }) => { - const value = getNestedValue(el, key); - if ( - key === 'stockAvailable' && - value !== null && - value !== undefined && - Number(value) < 10 - ) { - return ( - - ); - } - return ( - - ); - })} - - + + + {dynamicColumns.map(({ label, key }) => ( + + ))} + {isMaterialsView && ( + + )} + {isMaterialsView && ( + + )} + {isMaterialsView && ( + + )} + - - diff --git a/src/components/BMDashboard/MaterialList/MaterialSummaryPanel.jsx b/src/components/BMDashboard/MaterialList/MaterialSummaryPanel.jsx new file mode 100644 index 0000000000..7b4705eda1 --- /dev/null +++ b/src/components/BMDashboard/MaterialList/MaterialSummaryPanel.jsx @@ -0,0 +1,70 @@ +import PropTypes from 'prop-types'; +import { calculateSummaryMetrics } from '../../../utils/materialInsights'; +import styles from './MaterialSummaryPanel.module.css'; + +/** + * MaterialSummaryPanel Component + * Displays key material metrics in a responsive grid of cards: + * - Total materials count + * - Percentage and count of items at low/critical stock + * - Percentage and count of items over usage threshold (80%) + * - Count of items on hold + * Uses materialInsights utility for centralized calculations + * Supports light/dark mode with responsive design + */ +export default function MaterialSummaryPanel({ materials, darkMode = false }) { + const metrics = calculateSummaryMetrics(materials); + + return ( +
+
+ {/* Total Materials Card */} +
+
Total Materials
+
{metrics.totalMaterials}
+
+ + {/* Low Stock Card */} +
+
% at Low Stock
+
+ {metrics.lowStockPercentage}% + ({metrics.lowStockCount}) +
+
+ + {/* Over Usage Card */} +
+
% Over Usage Threshold
+
+ {metrics.overUsagePercentage}% + ({metrics.overUsageCount}) +
+
+ + {/* On Hold Card */} +
+
Items on Hold
+
{metrics.onHoldCount}
+
+
+
+ ); +} + +MaterialSummaryPanel.propTypes = { + materials: PropTypes.arrayOf( + PropTypes.shape({ + stockBought: PropTypes.number, + stockUsed: PropTypes.number, + stockAvailable: PropTypes.number, + stockHold: PropTypes.number, + }), + ), + darkMode: PropTypes.bool, +}; + +MaterialSummaryPanel.defaultProps = { + materials: [], + darkMode: false, +}; diff --git a/src/components/BMDashboard/MaterialList/MaterialSummaryPanel.module.css b/src/components/BMDashboard/MaterialList/MaterialSummaryPanel.module.css new file mode 100644 index 0000000000..107f92f327 --- /dev/null +++ b/src/components/BMDashboard/MaterialList/MaterialSummaryPanel.module.css @@ -0,0 +1,99 @@ +/* Material Summary Panel Styles */ +/* Displays key material metrics in a responsive grid of cards */ + +.summaryPanel { + margin: 1.5rem 0; + padding: 1rem 0; +} + +.summaryGrid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1.5rem; + width: 100%; +} + +/* Summary card styling with gradient background and hover effects */ +.summaryCard { + background: linear-gradient(135deg, #f0f4ff 0%, #e8ecf8 100%); + border: 1px solid #d4dce8; + border-radius: 8px; + padding: 1.25rem; + text-align: center; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); + transition: all 0.3s ease; +} + +.summaryCard:hover { + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + transform: translateY(-2px); +} + +.cardLabel { + font-size: 0.875rem; + font-weight: 500; + color: #666; + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 0.75rem; +} + +.cardValue { + font-size: 2rem; + font-weight: 700; + color: #2c3e50; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.25rem; +} + +.cardCount { + font-size: 0.875rem; + font-weight: 400; + color: #7f8c8d; + margin-top: 0.25rem; +} + +/* Dark Mode Styles */ +.darkMode .summaryCard { + background: linear-gradient(135deg, #2f4157 0%, #1f2e45 100%); + border-color: #3f5269; +} + +.darkMode .summaryCard:hover { + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3); +} + +.darkMode .cardLabel { + color: #a0b0c8; +} + +.darkMode .cardValue { + color: #e8edf4; +} + +.darkMode .cardCount { + color: #7a8a9e; +} + +/* Responsive Design */ +@media (max-width: 1024px) { + .summaryGrid { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 640px) { + .summaryGrid { + grid-template-columns: 1fr; + } + + .summaryCard { + padding: 1rem; + } + + .cardValue { + font-size: 1.5rem; + } +} diff --git a/src/components/BMDashboard/MaterialList/StockHealthIndicator.jsx b/src/components/BMDashboard/MaterialList/StockHealthIndicator.jsx new file mode 100644 index 0000000000..be22dc7301 --- /dev/null +++ b/src/components/BMDashboard/MaterialList/StockHealthIndicator.jsx @@ -0,0 +1,62 @@ +import PropTypes from 'prop-types'; +import { useState } from 'react'; +import { Tooltip } from 'reactstrap'; +import { + calculateMaterialInsights, + getStockHealthTooltip, + getStockHealthColor, + getStockHealthIcon, +} from '../../../utils/materialInsights'; +import styles from './StockHealthIndicator.module.css'; + +export default function StockHealthIndicator({ material, darkMode = false }) { + const [tooltipOpen, setTooltipOpen] = useState(false); + const tooltipId = `stock-health-${material._id}`; + + const insights = calculateMaterialInsights(material); + const color = getStockHealthColor(insights.stockHealth); + const icon = getStockHealthIcon(insights.stockHealth); + const label = insights.stockHealthLabel; + const tooltipText = getStockHealthTooltip(material); + + return ( + <> +
+ + {icon} + + {label} +
+ setTooltipOpen(!tooltipOpen)} + className={darkMode ? styles.darkTooltip : ''} + > + {tooltipText.split('\n').map((line, idx) => ( +
{line}
+ ))} +
+ + ); +} + +StockHealthIndicator.propTypes = { + material: PropTypes.shape({ + _id: PropTypes.string.isRequired, + stockBought: PropTypes.number, + stockUsed: PropTypes.number, + stockAvailable: PropTypes.number, + stockWasted: PropTypes.number, + stockHold: PropTypes.number, + }).isRequired, + darkMode: PropTypes.bool, +}; + +StockHealthIndicator.defaultProps = { + darkMode: false, +}; diff --git a/src/components/BMDashboard/MaterialList/StockHealthIndicator.module.css b/src/components/BMDashboard/MaterialList/StockHealthIndicator.module.css new file mode 100644 index 0000000000..363503c2ba --- /dev/null +++ b/src/components/BMDashboard/MaterialList/StockHealthIndicator.module.css @@ -0,0 +1,130 @@ +/* Stock Health Indicator Styles */ + +.indicator { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.4rem 0.8rem; + border-radius: 4px; + font-size: 0.875rem; + font-weight: 500; + cursor: help; + transition: all 0.2s ease; +} + +.icon { + font-size: 1.2rem; + line-height: 1; + display: inline-block; +} + +.label { + white-space: nowrap; +} + +/* Stock Health Colors - Light Mode */ +.indicator.green { + background-color: #d1fae5; + color: #065f46; + border: 1px solid #6ee7b7; +} + +.indicator.green:hover { + background-color: #a7f3d0; + box-shadow: 0 2px 4px rgba(16, 185, 129, 0.2); +} + +.indicator.yellow { + background-color: #fef3c7; + color: #92400e; + border: 1px solid #fcd34d; +} + +.indicator.yellow:hover { + background-color: #fde68a; + box-shadow: 0 2px 4px rgba(245, 158, 11, 0.2); +} + +.indicator.red { + background-color: #fee2e2; + color: #7f1d1d; + border: 1px solid #fca5a5; +} + +.indicator.red:hover { + background-color: #fecaca; + box-shadow: 0 2px 4px rgba(220, 38, 38, 0.2); +} + +.indicator.gray { + background-color: #f3f4f6; + color: #6b7280; + border: 1px solid #d1d5db; +} + +.indicator.gray:hover { + background-color: #e5e7eb; + box-shadow: 0 2px 4px rgba(107, 114, 128, 0.2); +} + +/* Dark Mode Styles */ +.darkMode.indicator.green { + background-color: rgba(16, 185, 129, 0.2); + color: #a7f3d0; + border: 1px solid #10b981; +} + +.darkMode.indicator.green:hover { + background-color: rgba(16, 185, 129, 0.3); + box-shadow: 0 2px 4px rgba(16, 185, 129, 0.3); +} + +.darkMode.indicator.yellow { + background-color: rgba(245, 158, 11, 0.2); + color: #fde68a; + border: 1px solid #f59e0b; +} + +.darkMode.indicator.yellow:hover { + background-color: rgba(245, 158, 11, 0.3); + box-shadow: 0 2px 4px rgba(245, 158, 11, 0.3); +} + +.darkMode.indicator.red { + background-color: rgba(220, 38, 38, 0.2); + color: #fecaca; + border: 1px solid #dc2626; +} + +.darkMode.indicator.red:hover { + background-color: rgba(220, 38, 38, 0.3); + box-shadow: 0 2px 4px rgba(220, 38, 38, 0.3); +} + +.darkMode.indicator.gray { + background-color: rgba(107, 114, 128, 0.2); + color: #d1d5db; + border: 1px solid #6b7280; +} + +.darkMode.indicator.gray:hover { + background-color: rgba(107, 114, 128, 0.3); + box-shadow: 0 2px 4px rgba(107, 114, 128, 0.3); +} + +/* Tooltip Styles */ +:global(.tooltip-inner) { + max-width: 200px; + padding: 0.75rem; + text-align: left; + line-height: 1.5; +} + +:global(.darkMode .tooltip-inner) { + background-color: #1f2e45; + color: #e8edf4; +} + +:global(.tooltip.show) { + z-index: 1000; +} diff --git a/src/components/BMDashboard/MaterialList/UsagePercentageBar.jsx b/src/components/BMDashboard/MaterialList/UsagePercentageBar.jsx new file mode 100644 index 0000000000..bc6a1dd6a4 --- /dev/null +++ b/src/components/BMDashboard/MaterialList/UsagePercentageBar.jsx @@ -0,0 +1,96 @@ +import PropTypes from 'prop-types'; +import { useState } from 'react'; +import { Tooltip } from 'reactstrap'; +import { + calculateUsagePercentage, + getClampedUsagePercentage, + getUsagePercentageTooltip, +} from '../../../utils/materialInsights'; +import styles from './UsagePercentageBar.module.css'; + +export default function UsagePercentageBar({ material, darkMode = false }) { + const [tooltipOpen, setTooltipOpen] = useState(false); + const tooltipId = `usage-pct-${material._id}`; + + const bought = material?.stockBought || 0; + const used = material?.stockUsed || 0; + + const usagePct = calculateUsagePercentage(used, bought); + const usagePctClamped = getClampedUsagePercentage(usagePct); + const tooltipText = getUsagePercentageTooltip(material); + + // Determine color based on usage percentage + const getUsageColor = () => { + if (usagePct === null) return 'gray'; + if (usagePct >= 80) return 'red'; + if (usagePct >= 50) return 'yellow'; + return 'green'; + }; + + const usageColor = getUsageColor(); + + if (!bought || bought <= 0) { + return ( + <> +
+ N/A +
+ setTooltipOpen(!tooltipOpen)} + className={darkMode ? styles.darkTooltip : ''} + > + {tooltipText} + + + ); + } + + return ( + <> +
+
+
+
+ + {used} / {bought} ({usagePct}%) + +
+ setTooltipOpen(!tooltipOpen)} + className={darkMode ? styles.darkTooltip : ''} + > + {tooltipText} + + + ); +} + +UsagePercentageBar.propTypes = { + material: PropTypes.shape({ + _id: PropTypes.string.isRequired, + stockBought: PropTypes.number, + stockUsed: PropTypes.number, + }).isRequired, + darkMode: PropTypes.bool, +}; + +UsagePercentageBar.defaultProps = { + darkMode: false, +}; diff --git a/src/components/BMDashboard/MaterialList/UsagePercentageBar.module.css b/src/components/BMDashboard/MaterialList/UsagePercentageBar.module.css new file mode 100644 index 0000000000..e55495f262 --- /dev/null +++ b/src/components/BMDashboard/MaterialList/UsagePercentageBar.module.css @@ -0,0 +1,107 @@ +/* Usage Percentage Bar Styles */ + +.usageContainer { + display: flex; + flex-direction: column; + gap: 0.5rem; + align-items: stretch; + cursor: help; +} + +.barWrapper { + width: 100%; + height: 24px; + background-color: #e5e7eb; + border-radius: 4px; + overflow: hidden; + border: 1px solid #d1d5db; +} + +.progressBar { + height: 100%; + transition: width 0.3s ease; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.7rem; + color: white; + font-weight: 600; +} + +/* Progress Bar Colors - Light Mode */ +.progressBar.green { + background: linear-gradient(90deg, #10b981 0%, #34d399 100%); +} + +.progressBar.yellow { + background: linear-gradient(90deg, #f59e0b 0%, #fbbf24 100%); +} + +.progressBar.red { + background: linear-gradient(90deg, #ef4444 0%, #f87171 100%); +} + +.progressBar.gray { + background: linear-gradient(90deg, #9ca3af 0%, #d1d5db 100%); +} + +.usageLabel { + font-size: 0.8125rem; + color: #4b5563; + font-weight: 500; + text-align: center; + min-height: 1.25rem; + display: flex; + align-items: center; + justify-content: center; +} + +/* Dark Mode Styles */ +.darkMode .barWrapper { + background-color: #3f5269; + border-color: #2f4157; +} + +.darkMode .usageLabel { + color: #c8d2e0; +} + +/* Progress Bar Colors - Dark Mode */ +.darkMode .progressBar.green { + background: linear-gradient(90deg, #059669 0%, #10b981 100%); +} + +.darkMode .progressBar.yellow { + background: linear-gradient(90deg, #d97706 0%, #f59e0b 100%); +} + +.darkMode .progressBar.red { + background: linear-gradient(90deg, #dc2626 0%, #ef4444 100%); +} + +.darkMode .progressBar.gray { + background: linear-gradient(90deg, #4b5563 0%, #6b7280 100%); +} + +/* Tooltip Styles */ +:global(.tooltip-inner) { + max-width: 200px; + padding: 0.75rem; + text-align: left; +} + +:global(.darkMode .tooltip-inner) { + background-color: #1f2e45; + color: #e8edf4; +} + +/* Responsive Design */ +@media (max-width: 768px) { + .barWrapper { + height: 20px; + } + + .usageLabel { + font-size: 0.75rem; + } +} diff --git a/src/components/BMDashboard/MaterialUsage/MaterialUsageChart.jsx b/src/components/BMDashboard/MaterialUsage/MaterialUsageChart.jsx index 696fdb16aa..977e4617f5 100644 --- a/src/components/BMDashboard/MaterialUsage/MaterialUsageChart.jsx +++ b/src/components/BMDashboard/MaterialUsage/MaterialUsageChart.jsx @@ -6,7 +6,6 @@ import axios from 'axios'; import { ENDPOINTS } from '../../../utils/URL'; import styles from './MaterialUsageChart.module.css'; -// Constants const COLORS = ['#A74C4C', '#4C4C4C', '#C9B28A']; export default function MaterialUsageChart({ projectId, toggle, darkMode = false }) { @@ -15,47 +14,62 @@ export default function MaterialUsageChart({ projectId, toggle, darkMode = false const [loading, setLoading] = useState(true); const [error, setError] = useState(''); - // Calculate percentages for chart data const chartDataWithPercentages = useMemo(() => { if (!chartData.length) return []; - const total = chartData.reduce((sum, item) => sum + item.value, 0); if (total === 0) return chartData.map(item => ({ ...item, percentage: '0.00' })); - return chartData.map(item => ({ ...item, percentage: total > 0 ? ((item.value / total) * 100).toFixed(2) : '0.00', })); }, [chartData]); - // Helper functions const formatIncrease = value => (value >= 0 ? `+${value}%` : `${value}%`); - const isEmptyData = useMemo(() => chartData.every(item => item.value === 0), [chartData]); - // Center label component const CenterLabel = ({ increase }) => ( -
- {formatIncrease(increase)} -
week over week
+
+ + {formatIncrease(increase)} + +
+ week over week +
); - // Chart legend component const ChartLegend = ({ data }) => ( -
+
{data.map((entry, index) => ( -
+
- {entry.name}: {entry.percentage}% + {entry.name}: {entry.percentage}
))}
); - // Custom label component to show percentages and labels outside the chart const renderCustomizedLabel = ({ cx, cy, @@ -70,12 +84,14 @@ export default function MaterialUsageChart({ projectId, toggle, darkMode = false const x = cx + radius * Math.cos(-midAngle * RADIAN); const y = cy + radius * Math.sin(-midAngle * RADIAN); + // Explicit Recharts SVG colors + const labelColor = darkMode ? '#e2e8f0' : '#495057'; return ( cx ? 'start' : 'end'} dominantBaseline="central" fontSize={12} @@ -83,23 +99,20 @@ export default function MaterialUsageChart({ projectId, toggle, darkMode = false > {`${name}: ${percentage}%`} - {/* Add a line connecting the segment to the label */} ); }; - // Data fetching useEffect(() => { if (!projectId) return; - const fetchData = async () => { try { setLoading(true); @@ -107,14 +120,12 @@ export default function MaterialUsageChart({ projectId, toggle, darkMode = false const { data } = await axios.get(`${ENDPOINTS.BM_MATERIALS}/${projectId}`, { timeout: 10000, }); - const { availableMaterials = 0, usedMaterials = 0, wastedMaterials = 0, increaseOverLastWeek = 0, } = data; - setChartData([ { name: 'Available', value: availableMaterials }, { name: 'Used', value: usedMaterials }, @@ -127,47 +138,71 @@ export default function MaterialUsageChart({ projectId, toggle, darkMode = false setLoading(false); } }; - fetchData(); }, [projectId]); - // Retry function const retryFetch = () => { setError(''); setLoading(true); }; - // Apply dark mode class if enabled - const modalClass = darkMode - ? `${styles.materialChartModal} ${styles.darkMode}` - : styles.materialChartModal; + // Matches the wrapper background to hide the pie gaps cleanly + const pieStrokeColor = darkMode ? '#2d3748' : '#fff'; return ( - - + + Material Usage Proportion - + {loading ? ( -
+
-
Loading material data...
+
+ Loading material data... +
) : error ? ( -
-

{error}

-
) : 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; diff --git a/src/components/common/ThemeManager.jsx b/src/components/common/ThemeManager.jsx index 5b49fb2c7d..806e33bc1f 100644 --- a/src/components/common/ThemeManager.jsx +++ b/src/components/common/ThemeManager.jsx @@ -9,6 +9,15 @@ const ThemeManager = () => { const darkMode = useSelector(state => state.theme?.darkMode || false); useEffect(() => { + // Drive the page background directly from the redux theme so it can never + // disagree with the component-level theme (inline !important beats any + // lingering `.dark-mode` stylesheet rule). + const pageBg = darkMode ? '#1a1d23' : '#ffffff'; + document.documentElement.style.setProperty('background-color', pageBg, 'important'); + document.body.style.setProperty('background-color', pageBg, 'important'); + const rootEl = document.getElementById('root'); + if (rootEl) rootEl.style.setProperty('background-color', pageBg, 'important'); + // Apply dark mode class to body for global styling if (darkMode) { document.body.classList.add('dark-mode'); @@ -26,6 +35,10 @@ const ThemeManager = () => { document.body.classList.remove('dark-mode'); document.body.classList.remove('bm-dashboard-dark'); document.documentElement.classList.remove('dark-mode'); + document.documentElement.style.removeProperty('background-color'); + document.body.style.removeProperty('background-color'); + const rootEl = document.getElementById('root'); + if (rootEl) rootEl.style.removeProperty('background-color'); }; }, [darkMode]); 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}%)`; +};
- Usage Record - Usage %Stock Health + Usage Record +
{el.project?.name}{el.itemType?.name} - - Low - - {value} - - {value} - - - - -
{el.project?.name}{el.itemType?.name} + {getNestedValue(el, key) ?? 'N/A'} + + + + + + + +
- - - + + +
+
+ No items data