/* =============================================================
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(); }}
/>
{busy ? "Saving…" : "Save & receive"}
)}
{(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") &&
{busy ? "Receiving…" : "Receive"} }
{open ? "Hide" : "Lines"}
{open && (
{plan == null &&
Loading…
}
{plan && plan.error &&
Couldn't load the line plan.
}
{plan && !plan.error && (
Product Reorder code Qty Line ex GST Stock 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()}>
Barcode (scan) Product Qty
Cost ex GST GST-free Line 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 && (
)}
{/* 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."}
Cancel
{busy ? : } Save invoice
);
}
// 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()}>
Upload a PDF or photo
Parsed automatically — you confirm before anything posts
Key it by hand
Type the supplier, lines and total yourself
);
}
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={
setChoosingEntry(true)}> Enter invoice
Refresh
}
>
{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 # Supplier Date
Lines Waiting
Total Status
{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 && (
Item Supplier On hand
Last invoiced Scan 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); }}/>
attach(r)}>{busy === r.id ? "Saving…" : "Add"}
))}
)}
);
}
// 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,
});