// --------------------------------------------------------------------------- // Inbox page (sub-project 6, SP14). // // Full-bleed dark working surface for the five-lane triage workflow. // Wires useInboxLanes + Lane + InboxHeader + BulkBar. Bulk actions call // the inbox API client and refetch on completion. // // SP14: added the `payer_rejected` lane (277CA STC A4/A6/A7) and the // Acknowledge bulk action. The Payer-Rejected lane sits between // `rejected` (envelope) and `candidates` (recon opportunities) so the // operator's eye flow is: envelope problems → payer problems → // auto-recon opportunities → claims still in flight → done today. // --------------------------------------------------------------------------- import { useState } from "react"; import { useNavigate } from "react-router-dom"; import { cn } from "@/lib/utils"; import { Lane, type LaneRow } from "@/components/inbox/Lane"; import { InboxHeader } from "@/components/inbox/InboxHeader"; import { BulkBar } from "@/components/inbox/BulkBar"; import { RemitDrawer } from "@/components/RemitDrawer"; import { useRemitDrawerUrlState } from "@/hooks/useRemitDrawerUrlState"; import { useInboxLanes } from "@/hooks/useInboxLanes"; import { exportInboxCsvUrl, dismissCandidates, resubmitRejected, resubmitRejectedWithDownload, acknowledgePayerRejected, } from "@/lib/inbox-api"; import { downloadBlob } from "@/lib/download"; type LaneKey = | "rejected" | "payer_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(); // SP21 Phase 4 Task 4.4: drill-down from inbox rows. The hook reads // `?remit=` off `window.location.search` so opening a row from the // /inbox URL pushes `?remit=ID` onto /inbox itself (it doesn't // navigate to /remittances). The drawer just opens in-place. const navigate = useNavigate(); const { remitId, open, close } = useRemitDrawerUrlState(); const [selected, setSelected] = useState>({ rejected: [], payer_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(); } } async function onAcknowledge() { // SP14: drop selected payer-rejected claims from the working // surface. The backend persists an `acknowledged_at` timestamp; // the original 277CA rejection (status code, reason, source 277CA // id) stays intact for the SP11 audit log. const ids = selected.payer_rejected; if (ids.length === 0) return; await acknowledgePayerRejected(ids); setSelected((prev) => ({ ...prev, payer_rejected: [] })); 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}
); } // SP14: payer_rejected is also actionable — a 277CA rejection that // needs operator follow-up. Without it, payer-side rejections would // hide behind the 999 envelope "rejected" lane and the operator // could miss a high-value denial. const needEyes = lanes.rejected.length + lanes.payer_rejected.length + lanes.candidates.length + lanes.unmatched.length; return (
{/* Ambient halo — a soft, fixed radial that creates a subtle lighter zone behind the InboxHeader. The inbox has its own dark ticker-tape background so it doesn't receive the body wash; this overlay restores the "lit from above" feel that the main pages get from the body::before softbox, in the same warm-amber accent family as the rest of the surface. Pointer-events disabled. */}
{/* SP21 Phase 4 Task 4.4: RemitDrawer mounts here so a row click in the candidates / unmatched lanes drills into the parent remit. The drawer portals into document.body (Radix Dialog), so the surrounding dark surface is decorative — the drawer overlays it when open. The hook reads `?remit=` off the URL, so deep links restore the open remit on reload. */} { // No cheatsheet on the inbox surface — `?` is a no-op // here, but the prop is required by the drawer's contract. }} /> {/* Lane grid transitions directly into the summary strip below. The previous "fold" divider with italic serif arrows and "Five lanes · one queue" caption was the page's last warm- paper print-shop artifact — removed so the ticker-tape aesthetic carries all the way through. */}
{ if (row.kind === "claim") { navigate(`/claims?claim=${encodeURIComponent(row.id)}`); } }} onSelectionChange={(ids) => setLaneSelected("rejected", ids)} /> {/* SP14: payer-rejected (277CA STC A4/A6/A7). Distinct accent from the 999 envelope rejected lane — the operator's eye flow is: 999 problems → payer problems → reconciliation opportunities → claims still in flight → done today. */} { if (row.kind === "claim") { navigate(`/claims?claim=${encodeURIComponent(row.id)}`); } }} onSelectionChange={(ids) => setLaneSelected("payer_rejected", ids)} /> open(row.id)} onSelectionChange={(ids) => setLaneSelected("candidates", ids)} /> { if (row.kind === "remit") { open(row.id); } else if (row.kind === "claim") { navigate( `/claims?claim=${encodeURIComponent(row.id)}`, ); } }} onSelectionChange={(ids) => setLaneSelected("unmatched", ids)} /> { if (row.kind === "claim") { navigate(`/claims?claim=${encodeURIComponent(row.id)}`); } }} onSelectionChange={(ids) => setLaneSelected("done_today", ids)} />
{/* ---------------------------------------------------------------- TICKER-TAPE QUEUE SUMMARY A dark terminal panel below the lanes that aggregates the eye-flow across the queue. Sits on the same --tt-bg-elev surface as the Lane cards so the page reads as one continuous instrument instead of mixing a paper-toned "ledger" panel into the terminal aesthetic. ---------------------------------------------------------------- */}
{/* Header row — machine-tag eyebrow + editorial headline + aggregate Need eyes count, separated from the lane metrics by a hairline. */}
Eye-flow · five lanes, one queue

at a glance.

Need eyes
{needEyes}
{/* Lane metrics row — each tile carries its own accent dot and count, so the operator can read the queue breakdown at a glance without scanning back up to the Lane grid. */}
{/* Per-lane bulk bars. Each shows when its lane has a selection. */} {}} onDismiss={() => {}} onExport={() => onExport("rejected")} /> {}} onAcknowledge={onAcknowledge} onDismiss={() => {}} onExport={() => onExport("payer_rejected")} /> {}} onAcknowledge={() => {}} onDismiss={onDismiss} onExport={() => onExport("candidates")} /> {}} onAcknowledge={() => {}} onDismiss={() => {}} onExport={() => onExport("unmatched")} /> {}} onAcknowledge={() => {}} 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 && ( ); } // --------------------------------------------------------------------------- // SummaryTile — ticker-tape metric for the eye-flow strip. Each tile // carries the lane's accent dot + label + count in the same color // family the Lane component uses (oxblood / amber / ink-blue / muted), // so the operator can read the queue breakdown at a glance without // scanning back up to the lane grid. // --------------------------------------------------------------------------- const SUMMARY_ACCENTS: Record< "oxblood" | "amber" | "ink" | "muted", { dot: string; value: string } > = { oxblood: { dot: "var(--tt-oxblood)", value: "var(--tt-ink)", }, amber: { dot: "var(--tt-amber)", value: "var(--tt-amber)", }, ink: { dot: "var(--tt-ink-blue)", value: "var(--tt-ink)", }, muted: { dot: "var(--tt-muted)", value: "var(--tt-ink-dim)", }, }; function SummaryTile({ label, value, tone, }: { label: string; value: number; tone: "oxblood" | "amber" | "ink" | "muted"; }) { const a = SUMMARY_ACCENTS[tone]; return (
{label}
{value}
{value === 0 ? "clear" : value === 1 ? "row" : "rows"}
); }