// 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) */}
{/* 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. */}
);
};
// 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 }) => (