import { useState, useMemo, useEffect } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { AlertTriangle, TrendingDown, ShoppingCart, Check, BellOff, ChevronDown, ChevronUp, Info, Edit2, X, Lock, ArrowUp, ArrowDown, ArrowUpDown, Package, Trash2, BarChart2, CreditCard, PackageCheck, Download, RotateCcw, } from 'lucide-react'; import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine, Legend, } from 'recharts'; import { api } from '../api/client'; import type { InventorySpool, SpoolUsageRecord, FilamentSkuSettings, ShoppingListItem } from '../api/client'; import { getSwatchStyle } from '../utils/colors'; import { useToast } from '../contexts/ToastContext'; import { useAuth } from '../contexts/AuthContext'; // ── Types ───────────────────────────────────────────────────────────────────── interface SkuGroup { key: string; material: string; subtype: string | null; brand: string | null; spools: InventorySpool[]; } interface SkuForecast { group: SkuGroup; settings: FilamentSkuSettings | null; totalRemainingG: number; totalLabelG: number; totalSpools: number; totalUsedG: number; dailyRateG: number | null; dailyRateStdDev: number | null; rateTier: 'history' | 'delta' | 'none'; effectiveLeadTimeDays: number; safetyStockG: number; reorderPointG: number; daysRemaining: number | null; daysUntilROP: number | null; projectedEmptyDate: Date | null; reorderTriggerDate: Date | null; reorderAlert: boolean; stockBreakAlert: boolean; } type SortKey = 'material' | 'used' | 'days_left' | 'stock'; type SortDir = 'asc' | 'desc'; type ChartDays = 7 | 30 | 180; // ── Constants ───────────────────────────────────────────────────────────────── const Z_95 = 1.65; const CHART_COLORS = ['#1DB954', '#3B82F6', '#F59E0B', '#EF4444', '#8B5CF6']; // ── Pure helpers ────────────────────────────────────────────────────────────── function skuKey(material: string, subtype: string | null, brand: string | null) { return `${material}||${subtype ?? ''}||${brand ?? ''}`; } function addDays(date: Date, days: number): Date { const d = new Date(date); d.setDate(d.getDate() + Math.round(days)); return d; } function formatDate(date: Date): string { return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); } function formatDateShort(date: Date): string { return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); } /** * Compute a time-weighted daily consumption rate and standard deviation. * * Algorithm: * 1. Sort all usage events by timestamp (oldest → newest). * 2. Convert each event into a g/day intensity = weight_used / elapsed_days, * where elapsed_days is the gap to the previous event (floor: 0.5d to * avoid inflated rates from same-day prints). * 3. Apply exponential age-decay: each observation is weighted by * exp(-λ * age_days) so recent prints dominate. λ = ln(2)/30 gives a * 30-day half-life — prints from a month ago count half as much. * 4. Compute the weighted mean and weighted variance → std dev. * * Returns null when there is only one event (no gap to measure) — the * delta-rate fallback handles that case. */ function computeHistoryRate(records: SpoolUsageRecord[]): { rate: number; stdDev: number } | null { if (records.length < 2) return null; // Sort ascending by time const sorted = [...records].sort( (a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime(), ); const now = Date.now(); // λ for 30-day half-life: ln(2)/30 const lambda = Math.LN2 / 30; const observations: { rate: number; weight: number }[] = []; for (let i = 1; i < sorted.length; i++) { const prev = new Date(sorted[i - 1].created_at).getTime(); const curr = new Date(sorted[i].created_at).getTime(); const elapsedDays = Math.max((curr - prev) / 86400000, 0.5); // floor at 0.5d const ageDays = (now - curr) / 86400000; // g/day for this interval const intervalRate = sorted[i].weight_used / elapsedDays; // Exponential age-decay weight const w = Math.exp(-lambda * ageDays); observations.push({ rate: intervalRate, weight: w }); } const totalW = observations.reduce((s, o) => s + o.weight, 0); if (totalW === 0) return null; const mean = observations.reduce((s, o) => s + o.rate * o.weight, 0) / totalW; const variance = observations.reduce((s, o) => s + o.weight * (o.rate - mean) ** 2, 0) / totalW; return { rate: mean, stdDev: Math.sqrt(variance) }; } function computeDeltaRate(spools: InventorySpool[]): number | null { // Use weight_used - baseline so "Reset usage to 0" on the Inventory page // makes forecast restart from zero rather than carrying stale lifetime // consumption across the reset (#1390). const totalUsed = spools.reduce((s, sp) => s + Math.max(0, sp.weight_used - (sp.weight_used_baseline ?? 0)), 0); if (totalUsed === 0) return null; const now = Date.now(); const oldestMs = spools.reduce((min, sp) => { const t = new Date(sp.created_at).getTime(); return t < min ? t : min; }, now); const daysSinceOldest = (now - oldestMs) / 86400000; if (daysSinceOldest < 1) return null; return totalUsed / daysSinceOldest; } function buildProjectionSeries( forecast: SkuForecast, days = 60, ): { day: number; label: string; stock: number; rop: number }[] { if (forecast.dailyRateG === null) return []; const rate = forecast.dailyRateG; const result = []; for (let d = 0; d <= days; d++) { const stock = Math.max(0, forecast.totalRemainingG - rate * d); result.push({ day: d, label: formatDateShort(addDays(new Date(), d)), stock: Math.round(stock), rop: Math.round(forecast.reorderPointG), }); if (stock === 0) break; } return result; } // ── Main component ──────────────────────────────────────────────────────────── export function ForecastPanel({ spools }: { spools: InventorySpool[] }) { const queryClient = useQueryClient(); const { showToast } = useToast(); const { t } = useTranslation(); const { hasPermission, hasAnyPermission } = useAuth(); const canRead = hasPermission('inventory:forecast_read'); const canWrite = hasAnyPermission('inventory:forecast_write', 'inventory:update'); // All hooks must run unconditionally — guard render is deferred until after hooks const [alertsOpen, setAlertsOpen] = useState(false); const [sortKey, setSortKey] = useState('material'); const [sortDir, setSortDir] = useState('asc'); const [cartModal, setCartModal] = useState(null); const [listOpen, setListOpen] = useState(false); const [chartDays, setChartDays] = useState(30); const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: api.getSettings, enabled: canRead }); const { data: skuSettingsList = [] } = useQuery({ queryKey: ['sku-settings'], queryFn: api.getSkuSettings, staleTime: 60_000, enabled: canRead }); const { data: usageHistory = [] } = useQuery({ queryKey: ['all-usage-history-forecast'], queryFn: () => api.getAllUsageHistory(5000), staleTime: 60_000, enabled: canRead }); const { data: shoppingList = [] } = useQuery({ queryKey: ['shopping-list'], queryFn: api.getShoppingList, staleTime: 30_000, enabled: canRead }); const globalLeadTime = settings?.forecast_global_lead_time_days ?? 0; const settingsMap = useMemo(() => { const m = new Map(); for (const s of skuSettingsList) m.set(skuKey(s.material, s.subtype, s.brand), s); return m; }, [skuSettingsList]); const usageBySpoolId = useMemo(() => { const m = new Map(); for (const r of usageHistory) { const arr = m.get(r.spool_id) ?? []; arr.push(r); m.set(r.spool_id, arr); } return m; }, [usageHistory]); const groups = useMemo((): SkuGroup[] => { const map = new Map(); for (const spool of spools) { if (spool.archived_at) continue; const key = skuKey(spool.material, spool.subtype, spool.brand); const g = map.get(key) ?? { key, material: spool.material, subtype: spool.subtype, brand: spool.brand, spools: [] }; g.spools.push(spool); map.set(key, g); } return [...map.values()]; }, [spools]); const forecasts = useMemo((): SkuForecast[] => { const today = new Date(); today.setHours(0, 0, 0, 0); return groups.map((group): SkuForecast => { const skuSettings = settingsMap.get(group.key) ?? null; const skuLeadTime = skuSettings?.lead_time_days ?? 0; const effectiveLeadTimeDays = Math.max(globalLeadTime, skuLeadTime); const marginValue = skuSettings?.safety_margin_value ?? 14; const marginUnit = skuSettings?.safety_margin_unit ?? 'days'; const totalRemainingG = group.spools.reduce((s, sp) => s + Math.max(0, sp.label_weight - sp.weight_used), 0); const totalLabelG = group.spools.reduce((s, sp) => s + sp.label_weight, 0); // Consumed since baseline (post-reset); see InventoryPage stats calc (#1390). const totalUsedG = group.spools.reduce((s, sp) => s + Math.max(0, sp.weight_used - (sp.weight_used_baseline ?? 0)), 0); const groupHistory: SpoolUsageRecord[] = []; for (const s of group.spools) groupHistory.push(...(usageBySpoolId.get(s.id) ?? [])); let dailyRateG: number | null = null; let dailyRateStdDev: number | null = null; let rateTier: SkuForecast['rateTier'] = 'none'; const histResult = computeHistoryRate(groupHistory); if (histResult !== null) { dailyRateG = histResult.rate; dailyRateStdDev = histResult.stdDev; rateTier = 'history'; } else { const delta = computeDeltaRate(group.spools); if (delta !== null) { dailyRateG = delta; rateTier = 'delta'; } } const σ = dailyRateStdDev ?? (dailyRateG !== null ? dailyRateG * 0.2 : 0); const statisticalSafetyStockG = Z_95 * σ * Math.sqrt(effectiveLeadTimeDays); // safety margin: user-defined buffer on top of statistical safety stock const safetyMarginG = marginUnit === 'g' ? marginValue : (dailyRateG !== null ? dailyRateG * marginValue : marginValue * 5); const safetyStockG = statisticalSafetyStockG + safetyMarginG; const reorderPointG = dailyRateG !== null ? dailyRateG * effectiveLeadTimeDays + safetyStockG : 0; const daysRemaining = dailyRateG && dailyRateG > 0 ? Math.floor(totalRemainingG / dailyRateG) : null; const projectedEmptyDate = daysRemaining !== null ? addDays(today, daysRemaining) : null; const daysUntilROP = dailyRateG && dailyRateG > 0 ? Math.floor((totalRemainingG - reorderPointG) / dailyRateG) : null; const reorderTriggerDate = daysUntilROP !== null ? addDays(today, Math.max(0, daysUntilROP)) : null; const stockBreakAlert = daysRemaining !== null && effectiveLeadTimeDays > 0 && daysRemaining <= effectiveLeadTimeDays; const reorderAlert = !stockBreakAlert && daysUntilROP !== null && daysUntilROP <= 0; return { group, settings: skuSettings, totalRemainingG, totalLabelG, totalSpools: group.spools.length, totalUsedG, dailyRateG, dailyRateStdDev, rateTier, effectiveLeadTimeDays, safetyStockG, reorderPointG, daysRemaining, daysUntilROP, projectedEmptyDate, reorderTriggerDate, reorderAlert, stockBreakAlert, }; }); }, [groups, settingsMap, usageBySpoolId, globalLeadTime]); const sortedForecasts = useMemo(() => { const arr = [...forecasts]; arr.sort((a, b) => { let va: number | string = 0; let vb: number | string = 0; switch (sortKey) { case 'material': va = [a.group.material, a.group.subtype ?? '', a.group.brand ?? ''].join(' ').toLowerCase(); vb = [b.group.material, b.group.subtype ?? '', b.group.brand ?? ''].join(' ').toLowerCase(); break; case 'used': va = a.totalUsedG; vb = b.totalUsedG; break; case 'days_left': va = a.daysRemaining ?? 999999; vb = b.daysRemaining ?? 999999; break; case 'stock': va = a.totalRemainingG; vb = b.totalRemainingG; break; } const cmp = va < vb ? -1 : va > vb ? 1 : 0; return sortDir === 'asc' ? cmp : -cmp; }); return arr; }, [forecasts, sortKey, sortDir]); const alerts = useMemo(() => forecasts.filter((f) => !f.settings?.alerts_snoozed && (f.stockBreakAlert || f.reorderAlert)), [forecasts]); const top5 = useMemo(() => [...forecasts] .filter((f) => f.dailyRateG !== null) .sort((a, b) => b.totalUsedG - a.totalUsedG) .slice(0, 5), [forecasts] ); // ── Read permission guard — all hooks above this point ────────────────────── if (!canRead) { return (

