/* ============================================================= CoreIQ Office — Receiving, LIVE. The goods-in worklist over real Aurora data: every supplier invoice whose stock has not fully landed, with the exception lines FLAGGED inline (missing barcode → scan it right here; post-invoice count → explained) instead of buried in a notes string. The PharmX poller auto-receives clean invoices; this screen exists for the residue that genuinely needs a human. Loaded AFTER office-screens-receiving.jsx, so this registration REPLACES the demo Receiving prototype under the same screen key. Uses shared bare globals: OIcon, Screen, StatusPill, window.KpiCard, window.useOffice, window.OfficeAPI. ============================================================= */ const { useState: rlvUseState, useEffect: rlvUseEffect, useCallback: rlvUseCallback } = React; const rlvMoney = (c) => "$" + (Number(c || 0) / 100).toLocaleString("en-AU", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); const rlvNum = (v) => Number(v || 0).toLocaleString("en-AU"); const rlvDate = (s) => (s ? String(s).slice(0, 10) : "—"); // ---- One invoice line inside the expanded panel ------------------ // The flag IS the UI: amber lines carry the fix inline. function RlvPlanLine({ l, invoiceId, onFixed, pushToast }) { const [code, setCode] = rlvUseState(""); const [busy, setBusy] = rlvUseState(false); const cols = "minmax(220px, 2fr) 110px 60px 90px minmax(230px, 1.6fr)"; function saveBarcode() { let digits = code.replace(/\D/g, ""); // A carton scan (GS1-128, AI(01)+GTIN-14) arrives as 16+ digits — take // the GTIN rather than rejecting the scan. if (digits.length >= 16 && digits.slice(0, 2) === "01") digits = digits.slice(2, 16); if (digits.length < 8 || digits.length > 14) { // Say what we actually saw — the operator can't fix "not a barcode". const why = digits.length === 0 ? "That scan had no digits — scan the barcode on the pack or carton" : digits.length >= 18 && digits.slice(0, 2) === "00" ? "That's a pallet SSCC label (AI 00) — scan the barcode on the pack or carton instead" : "Got " + digits.length + " digits (" + digits + ") — a product code is 8, 12, 13 or 14 digits; a carton code starting (01) also works"; pushToast({ kind: "hold", icon: "alert", title: "That scan isn't a product barcode", meta: why }); return; } if (busy) return; setBusy(true); window.OfficeAPI.setInvoiceLineBarcode(l.lineId, digits) .then(() => window.OfficeAPI.receiveSupplierInvoice(invoiceId)) .then((r) => { setBusy(false); pushToast({ kind: "paid", icon: "check", title: "Line ranged & received", meta: l.name }); onFixed(r); }) .catch((e) => { setBusy(false); pushToast({ kind: "hold", icon: "alert", title: "Couldn't fix line", meta: String((e && e.message) || e) }); }); } const flag = l.action === "received" ? : l.action === "ready" ? : l.action === "no-stock" ? : l.action === "counted-after" ? : ; return (
{l.name} {l.supplierCode || "—"} {l.quantity} {rlvMoney(l.lineTotalCents)} {flag} {l.action === "needs-barcode" && ( setCode(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") saveBarcode(); }} /> )}
{(l.action === "needs-barcode" || l.action === "counted-after") && l.reason && (
{l.reason}
)}
); } // ---- One invoice row in the worklist ------------------------------ function RlvInvoiceRow({ v, onChanged, pushToast }) { const [open, setOpen] = rlvUseState(false); const [plan, setPlan] = rlvUseState(null); const [busy, setBusy] = rlvUseState(false); const cols = "140px minmax(180px, 1.4fr) 110px 70px 90px 110px 120px 120px"; function loadPlan() { window.OfficeAPI.receivePlan(v.id).then(setPlan).catch(() => setPlan({ lines: [], error: true })); } function toggle() { const next = !open; setOpen(next); if (next && plan == null) loadPlan(); } function receive(e) { e.stopPropagation(); if (busy) return; setBusy(true); window.OfficeAPI.receiveSupplierInvoice(v.id) .then((r) => { setBusy(false); if (r.status === "received") pushToast({ kind: "paid", icon: "check", title: "Stock received", meta: r.received + " line" + (r.received === 1 ? "" : "s") + " · invoice " + v.number }); else pushToast({ kind: "hold", icon: "alert", title: "Received, with items to check", meta: r.received + " to stock · " + r.skipped + " parked in the Barcode queue" }); loadPlan(); if (!open) setOpen(true); onChanged(); }) .catch((e2) => { setBusy(false); pushToast({ kind: "hold", icon: "alert", title: "Couldn't receive", meta: String((e2 && e2.message) || e2) }); }); } function onLineFixed() { loadPlan(); onChanged(); } const lineCols = "minmax(220px, 2fr) 110px 60px 90px minmax(230px, 1.6fr)"; return (
{v.number} {v.supplierName || "—"} {v.sourceType === "pharmx" && PharmX} {rlvDate(v.date)} {v.lineCount} {v.unreceivedLines > 0 ? {v.unreceivedLines} : 0} {rlvMoney(v.totalCents)} {v.status === "review" ? : v.status === "pending" ? : } {(v.status === "pending" || v.status === "review") && }
{open && (
{plan == null &&
Loading…
} {plan && plan.error &&
Couldn't load the line plan.
} {plan && !plan.error && (
ProductReorder codeQtyLine ex GSTStock status
{plan.lines.map((l) => ( ))}
)}
)}
); } // ---- The worklist screen ------------------------------------------ // MANUAL INVOICE ENTRY — the store keys a direct (non-PharmX) supplier's // invoice (Kirsty's ask 2026-08-04). Scan a barcode to autofill known // products; unknown lines are typed and get ranged at Receive time via the // normal scan flow. The printed grand total is entered from the paper — if // the keyed lines disagree by >5c the invoice lands in "Needs attention" // with the delta named, never silently absorbed. function RlvEnterInvoice({ onClose, onCreated, pushToast }) { const emptyLine = () => ({ barcode: "", name: "", quantity: "1", cost: "", gstFree: false, known: null }); const [suppliers, setSuppliers] = rlvUseState([]); const [supplierId, setSupplierId] = rlvUseState(""); const [number, setNumber] = rlvUseState(""); const [date, setDate] = rlvUseState(new Date().toISOString().slice(0, 10)); const [total, setTotal] = rlvUseState(""); const [lines, setLines] = rlvUseState([emptyLine()]); const [busy, setBusy] = rlvUseState(false); const [saveErr, setSaveErr] = rlvUseState(null); rlvUseEffect(() => { let alive = true; window.OfficeAPI.listSuppliers().then((ss) => { if (alive) setSuppliers(ss || []); }).catch(() => {}); return () => { alive = false; }; }, []); function setLine(i, patch) { setLines((ls) => ls.map((l, j) => (j === i ? Object.assign({}, l, patch) : l))); } function onScan(i, code) { const digits = String(code || "").replace(/\D/g, ""); if (digits.length < 8) return; window.OfficeAPI.productByBarcode(digits) .then((prod) => setLine(i, { name: prod.name, known: prod.id, cost: (function (l) { return l.cost !== "" ? l.cost : prod.costPriceCents != null ? (prod.costPriceCents / 100).toFixed(2) : ""; })(lines[i]), })) .catch(() => setLine(i, { known: null })); } function addRowIfLast(i) { if (i === lines.length - 1) setLines((ls) => ls.concat([emptyLine()])); } const cents = (v) => Math.round(Number(v || 0) * 100); const real = lines.filter((l) => l.name.trim() !== ""); const keyedSub = real.reduce((s2, l) => s2 + cents(l.cost) * (parseInt(l.quantity, 10) || 0), 0); const keyedTax = real.reduce((s2, l) => s2 + (l.gstFree ? 0 : Math.round(cents(l.cost) * (parseInt(l.quantity, 10) || 0) * 0.1)), 0); const keyed = keyedSub + keyedTax; const printed = cents(total); const delta = printed - keyed; const missing = []; if (!supplierId) missing.push("supplier"); if (!number.trim()) missing.push("invoice number"); if (total === "") missing.push("invoice total"); if (real.length === 0) missing.push("at least one line with a description"); else if (!real.every((l) => l.cost !== "")) missing.push("a unit cost on every line"); else if (!real.every((l) => (parseInt(l.quantity, 10) || 0) > 0)) missing.push("a quantity on every line"); const canSave = missing.length === 0 && !busy; function save() { if (!canSave) return; setBusy(true); window.OfficeAPI.createSupplierInvoice({ supplierId, invoiceNumber: number.trim(), invoiceDate: date, totalIncGstCents: printed, lines: real.map((l) => ({ name: l.name.trim(), barcode: l.barcode.trim() || null, quantity: parseInt(l.quantity, 10), unitCostExGstCents: cents(l.cost), gstFree: !!l.gstFree, })), }).then((r) => { pushToast(r.status === "review" ? { kind: "hold", icon: "alert", title: "Invoice saved — needs attention", meta: "Keyed lines differ from the printed total by $" + Math.abs(r.deltaCents / 100).toFixed(2) } : { kind: "paid", icon: "check-circle", title: "Invoice " + number.trim() + " saved", meta: "In Pending — hit Receive to land the stock" }); onCreated(); }).catch((e) => { setBusy(false); // The error belongs ON the form she is looking at. A toast alone left // the modal sitting there unchanged, which reads as "nothing happened". setSaveErr(String((e && e.message) || e)); pushToast({ kind: "hold", icon: "alert", title: "Couldn't save invoice", meta: String((e && e.message) || e) }); }); } const lineCols = "150px minmax(200px, 2fr) 70px 100px 60px 90px"; return (
e.stopPropagation()}>

