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
+150
View File
@@ -0,0 +1,150 @@
// ---------------------------------------------------------------------------
// AgingBars — AR aging stacked horizontal bar.
//
// One bar split into up to 4 segments by aging bucket (030 / 3160 /
// 6190 / 90+ days). Each segment is coloured along a cool-to-warm ramp
// so the eye reads left-to-right as "freshest to most stale". Below
// the bar, four legend rows show the bucket label, dollar amount, and
// share of total. The component is data-driven — pass any four-bucket
// distribution.
// ---------------------------------------------------------------------------
import { fmt } from "@/lib/format";
export interface AgingBucket {
/** Bucket id for keying. */
id: string;
/** Display label (e.g. "030", "3160", "6190", "90+"). */
label: string;
/** Dollar amount outstanding in this bucket. */
amount: number;
/** Tailwind/HSL colour for the segment. */
color: string;
}
export interface AgingBarsProps {
buckets: AgingBucket[];
/** Total AR amount for share calc. Defaults to sum of buckets. */
total?: number;
/** Optional caption shown right-aligned above the bar. */
caption?: string;
}
export function AgingBars({ buckets, total, caption }: AgingBarsProps) {
const sum = total ?? buckets.reduce((s, b) => s + b.amount, 0);
if (sum <= 0) {
return (
<div
className="mono text-[11px] py-6 text-center"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
Nothing outstanding.
</div>
);
}
return (
<div className="w-full">
{caption ? (
<div
className="mono text-[10.5px] uppercase tracking-[0.16em] mb-2 flex items-center justify-between"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
<span>Aging buckets</span>
<span>{caption}</span>
</div>
) : null}
{/* The stacked bar */}
<div
className="flex w-full h-3 overflow-hidden rounded-[3px]"
style={{ boxShadow: "inset 0 0 0 1px hsl(30 14% 14% / 0.08)" }}
>
{buckets.map((b, i) => {
const pct = (b.amount / sum) * 100;
if (pct <= 0) return null;
return (
<div
key={b.id}
className="h-full"
style={{
width: `${pct}%`,
backgroundColor: b.color,
animation: `bar-grow-h 800ms cubic-bezier(0.2,0.8,0.2,1) ${
i * 70
}ms both`,
transformOrigin: "left center",
}}
title={`${b.label}: ${fmt.usd(b.amount)}`}
aria-label={`${b.label} days: ${fmt.usd(b.amount)}`}
/>
);
})}
</div>
{/* Legend rows */}
<ul className="mt-4 space-y-2">
{buckets.map((b) => {
const share = sum > 0 ? (b.amount / sum) * 100 : 0;
return (
<li
key={b.id}
className="flex items-center justify-between gap-3 text-[12px]"
>
<div className="flex items-center gap-2 min-w-0">
<span
aria-hidden
className="inline-block h-2 w-2 rounded-sm shrink-0"
style={{ backgroundColor: b.color }}
/>
<span
className="mono tabular-nums"
style={{ color: "hsl(var(--surface-ink-2))" }}
>
{b.label}d
</span>
</div>
<div className="flex items-center gap-3 shrink-0">
<div
className="relative h-1 w-16 rounded-full overflow-hidden"
style={{ backgroundColor: "hsl(30 14% 14% / 0.06)" }}
aria-hidden
>
<div
className="absolute inset-y-0 left-0 rounded-full"
style={{
width: `${share}%`,
backgroundColor: b.color,
opacity: 0.7,
}}
/>
</div>
<span
className="mono tabular-nums"
style={{
color: "hsl(var(--surface-ink))",
minWidth: 70,
textAlign: "right",
}}
>
{fmt.usd(b.amount)}
</span>
<span
className="mono tabular-nums"
style={{
color: "hsl(var(--surface-ink-3))",
minWidth: 40,
textAlign: "right",
fontSize: 10.5,
}}
>
{share.toFixed(0)}%
</span>
</div>
</li>
);
})}
</ul>
</div>
);
}