Files
cyclone/src/pages/Inbox.tsx
T
Tyler 9bca4b608a feat(release): v0.2.0 — batch 837 export, ClaimCard, theme tokens
Backend:

- New POST /api/batches/{id}/export-837: regenerate X12 837 files
  for a list of claim_ids into a ZIP using HCPF file naming standards,
  with a unique interchange/group control number per export. Wire
  the clearhouse Loop 1000A (NM1*41 + PER) and per-payer receiver
  (NM1*40) blocks so the serializer no longer falls back to
  CYCLONE / RECEIVER placeholders.
- /api/parse-837 and /api/parse-835 now surface the server-side
  batch_id in both JSON and NDJSON response shapes so the frontend
  can hit batch-scoped endpoints without an extra listBatches
  round-trip.
- Filename helpers and the 837 serializer updated to match the new
  HCPF envelope; tests cover batch export, parse batch_id, and the
  serializer's control-number uniqueness guarantee.

Frontend:
- New shared components: ClaimCard, ClaimCard837, DominantKpiCard,
  EditorialNote, ExportBar, TickerTape, and a charts/ set
  (BarChart, HBarChart, SegmentedBar, AgingBars).
- New useBatchExport hook driving ExportBar's download flow against
  the new endpoint.
- ClaimDrawer, Lane, and Layout migrated from raw CSS-variable
  colors to Tailwind theme tokens (bg-card, text-foreground,
  border/60, etc.) for consistency with the rest of the instrument
  chrome; the active tab indicator gains a subtle accent glow.
- Upload, Inbox, Batches, BatchDiff, Reconciliation, and Acks pages
  reworked to compose the new shared components and consume the new
  batch-scoped API surface (notably ExportBar wired into Batches).

Tooling / Docs:
- Add audit-uiux.mjs and a docs/goodclaim.x12 sample fixture.
- Update ClaimDrawer testids and add coverage for the new
  components and the useBatchExport hook.

Rolls up into the v0.2.0 release tag.
2026-06-22 11:01:58 -06:00

