// Page 2 — Per-product detail. Shows yesterday/today KPIs (sales, requisitions, stock), // a 30-day-default historical chart with forecast overlay, and the order modal. // // Re-queries /api/product-detail whenever the date range changes. const { useState, useMemo, useEffect, useCallback } = React; const fmtDate = (d) => d.toISOString().slice(0, 10); const ProductDetailView = ({ M, product, branch, onBack, orderedToday, pendingMap, onPendingChange, upcomingFestivals, tweaks, onTweakChange, }) => { const run_date = M.run_date; const run_date_obj = new Date(run_date); const moq = product?.master?.minimumOrderQuantity || 0; const submitted = orderedToday.has(product.name); const pendingQty = pendingMap[product.name] || 0; // Date range: default = last 30 days through run_date (inclusive) const startDefault = new Date(run_date_obj); startDefault.setDate(startDefault.getDate() - 30); const [dateRange, setDateRange] = useState({ start: fmtDate(startDefault), end: run_date, }); // Detail data fetched per (product, branch, dateRange) const [detail, setDetail] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { let cancelled = false; setLoading(true); setError(null); window.fetchProductDetail({ product: product.name, branch, start: dateRange.start, end: dateRange.end, }) .then(d => { if (!cancelled) setDetail(d); }) .catch(e => { if (!cancelled) setError(e.message); }) .finally(() => { if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; }, [product.name, branch, dateRange.start, dateRange.end]); // Series toggles + confidence selection. `bandSelection` is an array of // keys like ["upper_80", "lower_60"] — one per checkbox the user has on. // The chart pairs same-level upper+lower into a single coloured band; the // rest get their own colours. const [series, setSeries] = useState({ forecast: true, actual: true, inventory: true, requisitions: true }); const [bandSelection, setBandSelection] = useState([]); const bandLevels = M.bandLevels || { upper: [], lower: [] }; // ---- Build date-indexed chart data from server response ---- const chartData = useMemo(() => { if (!detail) return []; const salesByDate = {}; detail.sales.forEach(s => { salesByDate[s.date] = s.quantity; }); const invByDate = {}; detail.inventory.forEach(s => { invByDate[s.date] = s.inventory; }); const reqByDate = {}; detail.requisitions.forEach(s => { reqByDate[s.date] = s.quantity; }); const fcByDate = {}; const recentRun = detail.forecasts[0]?.run_date; detail.forecasts.forEach(f => { if (f.run_date !== recentRun) return; fcByDate[f.date] = f; }); // Walk every day in [start, end] — the date picker is the chart window. // Future forecast appears only when the user extends `end` past run_date. const allDates = []; let d = new Date(dateRange.start); const stopD = new Date(dateRange.end); while (d <= stopD) { allDates.push(fmtDate(d)); d.setDate(d.getDate() + 1); } const scenarioMult = tweaks.scenario === 'spike' ? 1.6 : tweaks.scenario === 'low' ? 0.65 : 1; const inventoryMult = tweaks.scenario === 'low' ? 0.4 : 1; // Defensive client-side floor at 0 — server already does this, but the // tweak multipliers below shouldn't be able to introduce negatives either. const clamp = (v) => Math.max(0, v); return allDates.map(ds => { const fc = fcByDate[ds]; const sales = salesByDate[ds]; const inv = invByDate[ds]; const req = reqByDate[ds]; // bands: per-row dict of every (direction, level) value the server // returned. Multiplied by scenarioMult so spike/low scenarios visually // affect the confidence shading too. const bands = {}; if (fc?.bands) { for (const k in fc.bands) { const v = fc.bands[k]; bands[k] = v != null ? Math.round(clamp(v * scenarioMult)) : null; } } return { date: ds, is_run_date: ds === run_date, isPast: ds < run_date, forecast: fc ? Math.round(clamp(fc.predicted * scenarioMult)) : null, actual: sales != null ? Math.round(clamp(sales * scenarioMult)) : null, inventory: inv != null ? Math.round(clamp(inv * inventoryMult)) : null, requisitions: req != null ? Math.round(clamp(req)) : null, bands, }; }); }, [detail, dateRange.start, tweaks.scenario, run_date]); // ---- KPIs (yesterday_date / run_date) ---- const kpis = useMemo(() => { if (!detail) return null; const yesterday_date_obj = new Date(run_date_obj); yesterday_date_obj.setDate(yesterday_date_obj.getDate() - 1); const yesterday_date = fmtDate(yesterday_date_obj); const clamp = (v) => v == null ? v : Math.max(0, v); const find = (arr, dStr) => arr.find(r => r.date === dStr); const yesterday_date_sales = clamp(find(detail.sales, yesterday_date)?.quantity ?? 0); const yesterday_date_req = clamp(find(detail.requisitions, yesterday_date)?.quantity ?? 0); const run_date_sales = clamp(find(detail.sales, run_date)?.quantity ?? 0); const run_date_stock = clamp(find(detail.inventory, run_date)?.inventory ?? null); // Today's pipeline run output (predicts the NEXT day, given the schema's // invoicedate == run_date + 1 convention). Null when today's pipeline run // hasn't been written yet. const trf = detail.today_run_forecast || null; const yrf = detail.yesterday_run_forecast || null; const run_date_pred = trf?.predicted ?? null; const run_date_upper_80 = trf?.bands?.upper_80 ?? null; const run_date_lower_80 = trf?.bands?.lower_80 ?? null; // Accuracy: strict two-step lookup, no further fallback. Driven by the // pipeline's run_date (not invoicedate) so the label reflects when the // model actually computed the metric. // 1. today's pipeline run → Accuracy under "Today" // 2. else yesterday's run → Accuracy under "Yesterday" // 3. else (older / missing) → KPI hidden entirely let run_date_accuracy = null; let run_date_accuracy_label = null; if (trf?.rolling_accuracy_30d != null) { run_date_accuracy = trf.rolling_accuracy_30d; run_date_accuracy_label = 'Today'; } else if (yrf?.rolling_accuracy_30d != null) { run_date_accuracy = yrf.rolling_accuracy_30d; run_date_accuracy_label = 'Yesterday'; } return { yesterday_date, yesterday_date_sales, yesterday_date_req, run_date_sales, run_date_stock, run_date_pred, run_date_upper_80, run_date_lower_80, run_date_accuracy, run_date_accuracy_label, }; }, [detail, run_date]); // Festivals to mark on the chart (within visible range) const chartFestivals = useMemo(() => { if (!chartData.length) return []; const startStr = chartData[0].date; const endStr = chartData[chartData.length - 1].date; return M.festivals .filter(f => f.is_festival_day === 1 && f.date >= startStr && f.date <= endStr) .map(f => { const name = M.festivalCols.find(c => f[c] === 1) || 'Festival'; return { date: f.date, name }; }) .sort((a, b) => a.date.localeCompare(b.date)); }, [chartData, M.festivals, M.festivalCols]); const isEmpty = !loading && chartData.every(d => d.forecast == null && d.actual == null && d.inventory == null && d.requisitions == null ); // CSV export const downloadCSV = () => { const headers = ['Date']; if (series.forecast) headers.push('Forecast'); const bandKeys = series.forecast ? bandSelection : []; bandKeys.forEach(k => headers.push(k.replace(/^./, c => c.toUpperCase()))); if (series.actual) headers.push('Actual'); if (series.requisitions) headers.push('Requisitions'); if (series.inventory) headers.push('Inventory'); const rows = chartData.map(d => { const r = [d.date]; if (series.forecast) r.push(d.forecast ?? ''); bandKeys.forEach(k => r.push(d.bands?.[k] ?? '')); if (series.actual) r.push(d.actual ?? ''); if (series.requisitions) r.push(d.requisitions ?? ''); if (series.inventory) r.push(d.inventory ?? ''); return r.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 = `${product.name}-${branch}-${dateRange.start}-${dateRange.end}.csv`; a.click(); URL.revokeObjectURL(url); }; // ---- Pending-order panel state ---- // Local input state lets user type freely; commit-on-Set/Enter writes to localStorage. const [qtyInput, setQtyInput] = useState(String(pendingQty || '')); // Reset the input when navigating to a different product/branch or after // an external change (e.g. user edited it on Page 1, comes back here). useEffect(() => { setQtyInput(String(pendingQty || '')); }, [pendingQty, product.name, branch]); const inputAsNum = Math.max(0, parseInt(qtyInput) || 0); const inputActual = window.roundUpToMoq ? window.roundUpToMoq(inputAsNum, moq) : (moq > 1 && inputAsNum > 0 ? Math.ceil(inputAsNum / moq) * moq : inputAsNum); const dirty = inputAsNum !== pendingQty; const commitQty = (qty) => { window.setPendingQty({ run_date, branch, product: product.name, quantity: qty }); onPendingChange(); }; const handleSet = () => { if (submitted) return; commitQty(inputAsNum); }; const handleRemove = () => { if (submitted) return; setQtyInput(''); commitQty(0); }; return (
{/* Back + breadcrumb */}
/ {product.name} · {branch}
{/* Product info strip — static attributes from products_master */}
} /> } /> } />
{/* KPI strip — 2 labeled sections (Yesterday / Today) with a vertical divider. The Accuracy KPI slots into whichever section matches the date its data is from. Section header always shows the section's canonical date. */} {(() => { const accuracy_in_today = kpis?.run_date_accuracy_label === 'Today'; const accuracy_in_yesterday = kpis?.run_date_accuracy_label === 'Yesterday'; const accuracyKpi = ( ); return (
{/* Yesterday */}
{accuracy_in_yesterday && accuracyKpi}
{/* Today */}
{accuracy_in_today && accuracyKpi}
); })()} {/* Left column (filter + chart, col-span-7) | Right column (festivals + order qty, col-span-3). Filter bar lives inside the left column so its width matches the chart. */}
{/* Filters — date / series / confidence (Order Qty + CSV moved out) */}

