// 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 (
{/* Header */}

{title}

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.

{/* Table */}
{withActual.map((r, i) => { const bumped = r.actual !== (parseInt(r.quantity) || 0); return ( ); })} {withActual.length === 0 && ( )}
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.
{/* Footer */}
{itemCount} items · Total: {total.toLocaleString()} units
{error && {error}}
); }; // Toast notification const Toast = ({ open, type, message, onClose }) => { React.useEffect(() => { if (open) { const t = setTimeout(onClose, 3500); return () => clearTimeout(t); } }, [open, onClose]); if (!open) return null; const styles = { success: { bg: 'bg-green-50', border: 'border-green-200', text: 'text-green-900', icon: 'text-green-600' }, error: { bg: 'bg-red-50', border: 'border-red-200', text: 'text-red-900', icon: 'text-red-600' }, }[type] || { bg: 'bg-slate-50', border: 'border-slate-200', text: 'text-slate-900', icon: 'text-slate-600' }; return (
{type === 'success' ? ( ) : ( )}
{message}
); }; // Change Password modal — three fields, client-side confirm-match check, // real validation happens server-side on /api/change-password. const ChangePasswordModal = ({ open, onClose, onSuccess }) => { const [currentPwd, setCurrentPwd] = React.useState(''); const [newPwd, setNewPwd] = React.useState(''); const [confirmPwd, setConfirmPwd] = React.useState(''); const [error, setError] = React.useState(null); const [submitting, setSubmitting] = React.useState(false); React.useEffect(() => { if (open) { setCurrentPwd(''); setNewPwd(''); setConfirmPwd(''); setError(null); setSubmitting(false); } }, [open]); const handleSubmit = async (e) => { e.preventDefault(); setError(null); if (!currentPwd) { setError('Enter your current password.'); return; } if (!newPwd) { setError('Enter a new password.'); return; } if (newPwd !== confirmPwd) { setError('New passwords do not match.'); return; } if (newPwd === currentPwd) { setError('New password must differ from current password.'); return; } setSubmitting(true); try { const res = await window.authFetch('/api/change-password', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ current_password: currentPwd, new_password: newPwd }), }); if (!res.ok) { let detail = 'Failed to change password'; try { detail = (await res.json()).detail || detail; } catch (_) {} throw new Error(detail); } onSuccess && onSuccess(); onClose(); } catch (err) { setError(err.message); setSubmitting(false); } }; if (!open) return null; return (

Change password

Your current session stays signed in.

{error && (
{error}
)}
); }; const PwdField = ({ label, value, onChange, autoFocus = false, autoComplete }) => (
onChange(e.target.value)} autoFocus={autoFocus} autoComplete={autoComplete} className="w-full px-3 py-2 text-sm border border-slate-300 rounded focus:border-red-500 focus:ring-1 focus:ring-red-500 outline-none" />
); window.RequisitionModal = RequisitionModal; window.ChangePasswordModal = ChangePasswordModal; window.Toast = Toast; window.roundUpToMoq = roundUpToMoq;