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.
This commit is contained in:
Tyler
2026-06-22 11:01:58 -06:00
parent 35298907bc
commit 9bca4b608a
54 changed files with 6224 additions and 3871 deletions
+136 -106
View File
@@ -224,9 +224,26 @@ export default function Inbox() {
return (
<div
className="min-h-screen"
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),
@@ -245,50 +262,11 @@ export default function Inbox() {
/>
<InboxHeader needEyesCount={needEyes} doneTodayCount={lanes.done_today.length} />
{/* Fold — a thin amber rule + italic serif annotation that
transitions the editorial header into the lane grid below. */}
<div
aria-hidden
className="relative h-10 flex items-center"
style={{ background: "var(--tt-bg)" }}
>
<div
className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-px"
style={{
background:
"linear-gradient(to right, transparent, var(--tt-amber) 12%, var(--tt-amber) 88%, transparent)",
opacity: 0.35,
}}
/>
<div
className="relative z-10 mx-auto px-3 flex items-center gap-2"
style={{ background: "var(--tt-bg)" }}
>
<span
className="display italic"
style={{ color: "var(--tt-amber)", fontSize: 14 }}
>
</span>
<span
className="mono uppercase"
style={{
color: "var(--tt-ink-dim)",
fontSize: 10.5,
letterSpacing: "0.24em",
fontWeight: 500,
}}
>
Five lanes · one queue
</span>
<span
className="display italic"
style={{ color: "var(--tt-amber)", fontSize: 14 }}
>
</span>
</div>
</div>
{/* 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
@@ -374,64 +352,101 @@ export default function Inbox() {
</main>
{/* ----------------------------------------------------------------
PAPER-TONED SUMMARY STRIP
A cream "ledger" panel below the lanes that shows the aggregate
eye-flow across the queue. Mirrors the "Magazine Spread" pattern
used on the Dashboard/Claims/etc pages so the Inbox feels part
of the same document set, not a stand-alone terminal.
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-6 mb-4"
className="relative mx-6 lg:mx-10 mt-5 mb-6"
>
<div
className="relative rounded-xl overflow-hidden border"
className="relative rounded-md overflow-hidden"
style={{
backgroundColor: "hsl(var(--surface))",
borderColor: "hsl(30 14% 14% / 0.10)",
// "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.5), 0 1px 0 0 hsl(30 14% 22% / 0.06), 0 12px 40px -16px hsl(0 0% 0% / 0.45)",
"inset 0 1px 0 0 hsl(0 0% 100% / 0.05), 0 1px 2px 0 hsl(0 0% 0% / 0.4)",
}}
>
{/* Title row */}
{/* 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-7 py-4 border-b flex items-center justify-between gap-4 flex-wrap"
style={{ borderColor: "hsl(30 14% 14% / 0.10)" }}
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>
<div
className="mono text-[10.5px] uppercase tracking-[0.20em] font-semibold mb-1.5"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
Queue ledger
<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 leading-[0.95] tracking-[-0.03em]"
className="display italic"
style={{
color: "hsl(var(--surface-ink))",
fontSize: "clamp(22px, 2.2vw, 28px)",
color: "var(--tt-ink)",
fontSize: "clamp(20px, 1.9vw, 24px)",
lineHeight: 1.1,
fontWeight: 400,
letterSpacing: "-0.02em",
}}
>
The eye-flow, <span className="italic">at a glance.</span>
at a glance.
</h2>
</div>
<div
className="text-right"
aria-label="Aggregate metrics"
>
<div className="text-right" aria-label="Aggregate metrics">
<div
className="mono text-[10px] uppercase tracking-[0.18em] font-semibold"
style={{ color: "hsl(var(--surface-ink-3))" }}
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: "hsl(var(--surface-ink))",
fontSize: 24,
lineHeight: 1.1,
color: "var(--tt-amber)",
fontSize: 28,
lineHeight: 1,
fontWeight: 400,
marginTop: 4,
}}
>
{needEyes}
@@ -439,8 +454,13 @@ export default function Inbox() {
</div>
</div>
{/* Lane metrics row — paper tiles that mirror each lane's accent */}
<div className="grid grid-cols-2 lg:grid-cols-5 divide-x divide-y lg:divide-y-0" style={{ borderColor: "hsl(30 14% 14% / 0.08)" }}>
{/* 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}
@@ -629,33 +649,31 @@ export default function Inbox() {
}
// ---------------------------------------------------------------------------
// SummaryTile — paper-toned metric for the queue ledger strip. Mirrors
// the lane accent (oxblood / amber / ink-blue / muted) so the eye-flow
// reads at a glance.
// 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; tint: string; text: string }
{ dot: string; value: string }
> = {
oxblood: {
dot: "hsl(358 70% 42%)",
tint: "hsl(358 70% 92%)",
text: "hsl(358 70% 30%)",
dot: "var(--tt-oxblood)",
value: "var(--tt-ink)",
},
amber: {
dot: "hsl(36 92% 50%)",
tint: "hsl(36 82% 88%)",
text: "hsl(36 92% 30%)",
dot: "var(--tt-amber)",
value: "var(--tt-amber)",
},
ink: {
dot: "hsl(212 80% 45%)",
tint: "hsl(212 85% 92%)",
text: "hsl(212 80% 30%)",
dot: "var(--tt-ink-blue)",
value: "var(--tt-ink)",
},
muted: {
dot: "hsl(30 8% 50%)",
tint: "hsl(36 22% 90%)",
text: "hsl(30 8% 30%)",
dot: "var(--tt-muted)",
value: "var(--tt-ink-dim)",
},
};
@@ -672,41 +690,53 @@ function SummaryTile({
return (
<div
className={cn(
"relative px-4 py-4 first:pl-5 lg:first:pl-7 last:pr-5 lg:last:pr-7"
"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 }}
style={{
backgroundColor: a.dot,
boxShadow: `0 0 6px ${a.dot}`,
}}
/>
<span
className="mono text-[10px] uppercase tracking-[0.20em] font-semibold"
style={{ color: "hsl(var(--surface-ink-3))" }}
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 tracking-[-0.03em]"
className="display tabular-nums"
style={{
color: "hsl(var(--surface-ink))",
fontSize: "clamp(24px, 2vw, 30px)",
color: a.value,
fontSize: "clamp(22px, 1.9vw, 28px)",
lineHeight: 1,
fontWeight: 400,
letterSpacing: "-0.02em",
}}
>
{value}
</div>
<div
className="mono text-[10px] uppercase tracking-[0.16em] mt-1.5 inline-flex items-center gap-1 px-1.5 py-0.5 rounded-sm"
className="mono uppercase mt-1.5"
style={{
color: a.text,
backgroundColor: a.tint,
color: "var(--tt-muted)",
fontSize: 9.5,
letterSpacing: "0.16em",
fontWeight: 500,
}}
>
{value === 0 ? "Clear" : value === 1 ? "row" : "rows"}
{value === 0 ? "clear" : value === 1 ? "row" : "rows"}
</div>
</div>
);