Daily Forecast vs Actuals

{new Date(dateRange.start).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })} {' - '} {new Date(dateRange.end).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })} · {product.name} · {branch}

{loading ? (
Loading…
) : error ? (

Failed to load

{error}

) : isEmpty ? (

No data for this product / branch / range

Try widening the date range.

) : ( )}
{/* Right sidebar — Festivals expands (flex-1) to fill the column, Order Qty sits at the bottom. Grid stretch makes the column match the left column's total height (filter + chart), so Order Qty's bottom lines up with the chart's bottom. */}
Order Quantity
{submitted ? (
Sent
) : ( <>
setQtyInput(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { handleSet(); e.target.blur(); } }} placeholder="0" className="flex-1 min-w-0 px-3 py-2 text-2xl font-semibold text-right tabular-nums text-slate-900 border-0 bg-transparent focus:outline-none" />
{moq > 1 && inputAsNum > 0 && (
MOQ rounded →{' '} {inputActual.toLocaleString()}
)} {pendingQty > 0 && ( )} )}
); }; const KPI_ACCENTS = { slate: { dot: 'bg-slate-400', text: 'text-slate-700' }, green: { dot: 'bg-emerald-500', text: 'text-emerald-700' }, amber: { dot: 'bg-amber-500', text: 'text-amber-700' }, violet: { dot: 'bg-violet-500', text: 'text-violet-700' }, blue: { dot: 'bg-blue-500', text: 'text-blue-700' }, red: { dot: 'bg-red-500', text: 'text-red-700' }, }; const DetailKpi = ({ label, sub, value, accent = 'slate' }) => { const a = KPI_ACCENTS[accent] || KPI_ACCENTS.slate; return (
{label}
{value} {sub && {sub}}
); }; // Section header shown above each KPI group. The date is optional — Model // section omits it when no accuracy row was found at all. const KpiSectionHeader = ({ label, date }) => (
{label} {date && ( {new Date(date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} )}
); const MasterChip = ({ label, value, unit, icon }) => (
{icon}
{label} {value == null ? '—' : `${Math.max(0, value).toLocaleString()} ${unit}`}
); window.ProductDetailView = ProductDetailView;