// Page 1 — Product list with inline editable quantities, plus a sticky right // sidebar with the Submit Requisition button and per-category pending counts. // // Per-row UX: // • Type a quantity, blur the field or press Enter → auto-saves to localStorage. // • If MOQ > 1 the "actual" rounded value shows next to the input. // • Remove (×) clears the entry. Empty/0 also clears. // • Once submitted today, the row freezes and shows a "Sent" badge instead. // // Click on the product name (left side) to open the per-product detail page. // The quantity input is intentionally OUTSIDE that click target. const ProductListView = ({ M, selectedBranch, onSelectBranch, orderedToday, pendingMap, onPendingChange, onOpenProduct, onSubmitAll, }) => { const [search, setSearch] = React.useState(''); const [topFilter, setTopFilter] = React.useState(''); // run_date's AI prediction per product for the current branch. Empty object if // the bootstrap response didn't carry predictions (older server / no model run). const branchPredictions = (M.predictions_run_date && M.predictions_run_date[selectedBranch]) || {}; const tops = React.useMemo( () => Array.from(new Set(M.products.map(p => p.topCategory))).sort(), [M.products] ); const filtered = React.useMemo(() => { const q = search.trim().toLowerCase(); return M.products.filter(p => { if (topFilter && p.topCategory !== topFilter) return false; if (!q) return true; return p.name.toLowerCase().includes(q) || p.lastCategory.toLowerCase().includes(q); }); }, [M.products, search, topFilter]); const grouped = React.useMemo(() => { const g = {}; filtered.forEach(p => { g[p.topCategory] = g[p.topCategory] || {}; g[p.topCategory][p.lastCategory] = g[p.topCategory][p.lastCategory] || []; g[p.topCategory][p.lastCategory].push(p); }); return g; }, [filtered]); // ---- Sidebar stats: pending count per top-category, only categories with > 0 ---- const sidebarStats = React.useMemo(() => { const totalsByTop = {}; M.products.forEach(p => { totalsByTop[p.topCategory] = (totalsByTop[p.topCategory] || 0) + 1; }); const pendingByTop = {}; M.products.forEach(p => { if ((pendingMap[p.name] || 0) > 0) { pendingByTop[p.topCategory] = (pendingByTop[p.topCategory] || 0) + 1; } }); return Object.keys(pendingByTop) .map(top => ({ top, set: pendingByTop[top], total: totalsByTop[top] || 0 })) .sort((a, b) => a.top.localeCompare(b.top)); }, [M.products, pendingMap]); const totalPending = sidebarStats.reduce((s, c) => s + c.set, 0); return (
{/* MAIN — filters + product list */}
{/* Filters */}
setSearch(e.target.value)} placeholder="Search products…" className="w-full px-3 py-2 text-sm bg-white border border-slate-200 rounded-md hover:border-slate-300 focus:outline-none focus:ring-2 focus:ring-red-500/20 focus:border-red-500 transition" />
{/* Summary */}
Showing {filtered.length} products {topFilter && <> in {topFilter}} · branch {selectedBranch} {totalPending > 0 && ( {totalPending} pending )}
{/* List */} {filtered.length === 0 ? (

No products match

Try clearing the search or category filter.

) : (
{Object.entries(grouped).map(([top, lastsObj]) => (

{top}

{Object.values(lastsObj).reduce((s, arr) => s + arr.length, 0)} products
{Object.entries(lastsObj).map(([last, prods]) => (
{last}
{prods.map(p => ( ))}
))}
))}
)}
{/* SIDEBAR — sticky submit + category counts */}
{/* Submit panel */}
Pending Requisition
{totalPending} product{totalPending === 1 ? '' : 's'}

Quantities are submitted with MOQ rounding applied. Branch: {selectedBranch}.

{/* Category breakdown */}

By Top Category

Only categories with at least one pending product.

{sidebarStats.length === 0 ? (
Nothing pending yet. Set a quantity on any product to start.
) : (
    {sidebarStats.map(({ top, set, total }) => { const pct = total > 0 ? Math.round((set / total) * 100) : 0; return (
  • {top} {set} of {total}
  • ); })}
)}
); }; // Per-row component — local input state lets the user type freely; commit // happens on blur or Enter. Empty/0 calls setPendingQty with 0 which removes // the entry from localStorage. const ProductRow = ({ product, run_date, branch, submitted, pendingQty, aiPrediction, onOpen, onPendingChange }) => { const moq = product.master?.minimumOrderQuantity || 0; const [val, setVal] = React.useState(String(pendingQty || '')); const hasPrediction = aiPrediction != null && Number.isFinite(aiPrediction); const predictedDisplay = hasPrediction ? Math.round(aiPrediction).toLocaleString() : null; // The AI prediction now comes from today's pipeline run, which forecasts // tomorrow's demand (invoicedate == run_date + 1). Tomorrow label is // shown both inline and in the tooltip so reps know the value's target day. const tomorrowLabel = React.useMemo(() => { if (!run_date) return ''; const d = new Date(run_date); d.setDate(d.getDate() + 1); return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); }, [run_date]); // Keep input in sync if parent state changes (Page 2 edit, submit-all clear, branch change). React.useEffect(() => { setVal(String(pendingQty || '')); }, [pendingQty, product.name, branch]); const num = Math.max(0, parseInt(val) || 0); const actual = window.roundUpToMoq ? window.roundUpToMoq(num, moq) : num; const dirty = num !== pendingQty; const commit = () => { if (!dirty) return; window.setPendingQty({ run_date, branch, product: product.name, quantity: num }); onPendingChange(); }; const remove = () => { setVal(''); if (pendingQty === 0) return; window.setPendingQty({ run_date, branch, product: product.name, quantity: 0 }); onPendingChange(); }; return (
{hasPrediction && ( AI {predictedDisplay} )} {submitted ? ( Sent ) : ( <> setVal(e.target.value)} onBlur={commit} onKeyDown={(e) => { if (e.key === 'Enter') { commit(); e.target.blur(); } if (e.key === 'Escape') { setVal(String(pendingQty || '')); e.target.blur(); } }} placeholder="0" className="w-20 px-2.5 py-1.5 text-sm bg-white border border-slate-200 rounded-md text-right tabular-nums focus:outline-none focus:ring-2 focus:ring-red-500/20 focus:border-red-500 transition" onClick={(e) => e.stopPropagation()} /> {moq > 1 && num > 0 ? <>= {actual.toLocaleString()} : } )}
); }; window.ProductListView = ProductListView;