// VMAX prototype — shared store: event ledger + live state + actions, persisted to // localStorage and synced across the two apps. The reconciliation spine lives here: // every consequential action mutates live state AND appends an attributed, timestamped // ledger record. Both apps read the same data; identity (who's on this till) is held // per-app in the shells, and passed in as `by` when an action needs attribution. (function () { const P = window.VMAXP; const KEY = 'vmax365.proto.v6'; const num = (q) => { const m = String(q).match(/-?\d+(\.\d+)?/); return m ? parseFloat(m[0]) : 0; }; const pad2 = (n) => (n < 10 ? '0' : '') + n; let seq = 2000; const nid = (p) => p + '-' + (++seq); // ---------- seed a fresh state ---------- function seedState(mode) { const populated = mode !== 'firstrun'; const ingredients = P.ingredients.map((i) => ({ ...i })); const base = { v: 6, mode: populated ? 'populated' : 'firstrun', clock: 12 * 60 + 34, // minutes since midnight; advances on consequential actions day: { date: P.today, status: 'open', openedBy: populated ? 'Clara' : null, prevOpen: false, closedBy: null, closedAt: null }, people: P.people.map((p) => ({ ...p })), locations: P.locations.map((l) => ({ ...l })), menu: P.menu.map((m) => ({ ...m })), recipes: JSON.parse(JSON.stringify(P.recipes)), ingredients, members: JSON.parse(JSON.stringify(P.members)), order: { lines: [], mode: 'Dine-in', member: null }, orderNo: 47, queue: populated ? P.seedQueue.map((o) => ({ ...o, lines: o.lines.map((l) => ({ ...l })) })) : [], requests: populated ? P.requests.map((r) => ({ ...r })) : [], receipts: populated ? P.receipts.map((r) => ({ ...r })) : [], counts: populated ? P.counts.map((c) => ({ ...c })) : [], tally: populated ? JSON.parse(JSON.stringify(P.seedTally)) : { cash: 0, mobile: { WeChat: 0, Alipay: 0, VMAX: 0 }, coldCups: 0, hotCups: 0, dishes: 0, consumption: {} }, sales: populated ? P.seedSales.map((o) => ({ ...o, lines: o.lines.map((l) => ({ ...l })) })) : [], ledger: populated ? P.seedLedger.map((e) => ({ ...e })) : [{ id: 'L-1000', type: 'open', at: '07:45', by: 'Owner', text: 'New outlet — first run' }], close: { counted: null, reasons: {}, signed: false }, onboarding: populated ? { owner: true, locations: true, people: true, menu: true, stock: true, recipes: true, started: true, dismissed: true } : { owner: true, locations: true, people: false, menu: false, stock: false, recipes: false, started: true, dismissed: false }, tweaks: { dayState: 'flagged', net: 'online', compact: false }, settings: { pinForPrint: true, pinForClaim: true, pinForRefund: false }, }; if (!populated) { // a fresh shop: empty front/back except a starter delivery not yet received base.ingredients.forEach((i) => { i.back = 0; i.front = 0; }); base.menu = []; base.recipes = {}; base.members = []; base.people = P.people.filter((p) => p.roles.includes('owner')); } applyDayState(base, base.tweaks.dayState); return base; } // counted-side defaults for the close, per day-state tweak function applyDayState(st, ds) { const t = st.tally; if (ds === 'clean') { st.close.counted = { cash: t.cash, cold: t.coldCups, hot: t.hotCups, mobile: { ...t.mobile }, choc: 250 }; st.close.reasons = {}; } else if (ds === 'closing') { st.close.counted = { cash: t.cash - 2, cold: t.coldCups, hot: null, mobile: { ...t.mobile }, choc: null }; st.close.reasons = { cash: 'Rounding' }; } else { // flagged (default) st.close.counted = { cash: t.cash - 2, cold: t.coldCups, hot: t.hotCups + 1, mobile: { ...t.mobile }, choc: 232 }; st.close.reasons = { cash: 'Rounding', hot: 'Remake', choc: '' }; } } // ---------- load / persist / subscribe ---------- let state; try { const raw = localStorage.getItem(KEY); const p = raw && JSON.parse(raw); state = p && p.v === 6 ? p : seedState('populated'); } catch (e) { state = seedState('populated'); } const listeners = new Set(); const emit = () => listeners.forEach((l) => l()); function persist() { try { localStorage.setItem(KEY, JSON.stringify(state)); } catch (e) {} } function commit() { persist(); emit(); } function update(fn) { const next = fn(state) || state; state = Object.assign({}, next); commit(); } if (typeof window !== 'undefined') { window.addEventListener('storage', (e) => { if (e.key === KEY && e.newValue) { try { state = JSON.parse(e.newValue); emit(); } catch (_) {} } }); } const clockStr = (st) => pad2(Math.floor(st.clock / 60) % 24) + ':' + pad2(st.clock % 60); const tick = (st, mins = 6) => { st.clock += mins; }; // ---------- derived selectors ---------- const statusOf = (it) => (it.front === 0 ? 'out' : it.front <= it.warn ? 'low' : 'ok'); const lowStock = (st) => st.ingredients.filter((i) => i.front === 0 || i.front <= i.warn); const missingRecipes = (st) => st.menu.filter((m) => !st.recipes[m.id]); function notifications(st) { const frontOut = st.ingredients.filter((i) => i.front === 0); const frontLow = st.ingredients.filter((i) => i.front > 0 && i.front <= i.warn); const backOut = st.ingredients.filter((i) => i.back === 0); const backLow = st.ingredients.filter((i) => i.back > 0 && i.back <= i.warn); const unclaimed = st.queue.filter((o) => o.status !== 'done' && !o.maker).length; const pendingReq = st.requests.filter((r) => r.status === 'pending').length; return { frontOut, frontLow, backOut, backLow, unclaimed, pendingReq, front: frontOut.length + frontLow.length, back: backOut.length + backLow.length }; } function onboardingDone(st) { const o = st.onboarding; const cashierExists = st.people.some((p) => p.roles.includes('cashier') && p.pin); const menuPriced = st.menu.length > 0 && st.menu.every((m) => m.price); const stockSet = st.ingredients.some((i) => i.back + i.front > 0); const recipesOk = st.menu.filter((m) => m.hotcold || m.sizes || true).every((m) => st.recipes[m.id]); return { people: cashierExists, menu: menuPriced, stock: stockSet, recipes: st.menu.length > 0 && missingRecipes(st).length === 0, locations: o.locations, owner: o.owner, ready: cashierExists && menuPriced && stockSet }; } // ---------- ledger helper ---------- function logEvent(st, type, by, text, extra) { st.ledger.unshift(Object.assign({ id: nid('L'), type, at: clockStr(st), by: by || '—', text }, extra || {})); } // ---------- consumption engine ---------- function consume(st, lines, sign) { sign = sign || -1; lines.forEach((l) => { const r = st.recipes[l.id]; if (!r) return; const cup = l.temp || r.cup || 'cold'; if (cup === 'hot') st.tally.hotCups += -sign * l.qty * 0; // counted below r.ings.forEach((g) => { const it = st.ingredients.find((x) => x.id === g.id); if (it) it.front = Math.max(0, it.front + sign * l.qty); st.tally.consumption[g.id] = (st.tally.consumption[g.id] || 0) + -sign * num(g.q) * l.qty; }); }); } function countCups(st, lines, sign) { sign = sign || 1; lines.forEach((l) => { const r = st.recipes[l.id]; const cup = l.temp || (r && r.cup) || 'cold'; if (cup === 'hot') st.tally.hotCups += sign * l.qty; else st.tally.coldCups += sign * l.qty; }); } // ================= ACTIONS ================= const actions = { getState: () => state, reset: (mode) => update(() => seedState(mode || 'populated')), // ----- identity / people (data; session lives in app shells) ----- verifyPin: (id, pin) => { const p = state.people.find((x) => x.id === id); return !!p && p.pin === pin; }, setPin: (id, pin) => update((s) => { const p = s.people.find((x) => x.id === id); if (p) { p.pin = pin; p.pending = false; p.resetPending = false; } return s; }), addPerson: (person, by) => update((s) => { const id = nid('u'); s.people.push(Object.assign({ id, active: true }, person)); logEvent(s, 'people', by, 'Added ' + person.name + ' · ' + person.roles.join(' + ')); return s; }), editPerson: (id, patch, by) => update((s) => { const p = s.people.find((x) => x.id === id); if (p) { Object.assign(p, patch); logEvent(s, 'people', by, 'Edited ' + p.name); } return s; }), removePerson: (id, by) => update((s) => { const p = s.people.find((x) => x.id === id); if (p && !p.root) { s.people = s.people.filter((x) => x.id !== id); logEvent(s, 'people', by, 'Removed ' + p.name + ' — access revoked, records kept'); } return s; }), requestReset: (id, by) => update((s) => { const p = s.people.find((x) => x.id === id); if (p) { p.resetPending = true; logEvent(s, 'people', by, 'PIN reset requested for ' + p.name); } return s; }), // ----- onboarding ----- completeStep: (step) => update((s) => { s.onboarding[step] = true; return s; }), renameLocation: (id, name) => update((s) => { const l = s.locations.find((x) => x.id === id); if (l) l.name = name; return s; }), dismissOnboarding: () => update((s) => { s.onboarding.dismissed = true; return s; }), // ----- POS order building ----- setMode: (m) => update((s) => { s.order.mode = m; return s; }), addLine: (l) => update((s) => { s.order.lines = [...s.order.lines, { ...l, qty: l.qty || 1 }]; return s; }), bumpLine: (i, v) => update((s) => { s.order.lines = v < 1 ? s.order.lines.filter((_, k) => k !== i) : s.order.lines.map((l, k) => (k === i ? { ...l, qty: v } : l)); return s; }), attachMember: (m) => update((s) => { s.order.member = m; return s; }), clearMember: () => update((s) => { s.order.member = null; return s; }), enrolMember: (m) => update((s) => { const id = nid('m'); const mem = { id, since: '2026', tier: 'Member', history: [], ...m }; s.members.push(mem); s.order.member = mem; return s; }), clearOrder: () => update((s) => { s.order = { lines: [], mode: 'Dine-in', member: null }; return s; }), // ----- payment + attribution (by = the verified printer) ----- takePayment: (tender, by) => update((s) => { const ord = s.order; if (!ord.lines.length) return s; const id = 'A-0' + s.orderNo; let amount = 0; ord.lines.forEach((l) => { amount += (ord.member ? l.member : l.price) * l.qty; }); tick(s); // money tally if (tender === 'Cash') s.tally.cash += amount; else if (s.tally.mobile[tender] != null) s.tally.mobile[tender] += amount; else s.tally.mobile.VMAX += 0, (s.tally.cash += 0); if (tender !== 'Cash' && s.tally.mobile[tender] == null) { /* card */ } // cups + consumption countCups(s, ord.lines, 1); consume(s, ord.lines, -1); s.tally.dishes += ord.lines.reduce((a, l) => a + l.qty, 0); // queue + sale record + ledger s.queue = [{ id, mode: ord.mode, lines: ord.lines.map((l) => ({ id: l.id, name: l.name, temp: l.temp, qty: l.qty })), maker: null, status: 'waiting', when: 'just now', by }, ...s.queue]; const sale = { id, at: clockStr(s), by, tender, member: ord.member ? ord.member.name : null, amount, lines: ord.lines.map((l) => ({ id: l.id, name: l.name, temp: l.temp, size: l.size, qty: l.qty, price: ord.member ? l.member : l.price })) }; s.sales = [sale, ...s.sales]; // member history if (ord.member) { const mem = s.members.find((x) => x.id === ord.member.id); if (mem) ord.lines.forEach((l) => { const h = mem.history.find((x) => x.id === l.id); if (h) h.times += l.qty; else mem.history.push({ id: l.id, name: l.name, temp: l.temp, times: l.qty }); }); } logEvent(s, 'sale', by, id + ' · ' + ord.lines.map((l) => l.qty + '× ' + l.name).join(', ') + ' · ' + P.K(amount) + ' ' + tender); s.order = { lines: [], mode: 'Dine-in', member: null }; s.orderNo += 1; return s; }), // ----- refund (its own audited record; made-or-not decides stock) ----- refund: (saleId, lineIdxs, made, by) => update((s) => { const sale = s.sales.find((x) => x.id === saleId) || s.queue.find((x) => x.id === saleId); if (!sale) return s; const lines = (sale.lines || []).filter((_, i) => lineIdxs.includes(i)); const amount = lines.reduce((a, l) => a + (l.price || 0) * l.qty, 0); tick(s); s.tally.cash = Math.max(0, s.tally.cash - amount); if (!made) { consume(s, lines, +1); countCups(s, lines, -1); } // never made → ingredients return logEvent(s, 'refund', by, saleId + ' · ' + lines.map((l) => l.qty + '× ' + l.name).join(', ') + ' refunded ' + P.K(amount) + (made ? ' — made & discarded (consumed)' : ' — not made (stock returned)'), { reason: made ? 'made-discarded' : 'not-made' }); return s; }), // ----- remake (no money; consumes stock again, with a reason) ----- remake: (saleId, lineIdx, reason, by) => update((s) => { const sale = s.sales.find((x) => x.id === saleId) || s.queue.find((x) => x.id === saleId); if (!sale) return s; const line = (sale.lines || [])[lineIdx]; if (!line) return s; tick(s); consume(s, [line], -1); countCups(s, [line], 1); logEvent(s, 'remake', by, saleId + ' · ' + line.name + ' remade — ' + reason + '. Cup + ingredients consumed again', { reason }); return s; }), // ----- maker queue ----- claim: (id, name) => update((s) => { const o = s.queue.find((x) => x.id === id); if (o && !o.maker) { o.maker = name; o.status = 'making'; logEvent(s, 'claim', name, 'Claimed ' + id); } return s; }), markDone: (id) => update((s) => { const o = s.queue.find((x) => x.id === id); if (o) o.status = 'done'; return s; }), // ----- transfers (back -> front) ----- submitRequest: (items, urgent, by) => update((s) => { const id = nid('RQ'); s.requests = [{ id, items, urgent, status: 'pending', by, when: clockStr(s) }, ...s.requests]; logEvent(s, 'transfer', by, 'Requested ' + items.map((i) => i.qty + '× ' + (s.ingredients.find((x) => x.id === i.id) || {}).name).join(', ')); return s; }), fulfilRequest: (id, by) => update((s) => { const r = s.requests.find((x) => x.id === id); if (!r || r.status !== 'pending') return s; r.status = 'fulfilled'; r.fulfilledBy = by; tick(s); r.items.forEach((m) => { const it = s.ingredients.find((x) => x.id === m.id); if (it) { const moved = Math.min(m.qty, it.back); it.back -= moved; it.front += moved; } }); logEvent(s, 'transfer', by, 'Sent ' + r.items.map((i) => i.qty + '× ' + (s.ingredients.find((x) => x.id === i.id) || {}).name).join(', ') + ' → shop front', { ref: id }); return s; }), // ----- receiving from the warehouse (receive-and-note) ----- receiveStock: (items, note, by) => update((s) => { const id = nid('RC'); tick(s); items.forEach((m) => { const it = s.ingredients.find((x) => x.id === m.id); if (it) it.back += m.qty; }); s.receipts = [{ id, when: clockStr(s), by, items: items.map((i) => ({ ...i })), note: note || '' }, ...s.receipts]; logEvent(s, 'receive', by, 'Received ' + items.map((i) => i.qty + '× ' + (s.ingredients.find((x) => x.id === i.id) || {}).name).join(', ') + ' to back store' + (note ? ' (note attached)' : ''), { ref: id }); return s; }), // ----- physical counts (spot + full); a count is a tracked, revertible event ----- saveCount: (kind, location, lines, by) => update((s) => { const id = nid('CT'); tick(s); const recLines = lines.map((l) => { const it = s.ingredients.find((x) => x.id === l.id); const before = it ? (location === 'back' ? it.back : it.front) : null; if (it && l.counted != null && l.counted !== '') { if (location === 'back') it.back = l.counted; else it.front = l.counted; } return { ...l, before }; }); const varianced = recLines.filter((l) => l.counted !== l.expected && l.counted !== '' && l.counted != null); s.counts = [{ id, kind, when: clockStr(s), by, location, lines: recLines, resolved: varianced.length === 0, reverted: false }, ...s.counts]; logEvent(s, 'count', by, (kind === 'spot' ? 'Spot check' : 'Full count') + ' (' + location + ') — ' + recLines.length + ' items, ' + varianced.length + ' variance' + (varianced.length === 1 ? '' : 's'), { ref: id, revertible: true }); return s; }), revertCount: (countId, by) => update((s) => { const c = s.counts.find((x) => x.id === countId); if (!c || c.reverted) return s; c.lines.forEach((l) => { const it = s.ingredients.find((x) => x.id === l.id); if (it && l.before != null) { if (c.location === 'back') it.back = l.before; else it.front = l.before; } }); c.reverted = true; tick(s); const e = s.ledger.find((x) => x.ref === countId); if (e) e.reverted = true; logEvent(s, 'count', by, 'Reverted ' + countId + ' — levels restored to before the count', { revertOf: countId }); return s; }), revertLedger: (lid, by) => update((s) => { const e = s.ledger.find((x) => x.id === lid); if (e && !e.reverted) { e.reverted = true; logEvent(s, e.type, by, 'Reverted: ' + e.text, { revertOf: lid }); } return s; }), // ----- catalogue ----- bulkCommit: (kind, rows, by) => update((s) => { tick(s); if (kind === 'menu') rows.forEach((r) => { if (!s.menu.find((m) => m.id === r.id)) s.menu.push(r); }); if (kind === 'ingredients') rows.forEach((r) => { if (!s.ingredients.find((m) => m.id === r.id)) s.ingredients.push(r); }); logEvent(s, 'price', by, 'Bulk added ' + rows.length + ' ' + kind, { revertible: true }); return s; }), saveRecipe: (menuId, recipe, by) => update((s) => { s.recipes[menuId] = recipe; logEvent(s, 'price', by, 'Recipe set for ' + (s.menu.find((m) => m.id === menuId) || {}).name, { revertible: true }); return s; }), editMenu: (id, patch, by) => update((s) => { const m = s.menu.find((x) => x.id === id); if (m) { Object.assign(m, patch); logEvent(s, 'price', by, 'Edited ' + m.name, { revertible: true }); } return s; }), // ----- the close ----- setCounted: (patch) => update((s) => { s.close.counted = { ...(s.close.counted || {}), ...patch }; return s; }), setReason: (key, val) => update((s) => { s.close.reasons = { ...s.close.reasons, [key]: val }; return s; }), signOff: (by) => update((s) => { s.close.signed = true; s.day.status = 'closed'; s.day.closedBy = by; s.day.closedAt = clockStr(s); logEvent(s, 'close', by, 'Day closed — variances recorded with reasons; balances carried to tomorrow'); return s; }), reopenDay: () => update((s) => { s.close.signed = false; s.day.status = 'open'; return s; }), // ----- direct transfer (from the inventory UI) ----- transferStock: (itemId, qty, by) => update((s) => { const it = s.ingredients.find((x) => x.id === itemId); if (it) { const moved = Math.min(qty, it.back); it.back -= moved; it.front += moved; tick(s); logEvent(s, 'transfer', by, 'Transferred ' + moved + ' ' + it.unit + ' ' + it.name + ' → shop front'); } return s; }), adjustStock: (itemId, location, value, by) => update((s) => { const it = s.ingredients.find((x) => x.id === itemId); if (it) { const before = location === 'back' ? it.back : it.front; if (location === 'back') it.back = value; else it.front = value; tick(s); logEvent(s, 'count', by, 'Adjusted ' + it.name + ' · ' + location + ' ' + before + ' → ' + value + ' ' + it.unit, { revertible: true }); } return s; }), // ----- cashier cash-up (handover record; doesn't touch the manager close) ----- cashUp: (by, cash, mobile, note) => update((s) => { tick(s); const mob = mobile ? Object.values(mobile).reduce((a, b) => a + (Number(b) || 0), 0) : 0; logEvent(s, 'cashup', by, 'Cash-up by ' + by + ' — cash ' + P.K(cash) + (mob ? ', mobile ' + P.K(mob) : '') + (note ? ' (' + note + ')' : '')); return s; }), // ----- settings (which actions need a PIN) ----- setSetting: (k, v) => update((s) => { s.settings = { ...s.settings, [k]: v }; return s; }), // ----- tweaks ----- setTweak: (k, v) => update((s) => { s.tweaks = { ...s.tweaks, [k]: v }; if (k === 'dayState') applyDayState(s, v); return s; }), }; // ---------- React hook ---------- function useVMAX() { const get = React.useCallback(() => state, []); const sub = React.useCallback((cb) => { listeners.add(cb); return () => listeners.delete(cb); }, []); const s = React.useSyncExternalStore(sub, get); return [s, actions]; } window.VMAXStore = { useVMAX, actions, statusOf, lowStock, missingRecipes, onboardingDone, notifications, clockStr: () => clockStr(state) }; })();