// --------------------------------------------------------------------------- // Inbox page (sub-project 6). // // Full-bleed dark working surface for the four-lane triage workflow. // Wires useInboxLanes + Lane + InboxHeader + BulkBar. Bulk actions call // the inbox API client and refetch on completion. // --------------------------------------------------------------------------- import { useState } from "react"; import { Lane, type LaneRow } from "@/components/inbox/Lane"; import { InboxHeader } from "@/components/inbox/InboxHeader"; import { BulkBar } from "@/components/inbox/BulkBar"; import { useInboxLanes } from "@/hooks/useInboxLanes"; import { exportInboxCsvUrl, dismissCandidates, resubmitRejected, resubmitRejectedWithDownload, } from "@/lib/inbox-api"; import { downloadBlob } from "@/lib/download"; import { RoleGate } from "@/auth/RoleGate"; type LaneKey = "rejected" | "candidates" | "unmatched" | "done_today"; function rowKey(row: LaneRow): string { return row.kind === "remit" ? (row as { payer_claim_control_number: string }).payer_claim_control_number : row.id; } export default function Inbox() { const { lanes, loading, error, refetch } = useInboxLanes(); const [selected, setSelected] = useState>({ rejected: [], candidates: [], unmatched: [], done_today: [], }); function setLaneSelected(lane: LaneKey, ids: string[]) { setSelected((prev) => ({ ...prev, [lane]: ids })); } // Resubmit-bundle download modal state (SP8). When the user selects N>1 // rejected claims and hits Resubmit, we surface a confirm modal that // lets them choose "Resubmit only" (just move the state, no download) // vs "Resubmit + Download" (move the state AND hand them a zip of the // regenerated 837 files). Single-claim resubmit skips the modal — // there's nothing to bundle, the download is implicit in the action. const [bundleModalOpen, setBundleModalOpen] = useState(false); const [bundleSubmitting, setBundleSubmitting] = useState(false); async function performResubmit(ids: string[], withDownload: boolean) { if (withDownload) { const { blob, filename, serializeErrors } = await resubmitRejectedWithDownload(ids); downloadBlob(filename, blob); // Surface per-claim serialization failures (rare — usually means // raw_json is corrupted for one of the claims). Fail-soft: don't // throw, the user still got the zip with the claims that did work. if (serializeErrors.length > 0) { console.warn( "resubmit bundle: skipped", serializeErrors.length, "claims (couldn't regenerate 837):", serializeErrors, ); } } else { await resubmitRejected(ids); } setSelected((prev) => ({ ...prev, rejected: [] })); await refetch(); } async function onResubmit() { const ids = selected.rejected; if (ids.length === 0) return; if (ids.length === 1) { // No bundle to download — single file, just do it. The user can // always grab the .x12 from the claim drawer afterward. setBundleSubmitting(true); try { await performResubmit(ids, false); } finally { setBundleSubmitting(false); } return; } setBundleModalOpen(true); } async function onResubmitOnly() { const ids = selected.rejected; setBundleModalOpen(false); setBundleSubmitting(true); try { await performResubmit(ids, false); } finally { setBundleSubmitting(false); } } async function onResubmitAndDownload() { const ids = selected.rejected; setBundleModalOpen(false); setBundleSubmitting(true); try { await performResubmit(ids, true); } finally { setBundleSubmitting(false); } } async function onDismiss() { if (selected.candidates.length === 0) return; // Pair each selected remit with its first candidate claim (the row's // top match). This matches the dismiss UX described in the spec: the // user has already seen the top score, so we trust it for dismissal. const pairs: Array<{ claim_id: string; remit_id: string }> = []; for (const r of lanes.candidates) { if (!selected.candidates.includes(rowKey(r))) continue; const top = r.candidates[0]; if (top) pairs.push({ claim_id: top.claim_id, remit_id: r.id }); } if (pairs.length) { await dismissCandidates(pairs); setSelected((prev) => ({ ...prev, candidates: [] })); await refetch(); } } function onExport(lane: LaneKey) { if (selected[lane].length === 0) return; window.open(exportInboxCsvUrl(lane), "_blank"); } if (loading) { return (
loading inbox…
); } if (error) { return (
Connection error {error.message}
); } const needEyes = lanes.rejected.length + lanes.candidates.length + lanes.unmatched.length; return (
{}} onSelectionChange={(ids) => setLaneSelected("rejected", ids)} /> {}} onSelectionChange={(ids) => setLaneSelected("candidates", ids)} /> {}} onSelectionChange={(ids) => setLaneSelected("unmatched", ids)} /> {}} onSelectionChange={(ids) => setLaneSelected("done_today", ids)} />
{/* Per-lane bulk bars. Each shows when its lane has a selection. The two write-affordance BulkBars (rejected → resubmit, candidates → dismiss) are gated by RoleGate so a viewer-role account can still SEE the rows and select them but cannot trigger a state change. The export buttons live inside the same BulkBar component but are read-only, so we let them remain visible to viewers. */} {}} onExport={() => onExport("rejected")} /> {}} onDismiss={onDismiss} onExport={() => onExport("candidates")} /> {}} onDismiss={() => {}} onExport={() => onExport("unmatched")} /> {}} onDismiss={() => {}} onExport={() => onExport("done_today")} /> {/* Resubmit bundle modal — SP8. Shown when N>1 rejected claims are selected; lets the user choose to also download a zip of the regenerated 837 files. Single-claim resubmit skips this. */} {bundleModalOpen && (
Resubmit batch

Resubmit {selected.rejected.length} claims

Move these claims back to{" "} submitted ? Optionally download a bundle of regenerated 837 files (one .x12 per claim) — useful if you're about to ship them to the clearinghouse.

)} {/* Subtle progress overlay while a bulk resubmit is in flight, so the user knows the click registered even when the network is slow. Single-claim resubmit also benefits — no visible feedback before this and the inbox can sit there for a few hundred ms. */} {bundleSubmitting && ( ); }