Enter a supplier invoice

setNumber(e.target.value)} placeholder="As printed"/>
setDate(e.target.value)}/>
setTotal(e.target.value)} placeholder="0.00"/>
Barcode (scan)ProductQty Cost ex GSTGST-freeLine total
{lines.map((l, i) => (
setLine(i, { barcode: e.target.value })} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); onScan(i, l.barcode); addRowIfLast(i); } }} onBlur={() => l.barcode && !l.name && onScan(i, l.barcode)}/> setLine(i, { name: e.target.value, known: null })} onFocus={() => addRowIfLast(i)}/> setLine(i, { quantity: e.target.value })}/> setLine(i, { cost: e.target.value })}/> setLine(i, { gstFree: e.target.checked })}/> {l.cost === "" ? "" : rlvMoney(cents(l.cost) * (parseInt(l.quantity, 10) || 0))}
))}
Keyed: {rlvMoney(keyed)} inc GST Printed: {total === "" ? "—" : rlvMoney(printed)} {total !== "" && ( 5 ? { color: "var(--hold-text)", fontWeight: 600 } : { color: "var(--paid-text)", fontWeight: 600 }}> {Math.abs(delta) <= 5 ? "✓ reconciles" : "off by " + rlvMoney(Math.abs(delta))} )}
{saveErr && (
Not saved. {saveErr}
)}
{/* A disabled button with no reason is the other half of "nothing happened" — name what is still missing. */} {missing.length > 0 ? "Still needed: " + missing.join(", ") : "Unknown products get set up when you Receive — scan them there."}
); } // Choice shown by "Enter invoice" — key it by hand (the existing modal below) // or upload a PDF/photo for AI parse + confirm (Kirsty's ask 2026-08-21). function RlvEntryChoice({ onClose, onKeyByHand, onUpload }) { return (
e.stopPropagation()}>