744 lines
26 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 { 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<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"
style={{ background: "var(--tt-bg)", color: "var(--tt-ink)" }}
>
<InboxHeader needEyesCount={0} doneTodayCount={0} />
<div
className="px-6 lg:px-10 py-10 mono flex items-center gap-2"
style={{ 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>
</div>
);
}
if (error) {
return (
<div
className="min-h-screen"
style={{ background: "var(--tt-bg)", color: "var(--tt-ink)" }}
>
<InboxHeader needEyesCount={0} doneTodayCount={0} />
<div
className="px-6 lg:px-10 py-10 mono flex flex-col gap-2"
style={{ 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>
</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 relative"
style={{ background: "var(--tt-bg)", color: "var(--tt-ink)" }}
>
{/* 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. */}
<div
aria-hidden
className="pointer-events-none fixed inset-x-0 top-0 z-0 h-[55vh]"
style={{
background: `
radial-gradient(ellipse 60% 38% at 50% 0%, hsla(36, 60%, 30%, 0.10), transparent 65%),
radial-gradient(ellipse 35% 28% at 88% 8%, hsla(36, 70%, 50%, 0.07), transparent 60%)
`,
}}
/>
{/* 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. */}
<RemitDrawer
remitId={remitId}
remits={[]}
onClose={close}
onNavigate={open}
onToggleHelp={() => {
// No cheatsheet on the inbox surface — `?` is a no-op
// here, but the prop is required by the drawer's contract.
}}
/>
<InboxHeader needEyesCount={needEyes} doneTodayCount={lanes.done_today.length} />
{/* 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. */}
<main className="px-6 lg:px-10 pb-24 pt-4 flex gap-4 items-stretch flex-wrap">
<Lane
name="REJECTED"
accent="oxblood"
rows={lanes.rejected}
// SP21 Phase 5 Task 5.4: rejected claims drill into the
// ClaimDrawer. All rows here are claims (kind === "claim"),
// so the branch is straightforward — the type union still
// requires the defensive check, but a row click on a claim
// here is always a claim drill.
onRowClick={(row) => {
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.
*/}
<Lane
name="PAYER REJECTED"
accent="oxblood"
rows={lanes.payer_rejected}
// SP21 Phase 5 Task 5.4: payer-rejected claims also drill
// into the ClaimDrawer — same shape as the rejected lane.
onRowClick={(row) => {
if (row.kind === "claim") {
navigate(`/claims?claim=${encodeURIComponent(row.id)}`);
}
}}
onSelectionChange={(ids) => setLaneSelected("payer_rejected", ids)}
/>
<Lane
name="CANDIDATES"
accent="amber"
rows={lanes.candidates}
// Candidates are remits waiting for a claim match — drill
// straight into the RemitDrawer for the source remit.
onRowClick={(row) => open(row.id)}
onSelectionChange={(ids) => setLaneSelected("candidates", ids)}
/>
<Lane
name="UNMATCHED"
accent="ink-blue"
rows={lanes.unmatched}
// Unmatched is a mixed bucket (kind === "claim" | "remit" per
// the InboxClaimRow union). Defensive branch — today the
// backend only emits "claim" rows here, but the type allows
// both, and the next data-source change shouldn't require a
// page edit.
onRowClick={(row) => {
if (row.kind === "remit") {
open(row.id);
} else if (row.kind === "claim") {
navigate(
`/claims?claim=${encodeURIComponent(row.id)}`,
);
}
}}
onSelectionChange={(ids) => setLaneSelected("unmatched", ids)}
/>
<Lane
name="DONE"
accent="muted"
rows={lanes.done_today}
// SP21 Phase 5 Task 5.4: done_today rows are also claim
// drills — same as rejected / payer_rejected. The drawer
// surfaces the claim's current state + recent history,
// which is exactly what an operator wants when reviewing
// "what got shipped today".
onRowClick={(row) => {
if (row.kind === "claim") {
navigate(`/claims?claim=${encodeURIComponent(row.id)}`);
}
}}
onSelectionChange={(ids) => setLaneSelected("done_today", ids)}
/>
</main>
{/* ----------------------------------------------------------------
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.
---------------------------------------------------------------- */}
<section
aria-label="Queue summary"
className="relative mx-6 lg:mx-10 mt-5 mb-6"
>
<div
className="relative rounded-md overflow-hidden"
style={{
// "Lit from above" gradient — same treatment as the lane
// cards and the main .surface panels, so the queue
// summary reads as a slightly lifted, subtly lighter
// surface against the ticker-tape backdrop.
background:
"linear-gradient(180deg, hsl(220 18% 11.5%) 0%, hsl(220 18% 9.5%) 55%, hsl(220 18% 8.5%) 100%)",
border: "1px solid hsl(220 8% 18%)",
boxShadow:
"inset 0 1px 0 0 hsl(0 0% 100% / 0.05), 0 1px 2px 0 hsl(0 0% 0% / 0.4)",
}}
>
{/* Header row — machine-tag eyebrow + editorial headline +
aggregate Need eyes count, separated from the lane metrics
by a hairline. */}
<div
className="px-5 lg:px-6 py-3.5 flex items-center justify-between gap-4 flex-wrap"
style={{ borderBottom: "1px solid hsl(220 8% 16%)" }}
>
<div className="min-w-0">
<div className="flex items-center gap-2.5 mb-1.5">
<span
aria-hidden
className="inline-block h-1.5 w-1.5 rounded-full animate-pulse-dot"
style={{ background: "var(--tt-amber)" }}
/>
<span
className="mono uppercase"
style={{
color: "var(--tt-amber)",
fontSize: 10.5,
letterSpacing: "0.22em",
fontWeight: 600,
}}
>
Eye-flow
</span>
<span
className="mono uppercase"
style={{
color: "var(--tt-muted)",
fontSize: 10,
letterSpacing: "0.18em",
}}
aria-hidden
>
· five lanes, one queue
</span>
</div>
<h2
className="display italic"
style={{
color: "var(--tt-ink)",
fontSize: "clamp(20px, 1.9vw, 24px)",
lineHeight: 1.1,
fontWeight: 400,
letterSpacing: "-0.02em",
}}
>
at a glance.
</h2>
</div>
<div className="text-right" aria-label="Aggregate metrics">
<div
className="mono uppercase"
style={{
color: "var(--tt-ink-dim)",
fontSize: 10,
letterSpacing: "0.20em",
fontWeight: 500,
}}
>
Need eyes
</div>
<div
className="display tabular-nums"
style={{
color: "var(--tt-amber)",
fontSize: 28,
lineHeight: 1,
fontWeight: 400,
marginTop: 4,
}}
>
{needEyes}
</div>
</div>
</div>
{/* 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. */}
<div
className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5"
style={{ borderTop: "1px solid hsl(220 8% 16%)" }}
>
<SummaryTile
label="Rejected"
value={lanes.rejected.length}
tone="oxblood"
/>
<SummaryTile
label="Payer rejected"
value={lanes.payer_rejected.length}
tone="oxblood"
/>
<SummaryTile
label="Candidates"
value={lanes.candidates.length}
tone="amber"
/>
<SummaryTile
label="Unmatched"
value={lanes.unmatched.length}
tone="ink"
/>
<SummaryTile
label="Done today"
value={lanes.done_today.length}
tone="muted"
/>
</div>
</div>
</section>
{/* 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 75% 58% / 0.18), 0 24px 60px -12px hsl(0 0% 0% / 0.5), inset 0 1px 0 0 hsl(0 0% 100% / 0.03)",
}}
>
<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>
);
}
// ---------------------------------------------------------------------------
// 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 (
<div
className={cn(
"relative px-4 py-4 first:pl-5 lg:first:pl-6 last:pr-5 lg:last:pr-6"
)}
style={{ borderRight: "1px solid hsl(220 8% 14%)" }}
>
<div className="flex items-center gap-2 mb-2">
<span
aria-hidden
className="block h-1.5 w-1.5 rounded-full"
style={{
backgroundColor: a.dot,
boxShadow: `0 0 6px ${a.dot}`,
}}
/>
<span
className="mono uppercase"
style={{
color: "var(--tt-ink-dim)",
fontSize: 10.5,
letterSpacing: "0.18em",
fontWeight: 500,
}}
>
{label}
</span>
</div>
<div
className="display tabular-nums"
style={{
color: a.value,
fontSize: "clamp(22px, 1.9vw, 28px)",
lineHeight: 1,
fontWeight: 400,
letterSpacing: "-0.02em",
}}
>
{value}
</div>
<div
className="mono uppercase mt-1.5"
style={{
color: "var(--tt-muted)",
fontSize: 9.5,
letterSpacing: "0.16em",
fontWeight: 500,
}}
>
{value === 0 ? "clear" : value === 1 ? "row" : "rows"}
</div>
</div>
);
}