/* ============================================================= CoreIQ Office — Invoice capture: upload -> AI parse -> confirm -> post. Three screens: Upload (drop zone + settings), Capture queue (files and their parse status), Confirm (original image left, extracted fields right, CHECKS + Post/Save draft/Reject at the bottom). Nothing here posts money on its own — every Post click calls OfficeAPI.postInvoiceCapture, which the API answers by calling the SAME createManualInvoice the hand-keyed "Enter invoice" modal uses. Auto-post (when enabled at upload) is a server-side decision made from facts, never from the confidence numbers this screen displays — those are shown here purely so a human knows where to look. Uses shared bare globals: OIcon, Screen, StatusPill, window.useOffice, window.OfficeAPI (see office-screens-expand-1.jsx / office-app.jsx). Prefix: icap* ============================================================= */ const { useState: icapUseState, useEffect: icapUseEffect, useCallback: icapUseCallback, useRef: icapUseRef } = React; const icapMoney = (c) => (c == null ? "—" : "$" + (Number(c) / 100).toLocaleString("en-AU", { minimumFractionDigits: 2, maximumFractionDigits: 2 })); const icapDate = (s) => (s ? String(s).slice(0, 10) : "—"); const icapMaxMb = 20; const icapAccept = ".pdf,.png,.jpg,.jpeg,.tif,.tiff,application/pdf,image/png,image/jpeg,image/tiff"; const icapAcceptedTypes = ["application/pdf", "image/png", "image/jpeg", "image/jpg", "image/tiff"]; function icapConfKind(c) { if (c == null) return "neutral"; if (c >= 0.85) return "paid"; if (c >= 0.6) return "hold"; return "void"; } function IcapConfBadge({ confidence, reason }) { if (confidence == null) return null; const pct = Math.round(confidence * 100); return ( {pct}% ); } // --------------------------------------------------------------------- // Upload // --------------------------------------------------------------------- function IcapUploadScreen() { const { pushToast, setScreen } = window.useOffice(); const [sites, setSites] = icapUseState([]); const [siteId, setSiteId] = icapUseState(""); const [autoPost, setAutoPost] = icapUseState(false); const [splitMulti, setSplitMulti] = icapUseState(false); const [files, setFiles] = icapUseState([]); // [{file, error}] const [dragging, setDragging] = icapUseState(false); const [busy, setBusy] = icapUseState(false); const inputRef = icapUseRef(null); icapUseEffect(() => { let alive = true; window.OfficeAPI.listSites().then((ss) => { if (!alive) return; setSites(ss || []); setSiteId((cur) => cur || ((ss && ss[0]) ? ss[0].id : "")); }).catch(() => {}); return () => { alive = false; }; }, []); function addFiles(list) { const next = Array.from(list).map((f) => { const okType = icapAcceptedTypes.includes(f.type) || /\.(pdf|png|jpe?g|tiff?)$/i.test(f.name); const okSize = f.size <= icapMaxMb * 1024 * 1024; const error = !okType ? "Unsupported type — PDF, PNG, JPG or TIFF only" : !okSize ? "Over " + icapMaxMb + "MB" : null; return { file: f, error }; }); setFiles((fs) => fs.concat(next)); } function removeFile(i) { setFiles((fs) => fs.filter((_, j) => j !== i)); } const validFiles = files.filter((f) => !f.error); const canParse = !!siteId && validFiles.length > 0 && !busy; function parse() { if (!canParse) return; setBusy(true); window.OfficeAPI.uploadInvoiceCaptureFiles(validFiles.map((f) => f.file), { siteId, autoPost, splitMulti }) .then((r) => { setBusy(false); const okCount = (r.files || []).length; if (okCount > 0) { pushToast({ kind: "paid", icon: "check", title: okCount + " file" + (okCount === 1 ? "" : "s") + " uploaded", meta: "Parsing now — track progress in the Capture queue" }); } if (r.errors && r.errors.length) { pushToast({ kind: "hold", icon: "alert", title: "Some files were skipped", meta: r.errors.slice(0, 2).join("; ") }); } setScreen("invoiceCaptureQueue"); }) .catch((e) => { setBusy(false); pushToast({ kind: "hold", icon: "alert", title: "Upload failed", meta: String((e && e.message) || e) }); }); } return ( setScreen("invoiceCaptureQueue")}> Capture queue} >
{ e.preventDefault(); setDragging(true); }} onDragLeave={() => setDragging(false)} onDrop={(e) => { e.preventDefault(); setDragging(false); if (e.dataTransfer.files) addFiles(e.dataTransfer.files); }} onClick={() => inputRef.current && inputRef.current.click()} >
Drop invoices here, or click to browse
PDF, PNG, JPG or TIFF · up to {icapMaxMb}MB each · several at once
{ if (e.target.files) addFiles(e.target.files); e.target.value = ""; }} />
{files.length > 0 && (
{files.map((f, i) => (
{f.file.name} {(f.file.size / 1024 / 1024).toFixed(1)}MB {f.error && {f.error}}
))}
)}

