Each file is read — text-native PDFs are transcribed directly; scans and photos go to a vision model. Nothing is guessed that isn't on the page.
The parsed invoice lands in the Capture queue for you to check.
Nothing posts until you've reviewed it side-by-side with the original — unless "Auto-post clean invoices" is on above, and even then only once the numbers themselves check out (never a confidence score): the supplier is known, the invoice number isn't a duplicate, and the lines add up to the printed total.
Unmatched line items never create a new product at a guessed cost — they go to the existing Barcode queue after receiving, same as any other invoice.
A service invoice (no goods — cleaning, software, a professional fee) is captured and shown but never posted; Office has no ledger to code it to.
);
}
// ---------------------------------------------------------------------
// Capture queue — file-first, one file usually has one capture nested
// ---------------------------------------------------------------------
// Where a capture sits in the whole journey, which does NOT end at "posted".
// Posting hands the invoice to Receiving; the stock only exists once someone
// receives it. A capture that has been posted but not yet received is still
// somebody's job, so it must not sit in the same pile as the finished ones.
function icapStage(c) {
if (c.status === "rejected") return "done";
if (c.status === "posted") {
// Service invoices are captured and never posted, so they can't get here.
return c.postedInvoiceStatus === "received" ? "done" : "receipt";
}
if (c.status === "review") return c.autoPostEligible ? "ready" : "attention";
return "attention"; // parsing/failed captures need a human eventually
}
const ICAP_TABS = [
{ k: "attention", l: "Needs attention" },
{ k: "ready", l: "Ready to confirm" },
{ k: "receipt", l: "Awaiting receipt" },
{ k: "done", l: "Done" },
{ k: "all", l: "All" },
];
function IcapQueueScreen() {
const { pushToast, setScreen, setScreenState } = window.useOffice();
const [files, setFiles] = icapUseState(null);
const [err, setErr] = icapUseState(null);
const [tab, setTab] = icapUseState("attention");
const [confirmDelete, setConfirmDelete] = icapUseState(null);
const load = icapUseCallback(() => {
window.OfficeAPI.listInvoiceCaptureFiles()
.then((fs) => { setFiles(fs || []); setErr(null); })
.catch((e) => setErr(String((e && e.message) || e)));
}, []);
icapUseEffect(() => {
load();
// Parsing (esp. a multi-page vision call) can take a while — poll rather
// than leave the queue looking frozen on "Parsing…".
const t = setInterval(load, 5000);
return () => clearInterval(t);
}, [load]);
function openCapture(id) {
setScreenState((st) => Object.assign({}, st, { invoiceCaptureId: id }));
setScreen("invoiceCaptureConfirm");
}
function discard(f) {
if (confirmDelete !== f.id) { setConfirmDelete(f.id); return; }
setConfirmDelete(null);
window.OfficeAPI.deleteInvoiceCaptureFile(f.id)
.then(() => { pushToast({ kind: "paid", icon: "check", title: "Upload discarded", meta: f.originalFilename }); load(); })
.catch((e) => pushToast({ kind: "hold", icon: "alert", title: "Couldn't discard it", meta: String((e && e.message) || e) }));
}
function retry(id) {
window.OfficeAPI.reparseInvoiceCaptureFile(id)
.then(() => { pushToast({ kind: "paid", icon: "check", title: "Re-parsing" }); load(); })
.catch((e) => pushToast({ kind: "hold", icon: "alert", title: "Couldn't retry", meta: String((e && e.message) || e) }));
}
const fileStatusPill = (f) => {
if (f.status === "uploaded" || f.status === "parsing") return ;
if (f.status === "failed") return ;
return ;
};
const captureStatusPill = (c) => {
if (c.status === "review") return c.autoPostEligible ? : ;
if (c.status === "posted") {
// Say which of the two "posted" states this is. "Posted" alone reads as
// finished, and it isn't until the stock is on the ledger.
if (c.postedInvoiceStatus === "received") return ;
if (c.postedInvoiceStatus === "review") return ;
return ;
}
if (c.status === "rejected") return ;
return ;
};
// Filter the CAPTURES inside each file, then drop files left with none —
// split-multi means one file can hold captures at different stages.
const parsingCount = (files || []).filter((f) => f.status === "uploaded" || f.status === "parsing").length;
const counts = { attention: 0, ready: 0, receipt: 0, done: 0, all: 0 };
for (const f of files || []) {
for (const c of f.captures || []) { counts[icapStage(c)] += 1; counts.all += 1; }
}
const shown = (files || [])
.map((f) => {
if (tab === "all") return f;
const caps = (f.captures || []).filter((c) => icapStage(c) === tab);
// A file that failed to parse has no captures at all — it belongs in
// Needs attention, since only a person can decide to retry it.
if (caps.length === 0 && !(tab === "attention" && (f.status === "failed" || (f.captures || []).length === 0))) return null;
return Object.assign({}, f, { captures: caps });
})
.filter(Boolean);
return (
({ k: t.k, l: t.l, badge: t.k === "attention" ? (counts.attention || null) : t.k === "ready" ? (counts.ready || null) : null }))}
activeTab={tab}
onTab={setTab}
actions={
}
>
{/* Parsing is transient and worth seeing whichever tab you are on —
otherwise a fresh upload looks like it vanished. */}
{parsingCount > 0 && (
{tab === "attention" ? "Nothing needs attention — everything read cleanly."
: tab === "ready" ? "Nothing waiting to be confirmed."
: tab === "receipt" ? "Nothing waiting on receipt — every posted invoice has had its stock landed."
: tab === "done" ? "Nothing finished yet."
: "Nothing here."}
)}
{files && shown.length > 0 && (
{shown.map((f) => (
{f.originalFilename}
{fileStatusPill(f)}
{f.status === "failed" && (
)}
{/* Not offered once something here has been posted — the API
refuses that too, since the document is the record of a
real invoice. A shipping note that is not an invoice at all
should just go away rather than sit in the queue forever. */}
{!(f.captures || []).some((c) => c.status === "posted") && (
)}
{f.errorReason &&
{f.errorReason}
}
{f.captures.length > 0 && (
{f.captures.map((c) => (
openCapture(c.id)}>
{c.supplierName || "Unknown supplier"}{c.invoiceNumber || "no number read"}{c.kind === "service" ? "Service — not posted" : ""}{icapMoney(c.totalCents)}
{captureStatusPill(c)}
{icapStage(c) === "receipt" && (
// The next action isn't on this screen — the stock
// lands from the Receiving worklist. Say so, and go.
)}