Enter a supplier invoice

); } function ReceivingLive() { const { pushToast, setScreen } = window.useOffice(); const [data, setData] = rlvUseState(null); const [err, setErr] = rlvUseState(null); const [tab, setTab] = rlvUseState("attention"); const [entering, setEntering] = rlvUseState(false); const [choosingEntry, setChoosingEntry] = rlvUseState(false); const load = rlvUseCallback(() => { window.OfficeAPI.receivingWorklist() .then((d) => { setData(d); setErr(null); }) .catch((e) => setErr(String((e && e.message) || e))); }, []); rlvUseEffect(() => { load(); }, [load]); const s = (data && data.summary) || {}; const all = (data && data.invoices) || []; const FILTERS = [ { k: "attention", l: "Needs attention", pred: (v) => v.status === "review", badge: s.needsAttention || null }, { k: "pending", l: "Pending", pred: (v) => v.status === "pending" }, { k: "received", l: "Received", pred: (v) => v.status === "received" }, { k: "all", l: "All", pred: () => true }, ]; const shown = all.filter(FILTERS.find((f) => f.k === tab).pred); const cols = "140px minmax(180px, 1.4fr) 110px 70px 90px 110px 120px 120px"; return ( ({ k: f.k, l: f.l, badge: f.badge }))} activeTab={tab} onTab={setTab} actions={ } > {choosingEntry && ( setChoosingEntry(false)} onKeyByHand={() => { setChoosingEntry(false); setEntering(true); }} onUpload={() => { setChoosingEntry(false); setScreen("invoiceCaptureUpload"); }} /> )} {entering && ( setEntering(false)} onCreated={() => { setEntering(false); setTab("pending"); load(); }} /> )} {err &&
Couldn't load: {err}
}
Supplier invoices {data && {shown.length}}
{data == null && !err &&
Loading…
} {data && shown.length === 0 && (
{tab === "attention" ? "Nothing needs attention — the pipeline is receiving everything by itself." : "Nothing here."}
)} {data && shown.length > 0 && (
Invoice #SupplierDate LinesWaiting TotalStatus
{shown.map((v) => )}
)}
); } // Replaces the demo prototype registered by office-screens-receiving.jsx — // this file loads after it, so the same key now points at the live screen. // ----------------------------------------------------------------- // BARCODE QUEUE — the parking lot for receiving exceptions. // Receiving never stops for an item it can't identify any more: the line is // ranged from the invoice itself and the item lands here, so one person can // work the queue with a scanner across every invoice at once instead of the // store being blocked at the counter (store request 2026-08-12). They sell // fine meanwhile — the till finds products by name — a barcode is only speed. // ----------------------------------------------------------------- function BarcodeQueueScreen() { const { pushToast } = window.useOffice(); const [rows, setRows] = rlvUseState(null); const [err, setErr] = rlvUseState(null); const [codes, setCodes] = rlvUseState({}); const [busy, setBusy] = rlvUseState(null); const load = () => window.OfficeAPI.listBarcodeQueue() .then((d) => { setRows(d || []); setErr(null); }) .catch((e) => setErr(String((e && e.message) || e))); rlvUseEffect(() => { load(); }, []); function attach(p) { const digits = String(codes[p.id] || "").replace(/[^0-9]/g, ""); if (digits.length < 8) { pushToast({ kind: "hold", icon: "alert", title: "That doesn't look like a product barcode" }); return; } setBusy(p.id); window.OfficeAPI.addBarcode(p.id, { value: digits, units: 1, isPrimary: true }) .then(() => { setBusy(null); setCodes((c) => Object.assign({}, c, { [p.id]: "" })); pushToast({ kind: "paid", icon: "check", title: "Barcode added", meta: p.name + " — it scans at the till now" }); return load(); }) .catch((e) => { setBusy(null); pushToast({ kind: "hold", icon: "alert", title: "Couldn't add it", meta: String((e && e.message) || e) }); }); } const cols = "minmax(220px, 2fr) 150px 80px 110px 240px"; return ( {err &&
Couldn't load: {err}
} {!err && rows == null &&
Loading…
} {!err && rows && rows.length === 0 && (
Nothing waiting. Items land here when an invoice is received and the wholesaler sent no usable barcode — they sell by name in the meantime.
)} {!err && rows && rows.length > 0 && (
ItemSupplierOn hand Last invoicedScan the pack
{rows.map((r) => (
{r.name} {r.supplierName || "—"} {rlvNum(r.onHand)} {r.lastInvoiced || "—"} setCodes((c) => Object.assign({}, c, { [r.id]: e.target.value }))} onKeyDown={(e) => { if (e.key === "Enter") attach(r); }}/>
))}
)}
); } // Replaces the demo prototype registered by office-screens-receiving.jsx — // this file loads after it, so the same key now points at the live screen. window.OFFICE_SCREENS = Object.assign(window.OFFICE_SCREENS || {}, { receiving: ReceivingLive, barcodeQueue: BarcodeQueueScreen, });