What happens next

  1. 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.
  2. The parsed invoice lands in the Capture queue for you to check.
  3. 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.
  4. 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.
  5. 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 && (
Reading {parsingCount} {parsingCount === 1 ? "file" : "files"}…
)} {err &&
Couldn't load: {err}
} {files === null && !err &&
Loading…
} {files && files.length === 0 && !err && (
Nothing uploaded yet.
)} {files && files.length > 0 && shown.length === 0 && !err && (
{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. )}
))}
)}
))}
)}
); } // --------------------------------------------------------------------- // Confirm — image LEFT, fields RIGHT, checks + actions pinned at bottom // --------------------------------------------------------------------- function IcapMoneyInput({ cents, onCommit }) { const [v, setV] = icapUseState(cents == null ? "" : (cents / 100).toFixed(2)); icapUseEffect(() => { setV(cents == null ? "" : (cents / 100).toFixed(2)); }, [cents]); return ( setV(e.target.value)} onBlur={() => { const num = Number(v); onCommit(v.trim() === "" ? null : Math.round(num * 100)); }} /> ); } function IcapField({ label, confidence, reason, children }) { const low = confidence != null && confidence < 0.85; return (
{label}
{children} {reason && low &&
{reason}
}
); } function IcapLineCard({ line, editable, onPatch }) { const [desc, setDesc] = icapUseState(line.confirmedDescription ?? line.descriptionRaw); const [qty, setQty] = icapUseState(String(line.confirmedQuantity ?? line.quantityRaw ?? "")); const [cost, setCost] = icapUseState((((line.confirmedUnitCostCents ?? line.unitCostCentsRaw) || 0) / 100).toFixed(2)); const [gstFree, setGstFree] = icapUseState(!!(line.confirmedGstFree ?? line.gstFreeRaw)); icapUseEffect(() => { setDesc(line.confirmedDescription ?? line.descriptionRaw); setQty(String(line.confirmedQuantity ?? line.quantityRaw ?? "")); setCost((((line.confirmedUnitCostCents ?? line.unitCostCentsRaw) || 0) / 100).toFixed(2)); setGstFree(!!(line.confirmedGstFree ?? line.gstFreeRaw)); }, [line.id, line.confirmedDescription, line.confirmedQuantity, line.confirmedUnitCostCents, line.confirmedGstFree]); const lineTotalCents = (Number(qty) || 0) * Math.round((Number(cost) || 0) * 100); const matchedSuggestion = (line.suggestedMatches || []).find((s) => s.productId === line.matchedProductId); return (
{editable ? ( setDesc(e.target.value)} onBlur={() => { if (desc.trim() && desc !== (line.confirmedDescription ?? line.descriptionRaw)) onPatch({ confirmedDescription: desc }); }} /> ) : {desc}} {editable ? ( <> setQty(e.target.value)} onBlur={() => onPatch({ confirmedQuantity: Number(qty) || 0 })} /> × setCost(e.target.value)} onBlur={() => onPatch({ confirmedUnitCostCents: Math.round((Number(cost) || 0) * 100) })} /> ) : {qty} × {icapMoney(Math.round((Number(cost) || 0) * 100))}} {icapMoney(lineTotalCents)} {editable && ( )}
{line.matchedProductId ? ( Matched{matchedSuggestion ? ": " + matchedSuggestion.name : ""} ) : line.suggestedMatches && line.suggestedMatches.length > 0 ? ( Possible match: {line.suggestedMatches.slice(0, 3).map((s) => ( ))} ) : ( No product match — goes to the Barcode queue after receiving )} {line.supplierCodeRaw && code {line.supplierCodeRaw}}
); } const ICAP_CHECK_LABELS = [ ["totalsBalance", "Totals balance"], ["gst", "GST"], ["duplicate", "Duplicate check"], ["purchaseOrder", "Purchase order"], ["goodsReceipt", "Goods receipt"], ]; function IcapConfirmScreen() { const { screenState, setScreen, pushToast } = window.useOffice(); const captureId = screenState.invoiceCaptureId; const [cap, setCap] = icapUseState(null); const [err, setErr] = icapUseState(null); const [suppliers, setSuppliers] = icapUseState([]); const [fileUrl, setFileUrl] = icapUseState(null); const [fileIsPdf, setFileIsPdf] = icapUseState(true); const [busy, setBusy] = icapUseState(false); const [rejecting, setRejecting] = icapUseState(false); const [rejectReason, setRejectReason] = icapUseState(""); const load = icapUseCallback(() => { if (!captureId) return; window.OfficeAPI.getInvoiceCapture(captureId).then((c) => { setCap(c); setErr(null); }).catch((e) => setErr(String((e && e.message) || e))); }, [captureId]); icapUseEffect(() => { load(); }, [load]); icapUseEffect(() => { window.OfficeAPI.listSuppliers().then(setSuppliers).catch(() => {}); }, []); icapUseEffect(() => { if (!captureId) return; let alive = true, url = null; window.OfficeAPI.invoiceCaptureFile(captureId) .then((blob) => { if (!alive) return; url = URL.createObjectURL(blob); setFileIsPdf(blob.type === "application/pdf"); setFileUrl(url); }) .catch(() => {}); return () => { alive = false; if (url) URL.revokeObjectURL(url); }; }, [captureId]); if (!captureId) { return
No capture selected — open one from the Capture queue.
; } if (err) return
{err}
; if (!cap) return
Loading…
; const editable = cap.status === "review"; const pendingCount = cap.lines.filter((l) => l.status !== "confirmed").length; const checks = cap.checks || {}; function patchField(patch) { return window.OfficeAPI.patchInvoiceCapture(cap.id, patch).then(load) .catch((e) => pushToast({ kind: "hold", icon: "alert", title: "Couldn't save", meta: String((e && e.message) || e) })); } function patchLine(lineId, patch) { return window.OfficeAPI.patchInvoiceCaptureLine(lineId, patch).then(load) .catch((e) => pushToast({ kind: "hold", icon: "alert", title: "Couldn't save line", meta: String((e && e.message) || e) })); } function doPost() { if (busy) return; setBusy(true); window.OfficeAPI.postInvoiceCapture(cap.id) .then((r) => { setBusy(false); pushToast({ kind: "paid", icon: "check-circle", title: "Posted", meta: r.status === "review" ? "Landed in Receiving, needs attention (totals didn't quite reconcile)" : "In Pending — ready to receive", }); setScreen("invoiceCaptureQueue"); }) .catch((e) => { setBusy(false); pushToast({ kind: "hold", icon: "alert", title: "Couldn't post", meta: String((e && e.message) || e) }); }); } function doSaveDraft() { setBusy(true); window.OfficeAPI.saveInvoiceCaptureDraft(cap.id) .then(() => { setBusy(false); pushToast({ kind: "paid", icon: "check", title: "Draft saved" }); load(); }) .catch((e) => { setBusy(false); pushToast({ kind: "hold", icon: "alert", title: "Couldn't save", meta: String((e && e.message) || e) }); }); } function doReject() { setBusy(true); window.OfficeAPI.rejectInvoiceCapture(cap.id, rejectReason) .then(() => { setBusy(false); pushToast({ kind: "paid", icon: "check", title: "Rejected" }); setScreen("invoiceCaptureQueue"); }) .catch((e) => { setBusy(false); pushToast({ kind: "hold", icon: "alert", title: "Couldn't reject", meta: String((e && e.message) || e) }); }); } return ( setScreen("invoiceCaptureQueue")}> Back to queue} >
{fileUrl && fileIsPdf && } {fileUrl && !fileIsPdf && Original invoice} {!fileUrl &&
Loading the document…
}
{cap.status !== "review" && (
{cap.status === "posted" && <>Posted{cap.confirmedBy ? " by " + cap.confirmedBy : ""}.} {cap.status === "rejected" && <>Rejected{cap.rejectReason ? ": " + cap.rejectReason : ""}.}
)} {cap.kind === "service" && (
This looks like a service invoice — captured for the record, but not posted (Office has no ledger to code it to). {editable && }
)}
{editable ? ( ) : {cap.supplierName || cap.supplierNameRaw || "—"}} {cap.supplierAbnRaw || "—"} {editable ? ( e.target.value !== (cap.invoiceNumber || "") && patchField({ invoiceNumber: e.target.value })} /> ) : {cap.invoiceNumber || "—"}} {editable ? patchField({ invoiceDate: e.target.value })} /> : {icapDate(cap.invoiceDate)}} {editable ? patchField({ dueDate: e.target.value || null })} /> : {icapDate(cap.dueDate)}} {editable ? ( e.target.value !== (cap.poReference || "") && patchField({ poReference: e.target.value || null })} /> ) : {cap.poReference || "—"}} {editable ? patchField({ freightCents: c })} /> : {icapMoney(cap.freightCents)}} {editable ? patchField({ gstCents: c })} /> : {icapMoney(cap.gstCents)}} {editable ? patchField({ totalCents: c })} /> : {icapMoney(cap.totalCents)}}

Lines

{cap.lines.length} line{cap.lines.length === 1 ? "" : "s"}{pendingCount > 0 ? " · " + pendingCount + " to confirm" : ""}
{cap.lines.map((l) => patchLine(l.id, patch)} />)} {cap.lines.length === 0 &&
No lines were read from this document.
}
{ICAP_CHECK_LABELS.map(([key, label]) => { const chk = checks[key]; if (!chk) return null; return ( {label} ); })}
{editable && (
{pendingCount > 0 ? pendingCount + " line" + (pendingCount === 1 ? "" : "s") + " left to confirm" : "All lines confirmed"}
)}
{rejecting && (
setRejecting(false)}>
e.stopPropagation()}>

Reject this capture