Files
cyclone/src/pages/Inbox.tsx
T
Tyler 5c9365ec33 feat(sp14): 5-lane Inbox UI with Payer-Rejected acknowledge action
Closes the gap between the SP10 backend (5 lanes) and the SP6
frontend (4 lanes). The Payer-Rejected lane (277CA STC A4/A6/A7)
is now rendered alongside Rejected/Candidates/Unmatched/Done,
with an Acknowledge bulk action that drops claims from the
working surface without erasing the original 277CA rejection
event (audit log stays intact, SP11).

Backend:
* Migration 0010: add payer_rejected_acknowledged_at +
  payer_rejected_acknowledged_actor columns + partial index.
* db.py: surface the two new columns on the Claim model.
* inbox_lanes.py: filter acknowledged claims out of the
  payer_rejected lane; expose the new fields on the row payload
  for forward-compat (e.g. a future 'Recently acknowledged' view).
* api.py:
  - POST /api/inbox/payer-rejected/acknowledge
    Bulk-acknowledge. Idempotent. Returns transitioned /
    already_acked / not_found / not_rejected counts so the UI
    can show '3 of 5 were already acknowledged' on a noop bulk.
    Writes a 'claim.payer_rejected_acknowledged' event to the
    SP11 hash-chained audit log.
  - GET /api/inbox/export.csv: accept 'payer_rejected' lane.
* test_acks.py: bump user_version assertion to 10.
* test_lane_filter_acknowledged.py: 4 tests for the lane filter
  and forward-compat row payload.
* test_payer_rejected_acknowledge.py: 6 tests for the endpoint
  (happy path, idempotency, no-op on non-rejected, missing
  ids, 400 on empty, audit-log wiring + chain integrity).

Frontend:
* lib/inbox-api.ts: add payer_rejected to InboxLanes, add
  acknowledgePayerRejected(), update exportInboxCsvUrl union.
* hooks/useInboxLanes.ts: add payer_rejected to initial state.
* hooks/useInboxLanes.test.ts: add payer_rejected to mocks.
* components/inbox/BulkBar.tsx: add 'payer_rejected' lane with
  Acknowledge action (no Resubmit, no Dismiss — payer-rejected
  is not eligible for either).
* components/inbox/BulkBar.test.tsx: add payer_rejected test.
* pages/Inbox.tsx: render the 5th lane, hook up onAcknowledge,
  include payer_rejected in the needEyes count.
* pages/Inbox.test.tsx: 3 new tests (5-lane render, need-eyes
  count, acknowledge action hits the right endpoint).
* components/inbox/InboxHeader.tsx: doc comment now explains
  why payer_rejected rolls up into need-eyes.

Pre-existing typecheck warnings in BulkBar.test.tsx / InboxRow
.test.tsx / Lane.tsx / download.test.ts are unchanged from
main — not touched here.

Test counts: backend 724 -> 734 (+10). Frontend 350 -> 354 (+4).
2026-06-21 00:13:47 -06:00

407 lines
14 KiB
TypeScript

