// 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 (
{/* TOP BAR */}
United King

Sales Forecast & Requisitions

{view === 'list' ? 'Representative Dashboard' : `${selectedProduct?.name} · ${branch}`}

{nextFestival && ( } label="Next Festival" primary={`${nextFestival.days}d`} sub={nextFestival.name} /> )} } label="Last Run" primary={lastRunDate ? new Date(lastRunDate).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : '—'} sub="07:30 PKT" /> setChangePwdOpen(true)} />
{/* FRESHNESS BANNER — surfaces "new pipeline run available" without forcing a reload. Sticky so it's visible after scrolling Page 1/2. */} {freshAvailable && ( window.location.reload()} onDismiss={() => setFreshAvailable(false)} /> )} {/* VIEW */} {view === 'list' ? ( { setSelectedProduct(p); setView('detail'); }} onSubmitAll={handleOpenSubmitAll} /> ) : ( setView('list')} orderedToday={orderedToday} pendingMap={pendingMap} onPendingChange={bumpState} upcomingFestivals={upcomingFestivals} tweaks={tweaks} onTweakChange={setTweak} /> )} {/* MODAL */} setModalState(s => ({ ...s, open: false }))} onSubmit={handleSubmitOrder} /> {/* CHANGE PASSWORD MODAL */} setChangePwdOpen(false)} onSuccess={() => setToast({ open: true, type: 'success', message: 'Password updated.' })} /> {/* TOAST */} setToast(t => ({ ...t, open: false }))} /> {/* TWEAKS — only meaningful on detail view (scenario multipliers) */} {view === 'detail' && ( setTweak('scenario', v)} /> )}
); }; // Avatar + dropdown in the header. Reads email/name from localStorage (set by // /login.html on successful sign-in). Closes on outside click or Escape. const UserMenu = ({ onChangePassword }) => { const [open, setOpen] = React.useState(false); const wrapRef = React.useRef(null); const email = (typeof localStorage !== 'undefined' && localStorage.getItem('uk-auth-email')) || ''; const name = (typeof localStorage !== 'undefined' && localStorage.getItem('uk-auth-name')) || ''; const initial = ((name || email || '?').trim().charAt(0) || '?').toUpperCase(); React.useEffect(() => { if (!open) return; const onDocClick = (e) => { if (wrapRef.current && !wrapRef.current.contains(e.target)) setOpen(false); }; const onKey = (e) => { if (e.key === 'Escape') setOpen(false); }; document.addEventListener('mousedown', onDocClick); document.addEventListener('keydown', onKey); return () => { document.removeEventListener('mousedown', onDocClick); document.removeEventListener('keydown', onKey); }; }, [open]); return (
{open && (
{name || 'Signed in'}
{email || '—'}
)}
); }; const FreshnessBanner = ({ onRefresh, onDismiss }) => (
New forecast data available.
); const KpiChip = ({ icon, label, primary, sub }) => (
{icon}
{label}
{primary} · {sub}
); // Catch render-time crashes so we see the error in the page instead of a white screen. class ErrorBoundary extends React.Component { constructor(props) { super(props); this.state = { err: null }; } static getDerivedStateFromError(err) { return { err }; } componentDidCatch(err, info) { console.error('App crashed:', err, info); } render() { if (this.state.err) { const e = this.state.err; return (
App crashed
            {(e && e.message) || String(e)}{'\n\n'}{(e && e.stack) || ''}
          
); } return this.props.children; } } window.bootstrapData() .then(() => ReactDOM.createRoot(document.getElementById('root')).render( )) .catch(err => { document.getElementById('root').innerHTML = `
Failed to load data ${(err && err.message) || err}
`; });