{t('forecast.noReadAccess')}

); } function handleSort(key: SortKey) { if (sortKey === key) setSortDir((d) => d === 'asc' ? 'desc' : 'asc'); else { setSortKey(key); setSortDir(key === 'days_left' ? 'asc' : 'desc'); } } const shoppingListBadge = shoppingList.length > 0 ? shoppingList.length : null; return (
{/* ── Toolbar ── */}
{/* Alert button */} {alerts.length > 0 && ( )} {/* Global lead time */} {canWrite && ( { api.updateSettings({ forecast_global_lead_time_days: v }).then(() => { queryClient.invalidateQueries({ queryKey: ['settings'] }); showToast(t('forecast.globalLeadTimeSaved'), 'success'); }); }} /> )} {/* Shopping list toggle */}
{/* ── Collapsed alerts panel ── */} {alertsOpen && alerts.length > 0 && (
{alerts.map((f) => ( setCartModal(f)} /> ))}
)} {/* ── Shopping list panel ── */} {listOpen && ( setListOpen(false)} onRemove={(id) => { api.removeFromShoppingList(id) .then(() => queryClient.invalidateQueries({ queryKey: ['shopping-list'] })) .catch(() => showToast(t('forecast.failedSaveSettings'), 'error')); }} onClear={() => { api.clearShoppingList() .then(() => queryClient.invalidateQueries({ queryKey: ['shopping-list'] })) .catch(() => showToast(t('forecast.failedSaveSettings'), 'error')); }} /> )} {/* ── Usage + projection chart ── */} {top5.length > 0 && } {/* ── Table ── */} {forecasts.length === 0 ? (

{t('forecast.noSpools')}

) : (
{/* Color dot */} {/* Actions */} {sortedForecasts.map((f) => ( queryClient.invalidateQueries({ queryKey: ['sku-settings'] })} onCart={() => setCartModal(f)} showToast={showToast} /> ))}
{t('forecast.sku')} {t('forecast.stock')} {t('forecast.dailyRate')} {t('forecast.daysLeft')} {t('forecast.emptyBy')} {t('forecast.reorderBy')}
{/* Legend */}
{t('forecast.trendLegend')} {t('forecast.estimatedLegend')} {t('forecast.noDataLegend')}
)} {/* ── Add to cart modal ── */} {cartModal && ( setCartModal(null)} onAdd={(item) => { api.addToShoppingList(item).then(() => { queryClient.invalidateQueries({ queryKey: ['shopping-list'] }); showToast(t('forecast.addedToCart'), 'success'); setCartModal(null); setListOpen(true); }).catch(() => showToast(t('forecast.failedAddItem'), 'error')); }} /> )}
); } // ── Sortable th ─────────────────────────────────────────────────────────────── function SortableTh({ col, active, dir, onSort, children, }: { col: SortKey; active: SortKey; dir: SortDir; onSort: (k: SortKey) => void; children: React.ReactNode; }) { const isActive = active === col; return ( onSort(col)} > {children} {isActive ? dir === 'asc' ? : : } ); } // ── Alert Banner ────────────────────────────────────────────────────────────── function AlertBanner({ forecast: f, onCart }: { forecast: SkuForecast; onCart: () => void }) { const { t } = useTranslation(); const label = [f.group.brand, f.group.material, f.group.subtype].filter(Boolean).join(' '); const isBreak = f.stockBreakAlert; return (
{label} {isBreak ? ( {t('forecast.stockBreakRisk')} — {t('forecast.stockBreakDetail', { days: f.daysRemaining, lt: f.effectiveLeadTimeDays })} ) : ( {t('forecast.reorderNow')} — {t('forecast.reorderTriggerPassed', { date: f.reorderTriggerDate ? formatDate(f.reorderTriggerDate) : '—' })} )}
); } // ── Usage + Projection Chart ────────────────────────────────────────────────── const CHART_TIMEFRAMES: { label: string; value: ChartDays }[] = [ { label: '1W', value: 7 }, { label: '1M', value: 30 }, { label: '6M', value: 180 }, ]; function UsageChart({ forecasts, days: maxDays, onDaysChange }: { forecasts: SkuForecast[]; days: ChartDays; onDaysChange: (d: ChartDays) => void; }) { const { t } = useTranslation(); const days = Array.from({ length: maxDays + 1 }, (_, i) => i); const series = forecasts.map((f, idx) => ({ key: f.group.key, label: [f.group.brand, f.group.material, f.group.subtype].filter(Boolean).join(' '), color: CHART_COLORS[idx % CHART_COLORS.length], rop: f.reorderPointG, points: buildProjectionSeries(f, maxDays), })); const chartData = days.map((d) => { const row: Record = { day: d, label: formatDateShort(addDays(new Date(), d)) }; for (const s of series) { const pt = s.points.find((p) => p.day === d); row[s.key] = pt?.stock ?? 0; } return row; }); const lastNonZeroDay = (() => { for (let d = maxDays; d >= 0; d--) { if (series.some((s) => (chartData[d]?.[s.key] as number) > 0)) return d; } return maxDays; })(); const trimmedData = chartData.slice(0, lastNonZeroDay + 1); const ropLines = series.filter((s) => s.rop > 0); return (