// ---------------------------------------------------------------------------
// 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 { 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,
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();
const [selected, setSelected] = useState<Record<LaneKey, string[]>>({
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 (
<div
className="min-h-screen p-8 mono flex items-center gap-2"
style={{ background: "var(--tt-bg)", color: "var(--tt-ink-dim)", fontSize: 12 }}
>
<span
aria-hidden
className="inline-block h-1.5 w-1.5 rounded-full animate-pulse-dot"
style={{ background: "var(--tt-amber)" }}
/>
loading inbox
</div>
);
}
if (error) {
return (
<div
className="min-h-screen p-8 mono flex flex-col gap-2"
style={{ background: "var(--tt-bg)", color: "var(--tt-oxblood)", fontSize: 12 }}
>
<span className="uppercase tracking-[0.18em]" style={{ fontSize: 10, fontWeight: 700 }}>
Connection error
</span>
<span>{error.message}</span>
</div>
);
}
// 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 (
<div
className="min-h-screen"
style={{ background: "var(--tt-bg)", color: "var(--tt-ink)" }}
>
<InboxHeader needEyesCount={needEyes} doneTodayCount={lanes.done_today.length} />
<main className="px-6 pb-24 pt-4 flex gap-4 items-stretch flex-wrap">
<Lane
name="REJECTED"
accent="oxblood"
rows={lanes.rejected}
onRowClick={() => {}}
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.
*/}
<Lane
name="PAYER REJECTED"
accent="oxblood"
rows={lanes.payer_rejected}
onRowClick={() => {}}
onSelectionChange={(ids) => setLaneSelected("payer_rejected", ids)}
/>
<Lane
name="CANDIDATES"
accent="amber"
rows={lanes.candidates}
onRowClick={() => {}}
onSelectionChange={(ids) => setLaneSelected("candidates", ids)}
/>
<Lane
name="UNMATCHED"
accent="ink-blue"
rows={lanes.unmatched}
onRowClick={() => {}}
onSelectionChange={(ids) => setLaneSelected("unmatched", ids)}
/>
<Lane
name="DONE"
accent="muted"
rows={lanes.done_today}
onRowClick={() => {}}
onSelectionChange={(ids) => setLaneSelected("done_today", ids)}
/>
</main>
{/* Per-lane bulk bars. Each shows when its lane has a selection. */}
<BulkBar
lane="rejected"
count={selected.rejected.length}
onResubmit={onResubmit}
onAcknowledge={() => {}}
onDismiss={() => {}}
onExport={() => onExport("rejected")}
/>
<BulkBar
lane="payer_rejected"
count={selected.payer_rejected.length}
onResubmit={() => {}}
onAcknowledge={onAcknowledge}
onDismiss={() => {}}
onExport={() => onExport("payer_rejected")}
/>
<BulkBar
lane="candidates"
count={selected.candidates.length}
onResubmit={() => {}}
onAcknowledge={() => {}}
onDismiss={onDismiss}
onExport={() => onExport("candidates")}
/>
<BulkBar
lane="unmatched"
count={selected.unmatched.length}
onResubmit={() => {}}
onAcknowledge={() => {}}
onDismiss={() => {}}
onExport={() => onExport("unmatched")}
/>
<BulkBar
lane="done_today"
count={selected.done_today.length}
onResubmit={() => {}}
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 && (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4"
style={{ background: "rgba(0,0,0,0.65)", backdropFilter: "blur(4px)" }}
data-testid="resubmit-bundle-modal"
role="dialog"
aria-label="Resubmit and download bundle"
>
<div
className="w-[min(34rem,90vw)] rounded-md border p-6 mono"
style={{
background: "var(--tt-bg)",
borderColor: "var(--tt-amber)",
color: "var(--tt-ink)",
boxShadow:
"0 0 0 1px hsl(36 88% 56% / 0.18), 0 24px 60px -12px hsl(0 0% 0% / 0.7), inset 0 1px 0 0 hsl(0 0% 100% / 0.04)",
}}
>
<div className="flex items-center gap-2 mb-3">
<span
aria-hidden
className="inline-block h-2 w-2 rounded-full animate-pulse-dot"
style={{
background: "var(--tt-amber)",
boxShadow: "0 0 8px var(--tt-amber)",
}}
/>
<span
className="eyebrow"
style={{ color: "var(--tt-amber)" }}
>
Resubmit batch
</span>
</div>
<h2
className="display text-2xl mb-3"
style={{ color: "var(--tt-ink)", letterSpacing: "-0.02em" }}
>
Resubmit {selected.rejected.length} claims
</h2>
<p
className="mb-6 text-sm leading-relaxed"
style={{ color: "var(--tt-ink-dim)" }}
>
Move these claims back to{" "}
<span
className="px-1.5 py-0.5 rounded-sm"
style={{
background: "var(--tt-amber)",
color: "var(--tt-bg)",
fontWeight: 700,
}}
>
submitted
</span>
? Optionally download a bundle of regenerated 837 files
(one <code>.x12</code> per claim) useful if you're
about to ship them to the clearinghouse.
</p>
<div className="flex justify-end gap-2.5">
<button
type="button"
onClick={onResubmitOnly}
disabled={bundleSubmitting}
data-testid="resubmit-modal-only"
className="px-4 py-2 rounded-sm text-[11px] uppercase tracking-[0.14em] transition-all hover:bg-[color:var(--tt-amber)]/10 disabled:opacity-50 focus-visible:outline-none focus-visible:bg-[color:var(--tt-amber)]/10"
style={{
border: "1px solid var(--tt-amber)",
color: "var(--tt-amber)",
background: "transparent",
fontWeight: 600,
}}
>
Resubmit only
</button>
<button
type="button"
onClick={onResubmitAndDownload}
disabled={bundleSubmitting}
data-testid="resubmit-modal-download"
className="px-4 py-2 rounded-sm text-[11px] uppercase tracking-[0.14em] transition-all hover:brightness-110 disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--tt-amber)] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--tt-bg)]"
style={{
background: "var(--tt-amber)",
color: "var(--tt-bg)",
fontWeight: 700,
}}
>
Resubmit + Download
</button>
</div>
</div>
</div>
)}
{/* 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 && (
<div
className="fixed inset-0 z-40 cursor-wait"
style={{ background: "rgba(0,0,0,0.15)", backdropFilter: "blur(1px)" }}
data-testid="resubmit-progress-overlay"
aria-hidden="true"
/>
)}
</div>
);
}