// Round up to the next multiple of MOQ. moq=12, qty=7 → 12; qty=15 → 24; qty=24 → 24. // MOQ <= 0 or missing means no rounding constraint. const roundUpToMoq = (qty, moq) => { if (!moq || moq <= 1) return Math.max(0, qty); if (qty <= 0) return 0; return Math.ceil(qty / moq) * moq; }; // Requisition modal — table with editable order quantities. The displayed // "Actual" column applies the per-product Minimum Order Quantity rule and // is what gets submitted (rounded up to the next MOQ multiple). const RequisitionModal = ({ open, onClose, title, rows, onSubmit }) => { const [editedRows, setEditedRows] = React.useState([]); const [submitting, setSubmitting] = React.useState(false); const [error, setError] = React.useState(null); React.useEffect(() => { if (open) { setEditedRows(rows.map(r => ({ ...r, quantity: Math.max(0, r.order_qty_predicted || 0) }))); setError(null); setSubmitting(false); } }, [open, rows]); // "Actual" = quantity rounded up to MOQ multiple. This is what's submitted. const withActual = editedRows.map(r => ({ ...r, actual: roundUpToMoq(parseInt(r.quantity) || 0, r.minimumOrderQuantity), })); const total = withActual.reduce((s, r) => s + r.actual, 0); const itemCount = withActual.filter(r => r.actual > 0).length; const updateQty = (idx, val) => { const v = val === '' ? 0 : Math.max(0, parseInt(val) || 0); setEditedRows(prev => prev.map((r, i) => i === idx ? { ...r, quantity: v } : r)); }; const handleSubmit = async () => { setSubmitting(true); setError(null); try { const payload = withActual .filter(r => r.actual > 0) .map(r => ({ product_id: r.product_id, branch_id: r.branch_id, quantity: r.actual })); const res = await window.authFetch('/api/orders', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ orders: payload }), }); if (!res.ok) { let detail = ''; try { detail = (await res.json()).detail || ''; } catch (_) { detail = await res.text(); } throw new Error(detail || `Server returned ${res.status}`); } onSubmit(payload); } catch (e) { setError(e.message); setSubmitting(false); } }; if (!open) return null; return (
Quantities are pre-filled from the forecast model. The Actual column rounds up to the next Minimum Order Quantity multiple — that's the value submitted.
| Product | Branch | Predicted | Inventory | MOQ | Order Qty | Actual |
|---|---|---|---|---|---|---|
| {r.product} | {r.branch} | {Math.max(0, Math.round(r.predicted || 0))} | {Math.max(0, Math.round(r.inventory || 0))} | {r.minimumOrderQuantity || '—'} | updateQty(i, e.target.value)} className="w-24 px-2.5 py-1.5 border border-slate-200 rounded-md text-right tabular-nums text-slate-900 focus:outline-none focus:ring-2 focus:ring-red-500/20 focus:border-red-500" /> | {r.actual.toLocaleString()} {bumped && ( )} |
| No products to order. | ||||||
Your current session stays signed in.