// United King Sales Forecast & Requisitions — top-level shell. // Two views: a product list (Page 1) and per-product detail (Page 2). // // Branch is a single-select on Page 1, inherited by Page 2. State for which // products had a requisition submitted today is persisted in localStorage // (see data-loader.js) — that's what the "Requisition sent" badges read. const App = () => { const M = window.__DATA || {}; // Defensive defaults so a slim/unexpected bootstrap shape doesn't blow up render. M.products = M.products || []; M.branches = M.branches || []; M.festivals = M.festivals || []; M.festivalCols = M.festivalCols || []; const run_date = M.run_date; // ---- Tweaks (defaults) ---- const tweakDefaults = /*EDITMODE-BEGIN*/{ "scenario": "normal" }/*EDITMODE-END*/; const [tweaks, setTweak] = window.useTweaks(tweakDefaults); // ---- View routing ---- const [view, setView] = React.useState('list'); // 'list' | 'detail' const [selectedProduct, setSelectedProduct] = React.useState(null); // ---- Branch (single, persisted) ---- const BRANCH_KEY = 'uk-branch'; const initialBranch = (() => { const saved = localStorage.getItem(BRANCH_KEY); if (saved && M.branches.some(b => b.name === saved)) return saved; return M.branches[0]?.name || ''; })(); const [branch, setBranchState] = React.useState(initialBranch); const setBranch = (b) => { setBranchState(b); localStorage.setItem(BRANCH_KEY, b); }; // ---- "Ordered today" set + pending-quantity map. Both keyed by (run_date, branch). // Increment a version counter to force re-reads from localStorage after any // change (set/remove/submit) — pendingMap is reads-from-storage rather than // primary state so Page 1 and Page 2 stay in lockstep without prop drilling. ---- const [stateVersion, setStateVersion] = React.useState(0); const bumpState = React.useCallback(() => setStateVersion(v => v + 1), []); const orderedToday = React.useMemo( () => window.getOrderedProductsToday({ run_date, branch }), [run_date, branch, stateVersion] ); const pendingMap = React.useMemo( () => window.getPendingForBranch({ run_date, branch }), [run_date, branch, stateVersion] ); // ---- Modal + toast ---- const [modalState, setModalState] = React.useState({ open: false, rows: [], title: '' }); const [toast, setToast] = React.useState({ open: false, type: 'success', message: '' }); const [changePwdOpen, setChangePwdOpen] = React.useState(false); // ---- Freshness polling. Detects new pipeline runs after page load so the // user gets a banner instead of having to F5. Polls /api/freshness every // 5 minutes (just a MAX(run_date) — cheap). Banner stays until refresh // or explicit dismiss. ---- const [freshAvailable, setFreshAvailable] = React.useState(false); React.useEffect(() => { const initialLastRun = M.last_run; if (!initialLastRun) return; // no baseline → skip polling let cancelled = false; const check = async () => { try { const res = await window.authFetch('/api/freshness'); if (!res.ok || cancelled) return; const data = await res.json(); if (data.last_run && data.last_run !== initialLastRun) { setFreshAvailable(true); } } catch (_) { /* transient network errors are fine */ } }; const id = setInterval(check, 5 * 60 * 1000); // every 5 min return () => { cancelled = true; clearInterval(id); }; }, [M.last_run]); // Build the list of pending rows (one per pending product) and open the // existing requisition modal as a final review step before POSTing /api/orders. const handleOpenSubmitAll = () => { const rows = Object.entries(pendingMap) .filter(([, q]) => q > 0) .map(([prodName, q]) => { const meta = M.products.find(p => p.name === prodName); const branchMeta = M.branches.find(b => b.name === branch); if (!meta || !branchMeta) return null; return { product: prodName, branch, product_id: meta.id, branch_id: branchMeta.id, predicted: 0, inventory: 0, order_qty_predicted: q, minimumOrderQuantity: meta.master?.minimumOrderQuantity || null, }; }) .filter(Boolean); if (rows.length === 0) return; setModalState({ open: true, rows, title: `Submit Requisition · ${branch}` }); }; const handleSubmitOrder = (payload) => { setModalState(s => ({ ...s, open: false })); const products = payload.map(p => { const meta = M.products.find(mp => mp.id === p.product_id); return meta?.name; }).filter(Boolean); if (products.length) { window.markOrderSubmitted({ run_date, branch, products }); window.clearPendingProducts({ run_date, branch, products }); bumpState(); } setToast({ open: true, type: 'success', message: `Submitted: ${payload.length} item${payload.length === 1 ? '' : 's'}, ${payload.reduce((s, r) => s + r.quantity, 0).toLocaleString()} units`, }); }; // ---- Upcoming festivals (from run_date onward, used by detail view side panel + KPIs) ---- const upcomingFestivals = React.useMemo(() => { return M.festivals .filter(f => f.is_festival_day === 1 && f.date >= run_date) .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)); }, [run_date]); // ---- Header KPIs (Last Run + Next Festival, shown on both views) ---- const nextFestival = React.useMemo(() => { const upcoming = upcomingFestivals[0]; if (!upcoming) return null; const days = Math.round((new Date(upcoming.date) - new Date(run_date)) / 86400000); return { name: upcoming.name.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()), days, date: upcoming.date }; }, [upcomingFestivals, run_date]); const lastRunDate = M.last_run; return (
{view === 'list' ? 'Representative Dashboard' : `${selectedProduct?.name} · ${branch}`}
{(e && e.message) || String(e)}{'\n\n'}{(e && e.stack) || ''}