{t('forecast.chartTitle')}

{t('forecast.dashedLinesROP')}
{CHART_TIMEFRAMES.map((tf) => ( ))}
{series.map((s) => ( ))} v >= 1000 ? `${(v / 1000).toFixed(1)}kg` : `${v}g`} width={48} /> { if (typeof value !== 'number') return ''; const s = series.find((x) => x.key === String(name)); return `${value}g — ${s?.label ?? name}`; }} /> { const s = series.find((x) => x.key === value); return {s?.label ?? value}; }} /> {series.map((s) => ( ))} {ropLines.map((s) => ( ))}
); } // ── Global lead time setting (compact inline) ───────────────────────────────── function GlobalLeadTimeSetting({ value, onSave }: { value: number; onSave: (v: number) => void }) { const { t } = useTranslation(); const [editing, setEditing] = useState(false); const [input, setInput] = useState(String(value)); function save() { const v = parseInt(input, 10); if (isNaN(v) || v < 0) return; onSave(v); setEditing(false); } return (
{t('forecast.globalLeadTime')}: {editing ? (
{ e.preventDefault(); save(); }}> setInput(e.target.value)} className="w-14 px-1.5 py-0.5 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-sm text-white focus:outline-none focus:border-bambu-green" autoFocus /> d
) : (
{value}d
)}
); } // ── Forecast Row ────────────────────────────────────────────────────────────── function ForecastRow({ forecast: f, globalLeadTime, canWrite, onSaved, onCart, showToast, }: { forecast: SkuForecast; globalLeadTime: number; canWrite: boolean; onSaved: () => void; onCart: () => void; showToast: (msg: string, type: 'success' | 'error') => void; }) { const { t } = useTranslation(); const [expanded, setExpanded] = useState(false); const [editingLead, setEditingLead] = useState(false); const [editingMargin, setEditingMargin] = useState(false); const [leadInput, setLeadInput] = useState(String(f.settings?.lead_time_days ?? 0)); const [marginInput, setMarginInput] = useState(String(f.settings?.safety_margin_value ?? 14)); const [marginUnit, setMarginUnit] = useState<'days' | 'g'>(f.settings?.safety_margin_unit ?? 'days'); // Sync inputs when remote settings change and the field is not actively being edited. useEffect(() => { if (!editingLead) setLeadInput(String(f.settings?.lead_time_days ?? 0)); }, [f.settings?.lead_time_days, editingLead]); useEffect(() => { if (!editingMargin) { setMarginInput(String(f.settings?.safety_margin_value ?? 14)); setMarginUnit(f.settings?.safety_margin_unit ?? 'days'); } }, [f.settings?.safety_margin_value, f.settings?.safety_margin_unit, editingMargin]); const upsertMutation = useMutation({ mutationFn: api.upsertSkuSettings, onSuccess: () => { onSaved(); showToast(t('forecast.settingsSaved'), 'success'); }, onError: () => showToast(t('forecast.failedSaveSettings'), 'error'), }); const snoozed = f.settings?.alerts_snoozed ?? false; const label = [f.group.brand, f.group.material, f.group.subtype].filter(Boolean).join(' '); // Use getSwatchStyle so a Clear (alpha=00) lead spool renders as a // checkerboard rather than collapsing to solid black (#1545). const colorStyle = f.group.spools[0]?.rgba ? getSwatchStyle(f.group.spools[0].rgba) : { backgroundColor: '#4B5563' }; const remainPct = f.totalLabelG > 0 ? Math.round((f.totalRemainingG / f.totalLabelG) * 100) : 0; const daysColor = snoozed ? 'text-bambu-gray' : f.daysRemaining === null ? 'text-bambu-gray' : f.stockBreakAlert ? 'text-red-400' : f.reorderAlert ? 'text-yellow-400' : f.daysRemaining < 30 ? 'text-yellow-400' : 'text-green-400'; function upsert(lead: number, marginVal: number, marginUnitArg: 'days' | 'g', alertsSnoozed = snoozed) { upsertMutation.mutate({ material: f.group.material, subtype: f.group.subtype, brand: f.group.brand, lead_time_days: lead, safety_margin_value: marginVal, safety_margin_unit: marginUnitArg, alerts_snoozed: alertsSnoozed }); } function toggleSnooze(e: React.MouseEvent) { e.stopPropagation(); upsert(f.settings?.lead_time_days ?? 0, f.settings?.safety_margin_value ?? 14, f.settings?.safety_margin_unit ?? 'days', !snoozed); } const tierBadge = f.rateTier === 'history' ? {t('forecast.trend')} : f.rateTier === 'delta' ? {t('forecast.estimated')} : {t('forecast.noData')}; const rowAlertBorder = snoozed ? '' : f.stockBreakAlert ? 'bg-red-500/5' : f.reorderAlert ? 'bg-yellow-500/5' : ''; return ( <> setExpanded((e) => !e)} > {/* Color dot */} {/* SKU */}
{label}
{t('forecast.spoolCount', { count: f.totalSpools })}
{/* Stock */}
50 ? 'bg-bambu-green' : remainPct > 20 ? 'bg-yellow-500' : 'bg-red-500'}`} style={{ width: `${Math.min(remainPct, 100)}%` }} />
{Math.round(f.totalRemainingG)}g {/* Rate */}
{f.dailyRateG !== null ? `${f.dailyRateG.toFixed(1)}g/d` : '—'}
{tierBadge}
{/* Days left */} {f.daysRemaining !== null ? `${f.daysRemaining}d` : } {/* Empty by */} {f.projectedEmptyDate ? formatDate(f.projectedEmptyDate) : '—'} {/* Reorder by */} {f.reorderTriggerDate ? formatDate(f.reorderTriggerDate) : '—'} {/* Actions */} e.stopPropagation()}>
{canWrite && ( )} {!snoozed && (f.stockBreakAlert ? ( ) : f.reorderAlert ? ( ) : f.daysRemaining !== null ? ( ) : null)} {canWrite && ( )}
{/* ── Expanded detail row ── */} {expanded && (
{/* Logistics summary */}
{/* Per-SKU settings — write-gated */} {canWrite && (
{ setLeadInput(String(f.settings?.lead_time_days ?? 0)); setEditingLead(true); }} onSave={() => { const v = parseInt(leadInput, 10); if (!isNaN(v) && v >= 0) { upsert(v, f.settings?.safety_margin_value ?? 14, marginUnit); setEditingLead(false); } }} onCancel={() => setEditingLead(false)} isPending={upsertMutation.isPending} saveLabel={t('forecast.save')} cancelLabel={t('forecast.cancel')} /> setMarginUnit(u)} onEdit={() => { setMarginInput(String(f.settings?.safety_margin_value ?? 14)); setMarginUnit(f.settings?.safety_margin_unit ?? 'days'); setEditingMargin(true); }} onSave={() => { const v = parseInt(marginInput, 10); if (!isNaN(v) && v >= 0) { upsert(f.settings?.lead_time_days ?? 0, v, marginUnit); setEditingMargin(false); } }} onCancel={() => setEditingMargin(false)} isPending={upsertMutation.isPending} saveLabel={t('forecast.save')} cancelLabel={t('forecast.cancel')} safetyMarginLabel={t('forecast.safetyMarginLabel')} />
)} {/* Individual spools — shown when group has >1 spool */} {f.group.spools.length > 1 && (

{t('forecast.individualSpools')}

{f.group.spools.map((s) => { const remaining = Math.max(0, s.label_weight - s.weight_used); const pct = s.label_weight > 0 ? (remaining / s.label_weight) * 100 : 0; return ( ); })}
# {t('inventory.remaining')} {t('inventory.used')} {t('forecast.labelWeight')}
#{s.id}
50 ? 'bg-bambu-green' : pct > 20 ? 'bg-yellow-500' : 'bg-red-500'}`} style={{ width: `${Math.min(pct, 100)}%` }} />
{Math.round(remaining)}g
{Math.round(Math.max(0, s.weight_used - (s.weight_used_baseline ?? 0)))}g {s.label_weight}g
)}
)} ); } // ── Logistic stat chip ──────────────────────────────────────────────────────── function LogisticStat({ label, value, hint }: { label: string; value: string; hint: string }) { return (
{label}
{value}
); } // ── Setting field ───────────────────────────────────────────────────────────── function SettingField({ label, hint, unit, editing, value, inputValue, onInputChange, onEdit, onSave, onCancel, isPending, saveLabel = 'Save', cancelLabel = 'Cancel', }: { label: string; hint: string; unit: string; editing: boolean; value: number; inputValue: string; onInputChange: (v: string) => void; onEdit: () => void; onSave: () => void; onCancel: () => void; isPending: boolean; saveLabel?: string; cancelLabel?: string; }) { return (
{label}
{editing ? (
{ e.preventDefault(); onSave(); }}> onInputChange(e.target.value)} className="w-20 px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-sm text-white focus:outline-none focus:border-bambu-green" autoFocus disabled={isPending} /> {unit}
) : (
{value} {unit}
)}
); } // ── Safety margin field (dual unit: days | grams) ──────────────────────────── function SafetyMarginField({ value, unit, editing, inputValue, dailyRateG, onInputChange, onUnitChange, onEdit, onSave, onCancel, isPending, saveLabel = 'Save', cancelLabel = 'Cancel', safetyMarginLabel = 'Safety Margin', }: { value: number; unit: 'days' | 'g'; editing: boolean; inputValue: string; dailyRateG: number | null; onInputChange: (v: string) => void; onUnitChange: (u: 'days' | 'g') => void; onEdit: () => void; onSave: () => void; onCancel: () => void; isPending: boolean; saveLabel?: string; cancelLabel?: string; safetyMarginLabel?: string; }) { const { t } = useTranslation(); const displayG = unit === 'g' ? value : (dailyRateG !== null ? Math.round(dailyRateG * value) : null); const hint = unit === 'days' ? t('forecast.safetyMarginHintDays', { approx: displayG !== null ? t('forecast.safetyMarginHintDaysApprox', { g: displayG }) : '', }) : t('forecast.safetyMarginHintG', { approx: dailyRateG !== null ? t('forecast.safetyMarginHintGApprox', { days: Math.round(value / dailyRateG) }) : '', }); return (
{safetyMarginLabel}
{editing ? (
{ e.preventDefault(); onSave(); }}> onInputChange(e.target.value)} className="w-20 px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-sm text-white focus:outline-none focus:border-bambu-green" autoFocus disabled={isPending} /> {/* Unit toggle */}
) : (
{value} {unit} {displayG !== null && unit === 'days' && ( ≈ {displayG}g )} {unit === 'g' && dailyRateG !== null && ( ≈ {Math.round(value / dailyRateG)}d )}
)}
); } // ── Shopping list panel ─────────────────────────────────────────────────────── function ShoppingListPanel({ items, forecasts, globalLeadTime, canWrite, onClose, onRemove, onClear, }: { items: ShoppingListItem[]; forecasts: SkuForecast[]; globalLeadTime: number; canWrite: boolean; onClose: () => void; onRemove: (id: number) => void; onClear: () => void; }) { const { t } = useTranslation(); const queryClient = useQueryClient(); const [view, setView] = useState<'list' | 'logistics'>('list'); const statusMutation = useMutation({ mutationFn: async ({ id, status, item, avgSpoolG }: { id: number; status: 'pending' | 'purchased' | 'received'; item?: ShoppingListItem; avgSpoolG?: number; }) => { await api.updateShoppingListStatus(id, status); if (status === 'received' && item) { // Add received spools to stock category const spoolWeight = avgSpoolG ?? 1000; const spoolBase: Parameters[0] = { material: item.material, subtype: item.subtype, brand: item.brand, label_weight: spoolWeight, core_weight: 0, core_weight_catalog_id: null, color_name: null, rgba: null, extra_colors: null, effect_type: null, nozzle_temp_min: null, nozzle_temp_max: null, note: item.note ?? null, tag_uid: null, tray_uuid: null, data_origin: 'manual', tag_type: null, cost_per_kg: null, last_scale_weight: null, last_weighed_at: null, weight_used: 0, slicer_filament: null, slicer_filament_name: null, added_full: null, last_used: null, encode_time: null, category: 'Stock', low_stock_threshold_pct: null, }; await api.bulkCreateSpools(spoolBase, item.quantity_spools); await api.removeFromShoppingList(id); } }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['shopping-list'] }); queryClient.invalidateQueries({ queryKey: ['spools'] }); }, }); // Build a forecast lookup keyed by (material||subtype||brand) const forecastMap = useMemo(() => { const m = new Map(); for (const f of forecasts) m.set(f.group.key, f); return m; }, [forecasts]); // Resolve a forecast for each cart item const cartForecasts = useMemo(() => items.map((item) => ({ item, forecast: forecastMap.get(skuKey(item.material, item.subtype, item.brand)) ?? null, })), [items, forecastMap] ); // Items where stock break before replenishment is detected const breakAlerts = useMemo(() => cartForecasts.filter(({ forecast: f }) => { if (!f || f.dailyRateG === null) return false; // Stock runs out before the lead time window ends return f.stockBreakAlert || (f.daysRemaining !== null && f.daysRemaining <= f.effectiveLeadTimeDays); }), [cartForecasts] ); function downloadCsv() { const headers = [t('forecast.qty'), t('forecast.material'), 'Brand', 'Subtype', `${t('forecast.weight')} (g)`, `${t('forecast.leadTime')} (d)`, t('forecast.expectedRestock'), t('forecast.status'), t('forecast.note')]; const rows = items.map((i) => { const f = forecastMap.get(skuKey(i.material, i.subtype, i.brand)) ?? null; const avgSpoolG = f && f.totalSpools > 0 ? f.totalLabelG / f.totalSpools : 1000; const totalWeightG = Math.round(i.quantity_spools * avgSpoolG); const lt = f?.effectiveLeadTimeDays ?? globalLeadTime ?? 0; const restock = lt > 0 ? formatDate(addDays(new Date(), lt)) : ''; return [ i.quantity_spools, i.material, i.brand ?? '', i.subtype ?? '', totalWeightG, lt || '', restock, i.status, i.note ?? '', ].map((v) => `"${String(v).replace(/"/g, '""')}"`).join(','); }); const csv = [headers.join(','), ...rows].join('\n'); const blob = new Blob([csv], { type: 'text/csv' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `shopping-list-${new Date().toISOString().slice(0, 10)}.csv`; a.click(); setTimeout(() => URL.revokeObjectURL(url), 100); } return (
{/* Header */}

