// 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;