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
+194
View File
@@ -0,0 +1,194 @@
// ---------------------------------------------------------------------------
// HBarChart — horizontal bar chart for ranked lists (e.g. Top Providers).
//
// Each row gets a label slot on the left, a bar that grows from 0 to
// its value on mount, and a trailing value. Bars share a common scale
// derived from the max value. Designed for 48 rows; collapses to a
// list if rows are empty. Paper-friendly: cream background, dark ink.
// ---------------------------------------------------------------------------
import { useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
export interface HBarRow {
/** Stable id used as React key. */
id: string;
/** Left label (e.g. provider name). */
label: string;
/** Sub-label under the row (e.g. NPI). Optional. */
sublabel?: string;
/** Bar value. */
value: number;
/** Optional pre-formatted trailing value. Defaults to fmt.num(value). */
formatted?: string;
/** Optional color override. Defaults to --accent. */
color?: string;
/** Optional leading slot (icon, initials avatar, etc). */
leading?: React.ReactNode;
}
export interface HBarChartProps {
rows: HBarRow[];
/** Bar fill colour when a row doesn't override. Defaults to accent. */
defaultColor?: string;
/** Pixel height of each row. Default 44. */
rowHeight?: number;
/** Compact mode: smaller text, tighter padding. */
compact?: boolean;
/** Optional click handler. */
onRowClick?: (id: string) => void;
}
export function HBarChart({
rows,
defaultColor = "hsl(var(--accent))",
rowHeight = 44,
compact = false,
onRowClick,
}: HBarChartProps) {
const wrapRef = useRef<HTMLDivElement | null>(null);
const [labelW, setLabelW] = useState(180);
useEffect(() => {
const el = wrapRef.current;
if (!el) return;
const ro = new ResizeObserver((entries) => {
const w = entries[0]?.contentRect.width ?? 800;
// Reserve ~38% of width for label + leading slot, capped between
// 140 and 260 so bars stay readable on wide and narrow layouts.
setLabelW(Math.max(140, Math.min(260, Math.floor(w * 0.38))));
});
ro.observe(el);
setLabelW(Math.max(140, Math.min(260, Math.floor(el.clientWidth * 0.38))));
return () => ro.disconnect();
}, []);
if (rows.length === 0) {
return (
<div
className="mono text-[11px] py-6 text-center"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
No data yet.
</div>
);
}
const max = Math.max(...rows.map((r) => r.value), 1);
return (
<div ref={wrapRef} className="w-full">
{rows.map((r, i) => {
const pct = (r.value / max) * 100;
const color = r.color ?? defaultColor;
return (
<div
key={r.id}
role={onRowClick ? "button" : undefined}
tabIndex={onRowClick ? 0 : undefined}
onClick={onRowClick ? () => onRowClick(r.id) : undefined}
onKeyDown={
onRowClick
? (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onRowClick(r.id);
}
}
: undefined
}
className={cn(
"group flex items-center gap-3",
compact ? "py-1.5" : "py-2.5",
onRowClick ? "cursor-pointer" : ""
)}
style={{ minHeight: rowHeight }}
>
{/* Leading slot (e.g. initials avatar) */}
{r.leading ? (
<div className="shrink-0">{r.leading}</div>
) : null}
{/* Label column */}
<div
className="shrink-0 min-w-0"
style={{ width: labelW - (r.leading ? 36 : 0) }}
>
<div
className={cn(
"truncate font-medium",
compact ? "text-[12.5px]" : "text-[13.5px]"
)}
style={{ color: "hsl(var(--surface-ink))" }}
>
{r.label}
</div>
{r.sublabel ? (
<div
className="mono truncate"
style={{
color: "hsl(var(--surface-ink-3))",
fontSize: 10.5,
marginTop: 1,
}}
>
{r.sublabel}
</div>
) : null}
</div>
{/* Bar */}
<div className="flex-1 min-w-0 relative">
{/* Track (hairline) */}
<div
className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-px"
style={{ backgroundColor: "hsl(30 14% 14% / 0.08)" }}
aria-hidden
/>
<div
className="relative h-[10px] rounded-[2px]"
style={{
width: `${pct}%`,
backgroundColor: color,
opacity: 0.85,
animation: `bar-grow-h 700ms cubic-bezier(0.2,0.8,0.2,1) ${
i * 50
}ms both`,
transformOrigin: "left center",
boxShadow: `inset 0 1px 0 hsl(0 0% 100% / 0.15)`,
}}
aria-hidden
/>
{/* Rank tick on the bar */}
<span
className="mono absolute top-1/2 -translate-y-1/2 text-[9px] font-semibold tabular-nums"
style={{
left: 6,
color: "hsl(0 0% 100% / 0.92)",
mixBlendMode: "screen",
opacity: pct > 12 ? 1 : 0,
}}
>
{String(i + 1).padStart(2, "0")}
</span>
</div>
{/* Trailing value */}
<div
className={cn(
"shrink-0 text-right mono tabular-nums",
compact ? "text-[12.5px]" : "text-[14px]"
)}
style={{
color: "hsl(var(--surface-ink))",
minWidth: 56,
}}
>
{r.formatted ?? r.value.toLocaleString("en-US")}
</div>
</div>
);
})}
</div>
);
}