{t('forecast.shoppingList')}

{t('forecast.shoppingListItems', { count: items.length })} {/* View toggle */} {items.length > 0 && (
)}
{items.length > 0 && ( <> {canWrite && ( )} )}
{items.length === 0 ? (

{t('forecast.shoppingListEmpty')}

) : view === 'list' ? (
{items.map((item) => { const lbl = [item.brand, item.material, item.subtype].filter(Boolean).join(' '); const hasBreak = breakAlerts.some((a) => a.item.id === item.id); const f = forecastMap.get(skuKey(item.material, item.subtype, item.brand)) ?? null; const avgSpoolG = f && f.totalSpools > 0 ? f.totalLabelG / f.totalSpools : 1000; const totalWeightG = Math.round(item.quantity_spools * avgSpoolG); const lt = f?.effectiveLeadTimeDays ?? globalLeadTime ?? 0; const restockDate = lt > 0 ? addDays(new Date(), lt) : null; const isPurchased = item.status === 'purchased' || item.status === 'received'; const isReceived = item.status === 'received'; const isMutating = statusMutation.isPending; return ( {/* Qty */} {/* Material */} {/* Weight */} {/* Lead time */} {/* Expected restock */} {/* Status badge — read-only */} {/* Note */} {/* Actions */} ); })}
{t('forecast.qty')} {t('forecast.material')} {t('forecast.weight')} {t('forecast.leadTime')} {t('forecast.expectedRestock')} {t('forecast.status')} {t('forecast.note')} {t('forecast.actions')}
{item.quantity_spools}×
{lbl} {hasBreak && !isPurchased && ( )}
{totalWeightG >= 1000 ? `${(totalWeightG / 1000).toFixed(1)}kg` : `${totalWeightG}g`} {lt > 0 ? `${lt}d` : '—'} {restockDate ? formatDate(restockDate) : '—'} {isReceived ? t('forecast.received') : isPurchased ? t('forecast.purchased') : t('forecast.pending')} {item.note || '—'}
{canWrite && ( <> {/* Purchased icon — available when pending */} {/* Received icon — available only after purchasing */} {/* Delete */} )}
) : ( /* Logistics view — exclude received items */
{cartForecasts.filter(({ item }) => item.status !== 'received').map(({ item, forecast }) => ( onRemove(item.id)} /> ))}
)}
); } // ── Cart logistics row ──────────────────────────────────────────────────────── function CartLogisticsRow({ item, forecast: f, globalLeadTime, canWrite, onRemove, }: { item: ShoppingListItem; forecast: SkuForecast | null; globalLeadTime: number; canWrite: boolean; onRemove: () => void; }) { const { t } = useTranslation(); const label = [item.brand, item.material, item.subtype].filter(Boolean).join(' '); // Build a timeline showing stock depletion, arrival bump, then post-arrival depletion. // Two points are inserted at day `lt` (just-before and just-after arrival) so the // chart shows a clean vertical step rather than a smooth interpolated slope. const chartData = useMemo(() => { if (!f || f.dailyRateG === null || f.dailyRateG <= 0) return null; const rate = f.dailyRateG; const lt = f.effectiveLeadTimeDays; const avgSpoolG = f.totalSpools > 0 ? f.totalLabelG / f.totalSpools : 1000; const arrivalG = item.quantity_spools * avgSpoolG; const stockAtArrival = Math.max(0, f.totalRemainingG - rate * lt); const peakG = stockAtArrival + arrivalG; const daysPostArrival = Math.ceil(peakG / rate); const clampedMax = Math.min(lt + daysPostArrival + 5, 365); type Point = { day: number; label: string; stock: number; rop: number; safetyStock: number; arrival?: boolean }; const points: Point[] = []; for (let d = 0; d <= clampedMax; d++) { const dateLabel = formatDateShort(addDays(new Date(), d)); if (d === lt) { // Just before arrival — pre-bump stock level points.push({ day: d, label: dateLabel, stock: Math.round(stockAtArrival), rop: Math.round(f.reorderPointG), safetyStock: Math.round(f.safetyStockG) }); // Just after arrival — post-bump peak (same x label, creates the vertical step) points.push({ day: d, label: dateLabel, stock: Math.round(peakG), rop: Math.round(f.reorderPointG), safetyStock: Math.round(f.safetyStockG), arrival: true }); } else { const stock = d < lt ? Math.max(0, f.totalRemainingG - rate * d) : Math.max(0, peakG - rate * (d - lt)); points.push({ day: d, label: dateLabel, stock: Math.round(stock), rop: Math.round(f.reorderPointG), safetyStock: Math.round(f.safetyStockG) }); } } return { points, lt, maxDays: clampedMax, arrivalG, peakG, stockAtArrival }; }, [f, item.quantity_spools]); // Determine break scenario: stock hits zero before arrival const stockBreaksAt = useMemo(() => { if (!f || f.dailyRateG === null || f.dailyRateG <= 0) return null; const zeroDay = Math.floor(f.totalRemainingG / f.dailyRateG); if (zeroDay < f.effectiveLeadTimeDays) return zeroDay; return null; }, [f]); const hasBreak = stockBreaksAt !== null; return (
{/* Row header */}
{hasBreak ? : } {label} {t('forecast.spoolCount', { count: item.quantity_spools })} ordered
{canWrite && ( )}
{/* Break alert */} {hasBreak && (
{t('forecast.stockBreakIn', { days: stockBreaksAt })} {' '}{t('forecast.stockRunsOutBefore', { lt: f!.effectiveLeadTimeDays })} {f!.dailyRateG !== null && ( {t('forecast.atRate', { rate: f!.dailyRateG.toFixed(1) })}{' '} {t('forecast.moreSpools', { count: Math.ceil((f!.dailyRateG * f!.effectiveLeadTimeDays - f!.totalRemainingG) / ((f!.totalLabelG / (f!.totalSpools || 1)) || 1000)) })} {' '}{t('forecast.bridgeGap')} )}
)} {/* No forecast data */} {(!f || f.dailyRateG === null) ? (
{t('forecast.noUsageData')}
) : chartData ? ( <> {/* Key stats row */}
{t('forecast.stock')}
{Math.round(f.totalRemainingG)}g
{t('forecast.leadTime')}
{f.effectiveLeadTimeDays}d
max(g:{globalLeadTime}, sku:{f.settings?.lead_time_days ?? 0})
{t('forecast.safetyMarginLabel')}
{Math.round(f.safetyStockG)}g
{t('forecast.daysLeft')}
{f.daysRemaining ?? '—'}d
{chartData && (
{t('forecast.onArrival')}
{Math.round(chartData.arrivalG)}g
+{t('forecast.spoolCount', { count: item.quantity_spools })}
)}
{/* Chart */} {/* Pre-arrival fill: red if break, amber if tight, green if ok */} {/* Post-arrival fill: always green */} v >= 1000 ? `${(v / 1000).toFixed(1)}kg` : `${v}g`} width={44} /> { if (typeof value !== 'number') return ''; if (name === 'stock') return `${value}g — ${t('forecast.stock')}`; if (name === 'rop') return `${value}g — ${t('forecast.reorderPoint')}`; if (name === 'safetyStock') return `${value}g — ${t('forecast.safetyMarginLabel')}`; return `${value}`; }} /> {/* Single stock area — linear interpolation renders the vertical step correctly because the two duplicate-label points at arrival day create an instant jump */} {/* Reorder point */} {f.reorderPointG > 0 && ( )} {/* Safety stock floor */} {f.safetyStockG > 0 && ( )} {/* Arrival / lead-time-end vertical line */} = 1000 ? `${(chartData.arrivalG / 1000).toFixed(1)}kg` : `${Math.round(chartData.arrivalG)}g`} arrives (d${chartData.lt})`, position: 'insideTopLeft', fill: '#3B82F6', fontSize: 9 }} /> {/* Stock break — only shown when stock hits zero before arrival */} {stockBreaksAt !== null && ( )} {/* Legend */}
{t('forecast.ropLabel')} {t('forecast.safetyStockLegend')} {t('forecast.stockArrivalLegend')} {hasBreak && {t('forecast.stockoutLegend')}}
) : null}
); } // ── Add to Cart Modal ───────────────────────────────────────────────────────── function AddToCartModal({ forecast: f, onClose, onAdd, }: { forecast: SkuForecast; onClose: () => void; onAdd: (item: { material: string; subtype: string | null; brand: string | null; quantity_spools: number; note: string | null }) => void; }) { const { t } = useTranslation(); const label = [f.group.brand, f.group.material, f.group.subtype].filter(Boolean).join(' '); const [mode, setMode] = useState<'qty' | 'duration'>('qty'); const [qty, setQty] = useState('1'); const [durationDays, setDurationDays] = useState('30'); const [note, setNote] = useState(''); const spoolsForDuration = useMemo(() => { if (!f.dailyRateG || f.dailyRateG <= 0) return null; const neededG = f.dailyRateG * Number(durationDays); const avgSpoolG = f.group.spools.length > 0 ? f.group.spools.reduce((s, sp) => s + sp.label_weight, 0) / f.group.spools.length : 1000; return Math.ceil(neededG / avgSpoolG); }, [f, durationDays]); const finalQty = mode === 'qty' ? parseInt(qty, 10) || 1 : (spoolsForDuration ?? 1); function submit(e: React.FormEvent) { e.preventDefault(); onAdd({ material: f.group.material, subtype: f.group.subtype, brand: f.group.brand, quantity_spools: finalQty, note: note || null }); } return (

{t('forecast.addToCartTitle')}

{label}
{mode === 'qty' ? (
setQty(e.target.value)} className="w-full px-3 py-2 bg-bambu-dark-tertiary border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green" autoFocus />
) : (
setDurationDays(e.target.value)} className="w-full px-3 py-2 bg-bambu-dark-tertiary border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green" autoFocus />
{f.dailyRateG !== null ? (
{t('forecast.spoolCount', { count: spoolsForDuration ?? 0 })} at {f.dailyRateG.toFixed(1)}g/day
) : (
{t('forecast.noUsageQty')}
)}
)}
setNote(e.target.value)} placeholder={t('forecast.notePlaceholder')} className="w-full px-3 py-2 bg-bambu-dark-tertiary border border-bambu-dark-tertiary rounded-lg text-white text-sm placeholder:text-bambu-gray/40 focus:outline-none focus:border-bambu-green" />
); } // ── Column headers (re-exported for InventoryPage) ──────────────────────────── export function ForecastColumnHeaders() { return null; }