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
+86
View File
@@ -0,0 +1,86 @@
// Shared visual primitives used by ClaimCard837 and ClaimCard835.
//
// Both cards render validation status (Passed / Warnings / Failed) and
// a grid of stat pills (Member, Place of service, …). The exact same
// mark-up was being copy-pasted inside Upload.tsx for both cards; this
// file is the shared home.
import { AlertTriangle, CheckCircle2, XCircle } from "lucide-react";
import { cn } from "@/lib/utils";
/**
* Tri-state validation indicator.
*
* - `passed` → green "Passed" with check icon
* - `hasWarnings` → amber "Warnings" with triangle
* - else → red "Failed" with X
*/
export function ValidationDot({
passed,
hasWarnings,
}: {
passed: boolean;
hasWarnings?: boolean;
}) {
if (passed) {
return (
<span
className="inline-flex items-center gap-1.5 mono text-[10.5px] uppercase tracking-[0.12em] text-[hsl(var(--success))] font-medium"
title="Validation passed"
>
<CheckCircle2 className="h-3.5 w-3.5" strokeWidth={1.75} />
Passed
</span>
);
}
if (hasWarnings) {
return (
<span
className="inline-flex items-center gap-1.5 mono text-[10.5px] uppercase tracking-[0.12em] text-[hsl(var(--warning))] font-medium"
title="Validation passed with warnings"
>
<AlertTriangle className="h-3.5 w-3.5" strokeWidth={1.75} />
Warnings
</span>
);
}
return (
<span
className="inline-flex items-center gap-1.5 mono text-[10.5px] uppercase tracking-[0.12em] text-destructive font-medium"
title="Validation failed"
>
<XCircle className="h-3.5 w-3.5" strokeWidth={1.75} />
Failed
</span>
);
}
/**
* Small label + value cell for the expanded-card detail grid.
*/
export function StatPill({
label,
value,
mono = true,
}: {
label: string;
value: React.ReactNode;
mono?: boolean;
}) {
return (
<div className="flex flex-col gap-1">
<div className="mono text-[10px] uppercase tracking-[0.18em] font-semibold text-muted-foreground/70">
{label}
</div>
<div
className={cn(
"text-[13px]",
mono && "display mono"
)}
style={{ color: "hsl(var(--surface-ink))" }}
>
{value}
</div>
</div>
);
}
+162
View File
@@ -0,0 +1,162 @@
// @vitest-environment happy-dom
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
true;
import React, { act } from "react";
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { MemoryRouter } from "react-router-dom";
import { createRoot, type Root } from "react-dom/client";
import { useAppStore } from "@/store";
import { ClaimCard837 } from "./ClaimCard837";
import type { ClaimOutput } from "@/types";
// Tiny fixture — just enough to exercise the card's UI surface.
const CLAIM: ClaimOutput = {
claim_id: "CLM-TEST",
subscriber: { first_name: "Jane", last_name: "Doe", member_id: "MEM-1" },
payer: { name: "Test Payer", id: "P1" },
billing_provider: { npi: "1234567890" },
claim: {
total_charge: 100,
place_of_service: "11",
frequency_code: "1",
prior_auth: null,
},
service_lines: [
{
line_number: 1,
procedure: { qualifier: "HC", code: "99213", modifiers: [] },
charge: "100",
units: "1",
unit_type: "UN",
service_date: "2026-06-01",
},
],
diagnoses: [{ qualifier: "ABK", code: "J20.9" }],
validation: { passed: true, errors: [], warnings: [] },
};
function renderCard(
props: Partial<React.ComponentProps<typeof ClaimCard837>> = {},
): {
container: HTMLDivElement;
unmount: () => void;
} {
const container = document.createElement("div");
document.body.appendChild(container);
const root: Root = createRoot(container);
const onToggleSelect = props.onToggleSelect ?? (() => {});
act(() => {
root.render(
React.createElement(
MemoryRouter,
null,
React.createElement(ClaimCard837, {
claim: CLAIM,
selected: false,
onToggleSelect,
...props,
}),
),
);
});
return {
container,
unmount: () => {
act(() => root.unmount());
container.remove();
},
};
}
describe("ClaimCard837 — checkbox + select interaction", () => {
beforeEach(() => {
useAppStore.setState({ parsedBatches: [] });
});
afterEach(() => {
vi.restoreAllMocks();
});
it("renders a checkbox with checked={selected}", () => {
const { container, unmount } = renderCard({ selected: true });
const cb = container.querySelector<HTMLInputElement>(
'input[type="checkbox"]',
);
expect(cb).not.toBeNull();
expect(cb!.checked).toBe(true);
unmount();
});
it("renders an unchecked checkbox when selected={false}", () => {
const { container, unmount } = renderCard({ selected: false });
const cb = container.querySelector<HTMLInputElement>(
'input[type="checkbox"]',
);
expect(cb!.checked).toBe(false);
unmount();
});
it("clicking the checkbox fires onToggleSelect with the claim_id", () => {
const onToggleSelect = vi.fn();
const { container, unmount } = renderCard({ onToggleSelect });
const cb = container.querySelector<HTMLInputElement>(
'input[type="checkbox"]',
)!;
act(() => {
cb.click();
});
expect(onToggleSelect).toHaveBeenCalledWith("CLM-TEST");
unmount();
});
it("clicking the checkbox does NOT toggle the card's expand state", () => {
// Regression guard: the card body is a <button> that toggles expand
// on click. The new checkbox must not bubble its click into that
// button (which would also fire onToggleSelect, double-counting).
// We assert this by checking the expanded content (the service lines
// table) is NOT in the DOM after a checkbox click.
const { container, unmount } = renderCard();
const cb = container.querySelector<HTMLInputElement>(
'input[type="checkbox"]',
)!;
act(() => {
cb.click();
});
// The service lines table only renders when the card is expanded.
const table = container.querySelector("table");
expect(table).toBeNull();
unmount();
});
it("clicking the card body still toggles expand (existing behavior preserved)", () => {
const { container, unmount } = renderCard();
// The card body is a <button aria-expanded="false">. Clicking it
// should reveal the expanded details (service lines table).
const expandButton = container.querySelector<HTMLButtonElement>(
'button[aria-expanded]',
);
expect(expandButton).not.toBeNull();
act(() => {
expandButton!.click();
});
// After expand, the service lines table is in the DOM.
const table = container.querySelector("table");
expect(table).not.toBeNull();
unmount();
});
it("checkbox has an accessible label that names the claim", () => {
const { container, unmount } = renderCard();
const cb = container.querySelector<HTMLInputElement>(
'input[type="checkbox"]',
);
// aria-label or wrapping <label> — at minimum the input must be
// findable by an accessible name. The simplest assertion is that an
// aria-label exists on the input itself.
expect(
cb!.getAttribute("aria-label") ||
cb!.closest("label")?.textContent,
).toContain("CLM-TEST");
unmount();
});
});
+352
View File
@@ -0,0 +1,352 @@
// ClaimCard837 — the warm-paper card that represents one 837P claim in
// the Upload page's streaming section. Originally inlined in
// `src/pages/Upload.tsx`; extracted into its own file in SP9 (June 2026)
// so the per-claim select-for-export interaction can live here without
// bloating the page.
//
// Two click targets, by design (see approach A in the batch-export
// design spec): a leading checkbox column for selection, and the rest
// of the card (a <button>) for expand/collapse. Putting the checkbox
// inside the button would be an a11y anti-pattern (nested interactive
// elements).
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
AlertTriangle,
ArrowRight,
ChevronRight,
XCircle,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { useAppStore } from "@/store";
import { fmt, toNum } from "@/lib/format";
import { cn } from "@/lib/utils";
import type { ClaimOutput, ServiceLine } from "@/types";
import { StatPill, ValidationDot } from "./ClaimCard/shared";
export interface ClaimCard837Props {
claim: ClaimOutput;
/**
* Whether this card is currently selected for the batch export.
* Controlled by the parent (Upload.tsx) via the per-claim checkbox.
* The checkbox column only renders when the parent passes
* `onToggleSelect` — keeps the component backward-compatible with
* callers that don't need the export flow (e.g. the persisted
* batch's "Recent batches" view, when we add it).
*/
selected?: boolean;
/** Called with the claim's id when the user clicks the checkbox. */
onToggleSelect?: (claimId: string) => void;
}
export function ClaimCard837({ claim, selected = false, onToggleSelect }: ClaimCard837Props) {
const [open, setOpen] = useState(false);
const passed = claim.validation.passed;
const hasWarnings = claim.validation.warnings.length > 0;
// SP21 Phase 5 Task 5.7: a "See claim in detail →" link drills to
// /claims?claim=ID — but ONLY when this streamed claim_id has
// actually been persisted to a parsed batch. The Upload page
// streams claims as the parser emits them; until the user clicks
// "Save batch" the claim isn't visible to ClaimDrawer.
const parsedBatches = useAppStore((s) => s.parsedBatches);
const persistedClaimIds = useMemo(
() => new Set(parsedBatches.flatMap((b) => b.claimIds)),
[parsedBatches],
);
const canDrill = persistedClaimIds.has(claim.claim_id);
const navigate = useNavigate();
const showCheckbox = typeof onToggleSelect === "function";
return (
<div
className="rounded-lg overflow-hidden border flex"
style={{
backgroundColor: "hsl(36 22% 96%)",
borderColor: "hsl(30 14% 14% / 0.10)",
boxShadow: "inset 0 1px 0 0 hsl(0 0% 100% / 0.5)",
}}
>
{/* Selection column — only when the parent wires up selection. */}
{showCheckbox ? (
<label
className="flex items-center pl-3 pr-2 cursor-pointer shrink-0"
// Belt-and-suspenders: the label is a sibling of the button
// below, so click events don't bubble into it. stopPropagation
// here guards against future refactors that might nest the
// label inside the button.
onClick={(e) => e.stopPropagation()}
data-testid={`claim-card-select-${claim.claim_id}`}
>
<input
type="checkbox"
checked={selected}
onChange={() => onToggleSelect?.(claim.claim_id)}
aria-label={`Select claim ${claim.claim_id} for export`}
className="h-3.5 w-3.5 rounded border-border accent-accent cursor-pointer"
/>
</label>
) : null}
<button
type="button"
onClick={() => setOpen((v) => !v)}
className={cn(
"flex-1 min-w-0 text-left px-4 py-3 hover:bg-[hsl(36_22%_92%)] transition-colors",
!showCheckbox && "rounded-l-lg",
)}
aria-expanded={open}
>
<div className="flex items-center gap-3">
<ChevronRight
className={cn(
"h-3.5 w-3.5 transition-transform shrink-0",
open && "rotate-90"
)}
strokeWidth={1.75}
style={{ color: "hsl(var(--surface-ink-3))" }}
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2.5 flex-wrap">
<span
className="display mono text-[12.5px]"
style={{ color: "hsl(var(--surface-ink))" }}
>
{claim.claim_id}
</span>
<span
className="text-[12px] truncate"
style={{ color: "hsl(var(--surface-ink-2))" }}
>
{claim.subscriber.first_name} {claim.subscriber.last_name}
</span>
<span
className="text-[12px]"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
· {claim.payer.name}
</span>
</div>
<div
className="mono text-[10.5px] flex items-center gap-2 mt-1"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
<span>NPI {claim.billing_provider.npi}</span>
<span className="opacity-40">·</span>
<span>
{claim.service_lines.length} line
{claim.service_lines.length === 1 ? "" : "s"}
</span>
<span className="opacity-40">·</span>
<span>{claim.diagnoses.length} dx</span>
</div>
</div>
<div className="text-right shrink-0">
<div
className="display mono text-[14px]"
style={{ color: "hsl(var(--surface-ink))" }}
>
{fmt.usdDecimal(claim.claim.total_charge)}
</div>
<div className="mt-0.5">
<ValidationDot passed={passed} hasWarnings={hasWarnings} />
</div>
</div>
</div>
</button>
{open ? (
<div
className="basis-full border-t px-4 py-3 grid gap-4 animate-fade-in"
style={{
borderColor: "hsl(30 14% 14% / 0.08)",
backgroundColor: "hsl(36 22% 92%)",
}}
>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatPill label="Member" value={claim.subscriber.member_id} />
<StatPill
label="Place of service"
value={claim.claim.place_of_service ?? "—"}
/>
<StatPill label="Frequency" value={claim.claim.frequency_code ?? "—"} />
<StatPill
label="Prior auth"
value={claim.claim.prior_auth ?? "—"}
/>
</div>
{claim.diagnoses.length > 0 ? (
<div>
<div
className="mono text-[10px] uppercase tracking-[0.18em] font-semibold mb-1.5"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
Diagnoses
</div>
<div className="flex flex-wrap gap-1.5">
{claim.diagnoses.map((d, i) => (
<Badge key={`${d.code}-${i}`} variant="muted">
{d.qualifier ? `${d.qualifier}·` : ""}
{d.code}
</Badge>
))}
</div>
</div>
) : null}
{claim.service_lines.length > 0 ? (
<div>
<div
className="mono text-[10px] uppercase tracking-[0.18em] font-semibold mb-1.5"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
Service lines
</div>
<div
className="rounded-md border overflow-x-auto"
style={{
borderColor: "hsl(30 14% 14% / 0.10)",
backgroundColor: "hsl(36 22% 98%)",
}}
>
<table className="w-full text-[12px]">
<thead style={{ backgroundColor: "hsl(36 22% 90%)" }}>
<tr className="text-left" style={{ color: "hsl(var(--surface-ink-2))" }}>
<th className="px-2.5 py-1.5 font-medium">#</th>
<th className="px-2.5 py-1.5 font-medium">Code</th>
<th className="px-2.5 py-1.5 font-medium">Mods</th>
<th className="px-2.5 py-1.5 font-medium">Date</th>
<th className="px-2.5 py-1.5 font-medium">Units</th>
<th className="px-2.5 py-1.5 font-medium text-right">Charge</th>
</tr>
</thead>
<tbody>
{claim.service_lines.map((line) => (
<ServiceLine837Row key={line.line_number} line={line} />
))}
</tbody>
</table>
</div>
</div>
) : null}
{(claim.validation.errors.length > 0 ||
claim.validation.warnings.length > 0) ? (
<div>
<div
className="mono text-[10px] uppercase tracking-[0.18em] font-semibold mb-1.5"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
Validation
</div>
<ul className="space-y-1 text-[12px]">
{claim.validation.errors.map((issue, i) => (
<li
key={`e-${i}`}
className="flex items-start gap-2 text-destructive"
>
<XCircle
className="h-3.5 w-3.5 mt-0.5 shrink-0"
strokeWidth={1.75}
/>
<span>
<span className="mono">{issue.rule}</span> {issue.message}
</span>
</li>
))}
{claim.validation.warnings.map((issue, i) => (
<li
key={`w-${i}`}
className="flex items-start gap-2 text-[hsl(var(--warning))]"
>
<AlertTriangle
className="h-3.5 w-3.5 mt-0.5 shrink-0"
strokeWidth={1.75}
/>
<span>
<span className="mono">{issue.rule}</span> {issue.message}
</span>
</li>
))}
</ul>
</div>
) : null}
{/* SP21 Phase 5 Task 5.7: drill to the persisted claim. Only
renders when this streamed claim_id has been saved into
a parsed batch (so ClaimDrawer can find it). Before
"Save batch" the claim is streaming-only and the link
would 404. */}
{canDrill ? (
<div className="pt-1">
<button
type="button"
onClick={() =>
navigate(
`/claims?claim=${encodeURIComponent(claim.claim_id)}`,
)
}
data-testid="upload-claim-drill"
aria-label={`See claim ${claim.claim_id} in detail`}
className="inline-flex items-center gap-1.5 text-[12px] mono font-semibold cursor-pointer rounded-sm px-1 -mx-1 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
style={{ color: "hsl(var(--surface-ink))" }}
>
See claim in detail
<ArrowRight className="h-3 w-3" strokeWidth={2} />
</button>
</div>
) : null}
</div>
) : null}
</div>
);
}
function ServiceLine837Row({ line }: { line: ServiceLine }) {
return (
<tr
className="border-t"
style={{ borderColor: "hsl(30 14% 14% / 0.08)" }}
>
<td
className="px-2.5 py-1.5 mono"
style={{ color: "hsl(var(--surface-ink))" }}
>
{line.line_number}
</td>
<td
className="px-2.5 py-1.5 mono"
style={{ color: "hsl(var(--surface-ink))" }}
>
{line.procedure.qualifier}·{line.procedure.code}
</td>
<td
className="px-2.5 py-1.5 mono"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
{line.procedure.modifiers.length > 0
? line.procedure.modifiers.join(", ")
: "—"}
</td>
<td
className="px-2.5 py-1.5 mono"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
{line.service_date ?? "—"}
</td>
<td
className="px-2.5 py-1.5 mono"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
{line.units ? `${toNum(line.units)} ${line.unit_type ?? ""}`.trim() : "—"}
</td>
<td
className="px-2.5 py-1.5 mono text-right display"
style={{ color: "hsl(var(--surface-ink))" }}
>
{fmt.usdDecimal(line.charge)}
</td>
</tr>
);
}
+14 -12
View File
@@ -141,12 +141,14 @@ export function ClaimDrawer({
// Right-anchored side panel. We override the Dialog primitive's
// default centered positioning (`left-1/2 top-1/2 -translate-x-1/2
// -translate-y-1/2`) by re-asserting `right-0 top-0 translate-x-0
// translate-y-0`. `h-full` + `rounded-none` + the left border
// translate-y-0`. `h-full` + `rounded-none` + the left hairline
// give it the visual identity of a drawer rather than a modal.
// `bg-card` matches the rest of the dark instrument chrome; the
// outer shadow is a soft directional falloff to the left.
// `aria-describedby={undefined}` suppresses the Radix warning
// about a missing description — the drawer content is its own
// description and there's nothing useful to point at.
className="fixed right-0 top-0 h-full w-full max-w-2xl translate-x-0 translate-y-0 rounded-none border-l border-[color:var(--m-border-heavy)]/60 bg-[color:var(--m-surface)] p-0 shadow-[-24px_0_60px_-12px_rgba(0,0,0,0.6)]"
className="fixed right-0 top-0 h-full w-full max-w-2xl translate-x-0 translate-y-0 rounded-none border-l border-border/60 bg-card p-0 shadow-[-24px_0_60px_-12px_rgba(0,0,0,0.6)]"
data-testid="claim-drawer"
aria-describedby={undefined}
>
@@ -167,7 +169,7 @@ export function ClaimDrawer({
>
<ClaimDrawerHeader claim={data} onClose={onClose} />
<div
className="flex gap-0 px-6 border-b border-[color:var(--surface-line)]/40"
className="flex gap-0 px-6 border-b border-border/40"
data-testid="claim-drawer-tabs"
>
<button
@@ -176,15 +178,15 @@ export function ClaimDrawer({
className={cn(
"relative px-3 py-2.5 text-[10.5px] font-semibold uppercase tracking-[0.18em] transition-colors",
activeTab === "details"
? "text-[color:var(--surface-ink)]"
: "text-[color:var(--surface-ink-3)] hover:text-[color:var(--surface-ink-2)]"
? "text-foreground"
: "text-muted-foreground hover:text-foreground"
)}
data-testid="tab-button-details"
data-active={activeTab === "details" ? "true" : "false"}
>
Details
{activeTab === "details" ? (
<span className="absolute inset-x-0 -bottom-px h-[2px] bg-[color:var(--accent)]" />
<span className="absolute inset-x-0 -bottom-px h-[2px] bg-accent shadow-[0_0_8px_hsl(var(--accent)/0.6)]" />
) : null}
</button>
<button
@@ -193,29 +195,29 @@ export function ClaimDrawer({
className={cn(
"relative px-3 py-2.5 text-[10.5px] font-semibold uppercase tracking-[0.18em] transition-colors",
activeTab === "line-reconciliation"
? "text-[color:var(--surface-ink)]"
: "text-[color:var(--surface-ink-3)] hover:text-[color:var(--surface-ink-2)]"
? "text-foreground"
: "text-muted-foreground hover:text-foreground"
)}
data-testid="tab-button-line-reconciliation"
data-active={activeTab === "line-reconciliation" ? "true" : "false"}
>
Line Reconciliation
{activeTab === "line-reconciliation" ? (
<span className="absolute inset-x-0 -bottom-px h-[2px] bg-[color:var(--accent)]" />
<span className="absolute inset-x-0 -bottom-px h-[2px] bg-accent shadow-[0_0_8px_hsl(var(--accent)/0.6)]" />
) : null}
</button>
</div>
{activeTab === "line-reconciliation" ? (
lr.loading ? (
<p
className="px-6 py-4 text-sm text-[color:var(--m-ink-tertiary)]"
className="px-6 py-4 text-sm text-muted-foreground"
data-testid="line-reconciliation-loading"
>
Loading line reconciliation
</p>
) : lr.error ? (
<p
className="px-6 py-4 text-sm text-[color:var(--m-ink-tertiary)]"
className="px-6 py-4 text-sm text-muted-foreground"
data-testid="line-reconciliation-error"
>
Failed to load line reconciliation.
@@ -224,7 +226,7 @@ export function ClaimDrawer({
<LineReconciliationTab data={lr.data} />
) : null
) : (
<div className="flex flex-col divide-y divide-[color:var(--surface-line)]/12">
<div className="flex flex-col divide-y divide-border/40">
<ValidationPanel validation={data.validation} />
<ServiceLinesTable
serviceLines={data.serviceLines}
@@ -32,21 +32,21 @@ export function ClaimDrawerError({ kind, onRetry, onClose }: ClaimDrawerErrorPro
return (
<div
className="flex flex-col items-start gap-4 p-6 bg-[color:var(--m-surface)] text-[color:var(--m-ink-primary)]"
className="flex flex-col items-start gap-4 p-6 bg-card text-foreground"
role="alert"
data-testid={`claim-drawer-error-${kind}`}
>
<div className="flex items-center gap-2">
<Icon
className="h-5 w-5 text-[color:var(--m-error)]"
className="h-5 w-5 text-destructive"
strokeWidth={1.75}
aria-hidden
/>
<span className="eyebrow text-[color:var(--m-error)]">
<span className="eyebrow text-destructive">
{eyebrow}
</span>
</div>
<p className="text-sm text-[color:var(--m-ink-secondary)] max-w-sm">{message}</p>
<p className="text-sm text-muted-foreground max-w-sm">{message}</p>
<div className="flex items-center gap-2">
{kind === "network" && onRetry ? (
<Button variant="outline" size="sm" onClick={onRetry} data-testid="error-retry">
@@ -10,7 +10,7 @@ import { Skeleton } from "@/components/ui/skeleton";
export function ClaimDrawerSkeleton() {
return (
<div
className="flex flex-col gap-6 p-6 bg-[color:var(--m-surface)] text-[color:var(--m-ink-primary)]"
className="flex flex-col gap-6 p-6 bg-card text-foreground"
data-testid="claim-drawer-skeleton"
aria-busy="true"
>
+5 -5
View File
@@ -35,7 +35,7 @@ export function DiagnosesList({ diagnoses }: DiagnosesListProps) {
<section className="flex flex-col gap-3 px-6 py-4">
<h3
data-testid="section-label"
className="eyebrow text-[color:var(--m-ink-tertiary)]"
className="eyebrow text-muted-foreground"
>
Diagnoses ({diagnoses.length})
</h3>
@@ -43,7 +43,7 @@ export function DiagnosesList({ diagnoses }: DiagnosesListProps) {
{diagnoses.length === 0 ? (
<p
data-testid="diagnoses-empty"
className="text-sm text-[color:var(--m-ink-tertiary)]"
className="text-sm text-muted-foreground"
>
No diagnoses
</p>
@@ -58,17 +58,17 @@ export function DiagnosesList({ diagnoses }: DiagnosesListProps) {
className="flex items-baseline gap-3 text-sm"
>
<span
className="mono text-[color:var(--m-ink-primary)] font-medium"
className="mono text-foreground font-medium"
>
{d.qualifier ? `${d.qualifier} ${d.code}` : d.code}
</span>
{desc ? (
<>
<span
className="h-px w-3 bg-[color:var(--m-border-heavy)]/30"
className="h-px w-3 bg-border/60"
aria-hidden
/>
<span className="text-[color:var(--m-ink-secondary)] italic">
<span className="text-muted-foreground italic">
{desc}
</span>
</>
@@ -31,26 +31,26 @@ export function LineReconciliationTab({
>
<div className="flex gap-5 font-mono tabular-nums text-sm">
<span data-testid="billed-total" className="flex flex-col gap-0.5">
<span className="text-[color:var(--m-ink-tertiary)] eyebrow">
<span className="text-muted-foreground eyebrow">
Billed
</span>
<span className="text-[color:var(--m-ink-primary)] font-semibold">
<span className="text-foreground font-semibold">
{fmt.usdPrecise(Number(summary.billedTotal))}
</span>
</span>
<span data-testid="paid-total" className="flex flex-col gap-0.5">
<span className="text-[color:var(--m-ink-tertiary)] eyebrow">
<span className="text-muted-foreground eyebrow">
Paid
</span>
<span className="text-[color:var(--m-ink-primary)] font-semibold">
<span className="text-foreground font-semibold">
{fmt.usdPrecise(Number(summary.paidTotal))}
</span>
</span>
<span data-testid="adjustments-total" className="flex flex-col gap-0.5">
<span className="text-[color:var(--m-ink-tertiary)] eyebrow">
<span className="text-muted-foreground eyebrow">
Adjustments
</span>
<span className="text-[color:var(--m-ink-primary)] font-semibold">
<span className="text-foreground font-semibold">
{fmt.usdPrecise(Number(summary.adjustmentTotal))}
</span>
</span>
@@ -70,7 +70,7 @@ export function LineReconciliationTab({
<div className="grid grid-cols-2 gap-4">
<div data-testid="left-column">
<h3 className="eyebrow text-[color:var(--m-ink-tertiary)] mb-2">
<h3 className="eyebrow text-muted-foreground mb-2">
Billed (837 SV1)
</h3>
{leftRows.map((row) => (
@@ -78,7 +78,7 @@ export function LineReconciliationTab({
))}
</div>
<div data-testid="right-column">
<h3 className="eyebrow text-[color:var(--m-ink-tertiary)] mb-2">
<h3 className="eyebrow text-muted-foreground mb-2">
Adjudicated (835 SVC)
</h3>
{rightRows.map((row) => (
@@ -114,7 +114,7 @@ function LineCard({
return (
<div
className="flex flex-col gap-1 py-2 px-3 mb-2 rounded-r-md transition-colors hover:bg-[color:var(--m-ink-tertiary)]/5"
className="flex flex-col gap-1 py-2 px-3 mb-2 rounded-r-md transition-colors hover:bg-muted/40"
style={{
borderLeft: `3px solid ${accentColor}`,
background: "rgba(255,255,255,0.02)",
@@ -123,9 +123,9 @@ function LineCard({
>
<div className="flex items-baseline justify-between">
<div className="font-mono text-sm">
<span className="font-semibold text-[color:var(--m-ink-primary)]">{procCode}</span>
<span className="font-semibold text-foreground">{procCode}</span>
{lineNumber !== null ? (
<span className="text-[color:var(--m-ink-tertiary)] ml-2">
<span className="text-muted-foreground ml-2">
#{lineNumber}
</span>
) : null}
@@ -146,16 +146,16 @@ function LineCard({
</span>
) : null}
</div>
<div className="font-mono tabular-nums text-xs text-[color:var(--m-ink-primary)] font-semibold">
<div className="font-mono tabular-nums text-xs text-foreground font-semibold">
{side === "left" && cl ? <span>{fmt.usdPrecise(Number(cl.charge))}</span> : null}
{side === "right" && svc ? <span>{fmt.usdPrecise(Number(svc.payment))}</span> : null}
</div>
</div>
{row.adjustments.length > 0 ? (
<ul className="text-xs font-mono text-[color:var(--m-ink-tertiary)] mt-1 space-y-0.5">
<ul className="text-xs font-mono text-muted-foreground mt-1 space-y-0.5">
{row.adjustments.map((a, i) => (
<li key={i}>
<span className="text-[color:var(--m-ink-secondary)]">{a.groupCode}-{a.reasonCode}</span>
<span className="text-foreground/80">{a.groupCode}-{a.reasonCode}</span>
<span className="ml-2 tabular-nums">{fmt.usdPrecise(Number(a.amount))}</span>
</li>
))}
@@ -56,20 +56,20 @@ export function MatchedRemitCard({ matchedRemittance }: MatchedRemitCardProps) {
>
<h3
data-testid="section-label"
className="eyebrow text-[color:var(--m-ink-tertiary)]"
className="eyebrow text-muted-foreground"
>
Matched Remittance
</h3>
<div
data-testid="matched-remit-card-inner"
className="flex flex-col gap-4 rounded-lg border border-[color:var(--m-border-heavy)]/30 bg-[color:var(--m-surface)]/60 p-4 sm:flex-row sm:items-start sm:justify-between"
className="flex flex-col gap-4 rounded-lg border border-border/60 bg-muted/30 p-4 sm:flex-row sm:items-start sm:justify-between"
>
{/* Left: remit id + status badge + received date */}
<div className="flex min-w-0 flex-col gap-2">
<span
data-testid="matched-remit-id"
className="mono text-sm text-[color:var(--m-ink-primary)]"
className="mono text-sm text-foreground"
>
{id}
</span>
@@ -83,7 +83,7 @@ export function MatchedRemitCard({ matchedRemittance }: MatchedRemitCardProps) {
</Badge>
<span
data-testid="matched-remit-received"
className="text-xs text-[color:var(--m-ink-tertiary)]"
className="text-xs text-muted-foreground"
>
Received {fmt.date(receivedAt)}
</span>
@@ -94,7 +94,7 @@ export function MatchedRemitCard({ matchedRemittance }: MatchedRemitCardProps) {
<div className="flex flex-col items-start gap-2 sm:items-end">
<span
data-testid="matched-remit-total-paid"
className="display text-3xl text-[color:var(--m-ink-primary)] tabular-nums"
className="display text-3xl text-foreground tabular-nums"
>
{fmt.usdPrecise(totalPaid)}
</span>
@@ -103,7 +103,7 @@ export function MatchedRemitCard({ matchedRemittance }: MatchedRemitCardProps) {
size="sm"
onClick={handleViewRemittance}
data-testid="view-remittance-link"
className="gap-1 text-[color:var(--m-ink-secondary)] hover:text-[color:var(--m-ink-primary)]"
className="gap-1 text-muted-foreground hover:text-foreground"
>
View remittance
<ArrowRight
+7 -7
View File
@@ -28,7 +28,7 @@ function AddressBlock({ address }: { address: AddressLike }) {
const a = address as ClaimDetailAddress;
return (
<div
className="mt-1 border-t border-dashed border-[color:var(--m-border-heavy)]/30 pt-2 text-xs leading-relaxed text-[color:var(--m-ink-secondary)]"
className="mt-1 border-t border-dashed border-border/60 pt-2 text-xs leading-relaxed text-muted-foreground"
data-testid="party-address"
>
<div>{a.line1}</div>
@@ -63,9 +63,9 @@ function PartyCard({
return (
<div
data-testid={testId}
className="flex flex-col gap-2 rounded-lg border border-[color:var(--m-border-heavy)]/30 bg-[color:var(--m-surface)]/60 px-4 py-3 transition-colors hover:border-[color:var(--m-border-heavy)]/50"
className="flex flex-col gap-2 rounded-lg border border-border/60 bg-muted/30 px-4 py-3 transition-colors hover:border-border hover:bg-muted/40"
>
<span className="eyebrow text-[color:var(--m-ink-tertiary)]">
<span className="eyebrow text-muted-foreground">
{label}
</span>
{onNameClick ? (
@@ -74,17 +74,17 @@ function PartyCard({
onClick={onNameClick}
data-testid={`${testId}-name-drill`}
aria-label={`Drill into ${label.toLowerCase()} ${name}`}
className="display text-lg text-[color:var(--m-ink-primary)] text-left cursor-pointer drillable rounded-sm px-0 -mx-0"
className="display text-lg text-foreground text-left cursor-pointer drillable rounded-sm px-0 -mx-0"
>
{name}
</button>
) : (
<div className="display text-lg text-[color:var(--m-ink-primary)]">
<div className="display text-lg text-foreground">
{name}
</div>
)}
<div
className="mono text-[12.5px] text-[color:var(--m-ink-secondary)] tabular-nums"
className="mono text-[12.5px] text-muted-foreground tabular-nums"
>
{identity}
</div>
@@ -120,7 +120,7 @@ export function PartiesGrid({ parties }: PartiesGridProps) {
>
<h3
data-testid="section-label"
className="eyebrow text-[color:var(--m-ink-tertiary)]"
className="eyebrow text-muted-foreground"
>
Parties
</h3>
@@ -33,17 +33,17 @@ export function RawSegmentsPanel({ rawSegments }: RawSegmentsPanelProps) {
>
<h3
data-testid="section-label"
className="eyebrow text-[color:var(--m-ink-tertiary)]"
className="eyebrow text-muted-foreground"
>
Raw Segments ({rawSegments.length})
</h3>
<details className="group rounded-lg border border-[color:var(--m-border-heavy)]/30 bg-[color:var(--m-surface)]/40 overflow-hidden transition-colors hover:border-[color:var(--m-border-heavy)]/50">
<details className="group rounded-lg border border-border/60 bg-muted/30 overflow-hidden transition-colors hover:border-border">
<summary
className="cursor-pointer select-none px-4 py-2.5 text-sm text-[color:var(--m-ink-secondary)] hover:text-[color:var(--m-ink-primary)] transition-colors [&::-webkit-details-marker]:hidden flex items-center gap-2"
className="cursor-pointer select-none px-4 py-2.5 text-sm text-muted-foreground hover:text-foreground transition-colors [&::-webkit-details-marker]:hidden flex items-center gap-2"
data-testid="raw-segments-summary"
>
<span className="inline-block h-1.5 w-1.5 rounded-full bg-[color:var(--m-ink-tertiary)] group-open:bg-[color:var(--m-accent)] transition-colors" />
<span className="inline-block h-1.5 w-1.5 rounded-full bg-muted-foreground group-open:bg-accent transition-colors" />
{rawSegments.length === 0
? `Show raw segments`
: `Show ${rawSegments.length} raw segment${rawSegments.length === 1 ? "" : "s"}`}
@@ -52,14 +52,14 @@ export function RawSegmentsPanel({ rawSegments }: RawSegmentsPanelProps) {
{rawSegments.length === 0 ? (
<p
data-testid="raw-segments-empty"
className="px-4 pb-3 text-sm text-[color:var(--m-ink-tertiary)]"
className="px-4 pb-3 text-sm text-muted-foreground"
>
No segments
</p>
) : (
<pre
data-testid="raw-segments-pre"
className="overflow-x-auto whitespace-pre-wrap border-t border-[color:var(--m-border-heavy)]/20 px-4 pb-3 pt-3 text-[12px] leading-relaxed text-[color:var(--m-ink-primary)] font-mono"
className="overflow-x-auto whitespace-pre-wrap border-t border-border/40 px-4 pb-3 pt-3 text-[12px] leading-relaxed text-foreground font-mono"
style={{ fontFamily: "var(--m-font-mono)" }}
>
{rawSegments.map((segment, i) => (
@@ -49,7 +49,7 @@ export function ServiceLinesTable({
<section className="flex flex-col gap-3 px-6 py-4">
<h3
data-testid="section-label"
className="eyebrow text-[color:var(--m-ink-tertiary)]"
className="eyebrow text-muted-foreground"
>
Service Lines ({serviceLines.length})
</h3>
@@ -57,7 +57,7 @@ export function ServiceLinesTable({
{serviceLines.length === 0 ? (
<p
data-testid="service-lines-empty"
className="text-sm text-[color:var(--m-ink-tertiary)]"
className="text-sm text-muted-foreground"
>
No service lines
</p>
@@ -84,34 +84,34 @@ export function ServiceLinesTable({
data-line-number={line.lineNumber}
className="row-hover"
>
<TableCell className="font-mono text-[color:var(--m-ink-secondary)]">
<TableCell className="font-mono text-muted-foreground">
{line.lineNumber}
</TableCell>
<TableCell className="font-mono text-[color:var(--m-ink-primary)]">
<TableCell className="font-mono text-foreground">
{formatProcedure(line)}
</TableCell>
<TableCell
className="text-right font-mono text-base tabular-nums text-[color:var(--m-ink-primary)] font-semibold"
className="text-right font-mono text-base tabular-nums text-foreground font-semibold"
style={{ fontFamily: "var(--m-font-mono)" }}
>
{fmt.usdPrecise(line.charge)}
</TableCell>
<TableCell className="font-mono text-[color:var(--m-ink-secondary)]">
<TableCell className="font-mono text-muted-foreground">
{formatUnits(line)}
</TableCell>
<TableCell className="font-mono text-[color:var(--m-ink-secondary)]">
<TableCell className="font-mono text-muted-foreground">
{line.serviceDate ? fmt.date(line.serviceDate) : ""}
</TableCell>
<TableCell
data-testid="paid-cell"
className="text-right font-mono tabular-nums text-[color:var(--m-ink-primary)]"
className="text-right font-mono tabular-nums text-foreground"
style={{ fontFamily: "var(--m-font-mono)" }}
>
{formatMoneyOrDash(lr?.paid ?? null)}
</TableCell>
<TableCell
data-testid="adjustments-cell"
className="text-right font-mono tabular-nums text-[color:var(--m-ink-secondary)]"
className="text-right font-mono tabular-nums text-muted-foreground"
style={{ fontFamily: "var(--m-font-mono)" }}
>
{formatMoneyOrDash(lr?.adjustmentsSum ?? null)}
@@ -64,7 +64,7 @@ export function StateHistoryTimeline({ history }: StateHistoryTimelineProps) {
>
<h3
data-testid="section-label"
className="eyebrow text-[color:var(--m-ink-tertiary)]"
className="eyebrow text-muted-foreground"
>
State History ({history.length})
</h3>
@@ -72,14 +72,14 @@ export function StateHistoryTimeline({ history }: StateHistoryTimelineProps) {
{history.length === 0 ? (
<p
data-testid="state-history-empty"
className="text-sm text-[color:var(--m-ink-tertiary)]"
className="text-sm text-muted-foreground"
>
No history events
</p>
) : (
<ol
data-testid="state-history-timeline"
className="relative ml-2 flex flex-col gap-3.5 border-l border-dashed border-[color:var(--m-border-heavy)]/40 pl-6"
className="relative ml-2 flex flex-col gap-3.5 border-l border-dashed border-border/40 pl-6"
>
{history.map((event, i) => (
<li
@@ -91,18 +91,18 @@ export function StateHistoryTimeline({ history }: StateHistoryTimelineProps) {
<span
data-testid="state-history-dot"
aria-hidden
className={`absolute -left-[29px] top-1.5 h-2.5 w-2.5 rounded-full ring-4 ring-[color:var(--m-surface)] ${dotColorFor(event.kind)}`}
className={`absolute -left-[29px] top-1.5 h-2.5 w-2.5 rounded-full ring-4 ring-card ${dotColorFor(event.kind)}`}
/>
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
<span
data-testid="state-history-kind"
className="text-[10px] font-semibold uppercase tracking-[0.14em] text-[color:var(--m-ink-primary)]"
className="text-[10px] font-semibold uppercase tracking-[0.14em] text-foreground"
>
{kindLabel(event.kind)}
</span>
<span
data-testid="state-history-ts"
className="mono text-xs text-[color:var(--m-ink-secondary)] tabular-nums"
className="mono text-xs text-muted-foreground tabular-nums"
>
{fmt.date(event.ts)} · {fmt.time(event.ts)}
</span>
@@ -110,7 +110,7 @@ export function StateHistoryTimeline({ history }: StateHistoryTimelineProps) {
{event.remittanceId ? (
<span
data-testid="history-remit-id"
className="mono text-xs text-[color:var(--m-ink-tertiary)]"
className="mono text-xs text-muted-foreground"
>
Remit {event.remittanceId}
</span>
+13 -13
View File
@@ -63,12 +63,12 @@ function IssueGroup({
onClick={() => openPeek({ kind: "rule", rule })}
data-testid={`${testId}-rule-drill`}
aria-label={`Drill into rule ${rule}`}
className="mono text-[12px] font-semibold tracking-tight text-[color:var(--m-ink-primary)] cursor-pointer drillable rounded-sm px-0 -mx-0"
className="mono text-[12px] font-semibold tracking-tight text-foreground cursor-pointer drillable rounded-sm px-0 -mx-0"
>
{rule}
</button>
<span
className="inline-flex items-center rounded-full bg-[color:var(--m-ink-tertiary)]/15 px-1.5 py-0.5 text-[10px] font-medium text-[color:var(--m-ink-secondary)] tabular-nums"
className="inline-flex items-center rounded-full bg-muted-foreground/15 px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground tabular-nums"
data-testid={`${testId}-count`}
>
{issues.length}
@@ -78,7 +78,7 @@ function IssueGroup({
{issues.map((issue, idx) => (
<li
key={`${rule}-${idx}`}
className="flex items-start gap-2 text-[13px] leading-snug text-[color:var(--m-ink-secondary)]"
className="flex items-start gap-2 text-[13px] leading-snug text-muted-foreground"
data-testid={`${testId}-message`}
>
<Icon
@@ -121,14 +121,14 @@ export function ValidationPanel({ validation }: ValidationPanelProps) {
className="flex items-center gap-2 px-6 py-3"
>
<CheckCircle2
className="h-4 w-4 text-[color:var(--m-success)]"
className="h-4 w-4 text-[hsl(var(--success))]"
strokeWidth={1.75}
aria-hidden
/>
<span className="text-[13px] font-medium text-[color:var(--m-ink-primary)]">
<span className="text-[13px] font-medium text-foreground">
All checks passed
</span>
<span className="ml-auto inline-flex items-center rounded-full bg-[color:var(--m-success)]/12 px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-[color:var(--m-success)]">
<span className="ml-auto inline-flex items-center rounded-full bg-[hsl(var(--success)/0.12)] px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-[hsl(var(--success))]">
Valid
</span>
</section>
@@ -140,10 +140,10 @@ export function ValidationPanel({ validation }: ValidationPanelProps) {
return (
<section
className="flex flex-col gap-4 px-6 py-4 bg-[color:var(--m-surface)]"
className="flex flex-col gap-4 px-6 py-4"
data-testid="validation-panel"
>
<span className="eyebrow text-[color:var(--m-ink-tertiary)]">
<span className="eyebrow text-muted-foreground">
Validation
</span>
@@ -152,15 +152,15 @@ export function ValidationPanel({ validation }: ValidationPanelProps) {
data-testid="validation-errors"
data-rule-group="errors"
role="alert"
className="flex flex-col gap-3 border-l-2 border-[color:var(--m-error)] bg-[hsl(var(--destructive)/0.06)] px-3.5 py-3"
className="flex flex-col gap-3 border-l-2 border-[color:var(--m-error)] bg-[hsl(var(--destructive)/0.08)] px-3.5 py-3"
>
<div className="flex items-center gap-2">
<AlertCircle
className="h-3.5 w-3.5 text-[color:var(--m-error)]"
className="h-3.5 w-3.5 text-destructive"
strokeWidth={1.75}
aria-hidden
/>
<span className="text-[10.5px] font-semibold uppercase tracking-[0.14em] text-[color:var(--m-error)]">
<span className="text-[10.5px] font-semibold uppercase tracking-[0.14em] text-destructive">
Errors ({validation.errors.length})
</span>
</div>
@@ -185,11 +185,11 @@ export function ValidationPanel({ validation }: ValidationPanelProps) {
>
<div className="flex items-center gap-2">
<AlertTriangle
className="h-3.5 w-3.5 text-[color:var(--m-warning)]"
className="h-3.5 w-3.5 text-[hsl(var(--warning))]"
strokeWidth={1.75}
aria-hidden
/>
<span className="text-[10.5px] font-semibold uppercase tracking-[0.14em] text-[color:var(--m-warning)]">
<span className="text-[10.5px] font-semibold uppercase tracking-[0.14em] text-[hsl(var(--warning))]">
Warnings ({validation.warnings.length})
</span>
</div>
+167
View File
@@ -0,0 +1,167 @@
// ---------------------------------------------------------------------------
// DominantKpiCard — the headline KPI tile.
//
// One KPI gets the dominant slot on the dashboard. It carries:
// - a thicker accent rule on the left edge
// - an oversized Instrument Serif number
// - a wide sparkline (not a 28px afterthought)
// - delta + hint row in mono
// Same paper variant as the smaller KpiCard. Designed to live INSIDE
// the statement, next to a column of 4 smaller readouts.
// ---------------------------------------------------------------------------
import { ArrowDownRight, ArrowUpRight, type LucideIcon } from "lucide-react";
import * as React from "react";
import { Sparkline } from "./Sparkline";
import { AnimatedNumber } from "./AnimatedNumber";
import { cn } from "@/lib/utils";
interface DominantKpiCardProps {
label: string;
icon?: LucideIcon;
/** Pre-rendered value node. Required unless `rawValue`+`format`
* are both supplied, in which case the AnimatedNumber is used. */
value?: React.ReactNode;
/** Used when value is a plain number — drives the AnimatedNumber. */
rawValue?: number;
/** Formatter for the AnimatedNumber. */
format?: (n: number) => string;
delta?: { value: string; direction: "up" | "down"; positive: boolean };
hint?: string;
sparkline?: number[];
/** Sparkline stroke colour. Defaults to accent. */
sparklineColor?: string;
/** Accent rail colour (left edge). Defaults to accent. */
accent?: string;
className?: string;
style?: React.CSSProperties;
}
export function DominantKpiCard({
label,
icon: Icon,
value,
rawValue,
format,
delta,
hint,
sparkline,
sparklineColor = "hsl(var(--accent))",
accent = "hsl(var(--accent))",
className,
style,
}: DominantKpiCardProps) {
const computedStyle: React.CSSProperties = {
backgroundColor: "hsl(var(--surface))",
boxShadow:
"inset 0 1px 0 0 hsl(0 0% 100% / 0.45), 0 1px 0 0 hsl(30 14% 22% / 0.06), inset 3px 0 0 0 hsl(0 0% 100% / 0.4)",
...style,
};
return (
<div
style={computedStyle}
className={cn(
"relative rounded-xl p-6 flex flex-col gap-4 overflow-hidden",
"border border-[hsl(30_14%_14%/_0.10)] hover:bg-[hsl(36_22%_92%)] transition-colors",
className
)}
>
{/* Accent rail on the left — thicker than the small cards */}
<div
aria-hidden
className="absolute left-0 top-5 bottom-5 w-[3px] rounded-r-sm"
style={{ backgroundColor: accent, opacity: 0.85 }}
/>
{/* Header row: label + icon */}
<div className="flex items-center justify-between">
<div
className="eyebrow flex items-center gap-2"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
<span>{label}</span>
{hint ? (
<span
className="mono normal-case tracking-normal"
style={{
color: "hsl(var(--surface-ink-3))",
fontSize: 10,
opacity: 0.7,
}}
>
· {hint}
</span>
) : null}
</div>
{Icon ? (
<div
className="h-7 w-7 rounded-md flex items-center justify-center"
style={{
backgroundColor: "hsl(36 22% 90%)",
boxShadow: "inset 0 0 0 1px hsl(30 14% 22% / 0.08)",
color: "hsl(var(--surface-ink-2))",
}}
>
<Icon className="h-3.5 w-3.5" strokeWidth={1.75} />
</div>
) : null}
</div>
{/* Big number */}
<div
className="display tabular-nums tracking-[-0.04em]"
style={{
color: "hsl(var(--surface-ink))",
fontSize: "clamp(48px, 6vw, 72px)",
lineHeight: 0.92,
fontWeight: 400,
}}
>
{rawValue !== undefined && format ? (
<AnimatedNumber value={rawValue} format={format} />
) : (
value
)}
</div>
{/* Delta + sparkline */}
<div className="flex items-end justify-between gap-4">
<div className="flex items-center gap-2 text-[11px] min-h-[16px]">
{delta ? (
<span
className={cn(
"inline-flex items-center gap-0.5 font-medium mono",
delta.positive
? "text-[hsl(var(--success))]"
: "text-destructive"
)}
>
{delta.direction === "up" ? (
<ArrowUpRight className="h-3 w-3" strokeWidth={2} />
) : (
<ArrowDownRight className="h-3 w-3" strokeWidth={2} />
)}
{delta.value}
</span>
) : null}
<span
className="mono uppercase tracking-[0.14em]"
style={{
color: "hsl(var(--surface-ink-3))",
fontSize: 10,
}}
>
vs last 6 mo
</span>
</div>
{sparkline ? (
<div className="flex-1 max-w-[240px]">
<Sparkline values={sparkline} stroke={sparklineColor} />
</div>
) : null}
</div>
</div>
);
}
+82
View File
@@ -0,0 +1,82 @@
// ---------------------------------------------------------------------------
// EditorialNote — italic Instrument Serif margin annotations.
//
// A small italicized serif note that "speaks" to the data next to it.
// Used in the dashboard to narrate the day's narrative: "Three NPIs
// are in flight" sits next to the KPI grid; an editorial note sits
// beside the chart explaining what it shows. The note itself is a
// single italic line of Instrument Serif with a tiny serif ampersand
// or hairline rule introducing it.
//
// This is the dashboard's "voice" — the moment the data stops being
// silent and reads as a story.
// ---------------------------------------------------------------------------
import * as React from "react";
import { cn } from "@/lib/utils";
export interface EditorialNoteProps {
children: React.ReactNode;
/** Optional lead-in (e.g. "↘", "§", "—" or a short label). */
lead?: string;
/** Optional secondary line shown below the note in mono caps. */
caption?: string;
/** Tone — dark canvas (default) or paper. */
tone?: "dark" | "paper";
className?: string;
}
export function EditorialNote({
children,
lead,
caption,
tone = "dark",
className,
}: EditorialNoteProps) {
const noteColor =
tone === "paper"
? "hsl(var(--surface-ink))"
: "hsl(var(--foreground))";
const leadColor =
tone === "paper"
? "hsl(var(--surface-ink-3))"
: "hsl(var(--muted-foreground))";
const captionColor =
tone === "paper"
? "hsl(var(--surface-ink-3))"
: "hsl(var(--muted-foreground))";
return (
<div className={cn("flex items-start gap-3", className)}>
{lead ? (
<span
aria-hidden
className="display italic shrink-0 leading-none mt-1"
style={{ color: leadColor, fontSize: 18 }}
>
{lead}
</span>
) : null}
<div className="min-w-0">
<p
className="display italic leading-snug"
style={{
color: noteColor,
fontSize: 15,
letterSpacing: "-0.005em",
}}
>
{children}
</p>
{caption ? (
<p
className="mono uppercase tracking-[0.18em] mt-1.5"
style={{ color: captionColor, fontSize: 9.5 }}
>
{caption}
</p>
) : null}
</div>
</div>
);
}
+138
View File
@@ -0,0 +1,138 @@
// @vitest-environment happy-dom
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
true;
import React, { act, useState } from "react";
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { createRoot, type Root } from "react-dom/client";
import { ExportBar } from "./ExportBar";
function renderBar(
props: Partial<React.ComponentProps<typeof ExportBar>> = {},
): {
container: HTMLDivElement;
unmount: () => void;
} {
const container = document.createElement("div");
document.body.appendChild(container);
const root: Root = createRoot(container);
const onToggleAll = props.onToggleAll ?? (() => {});
const onExport = props.onExport ?? (() => {});
act(() => {
root.render(
React.createElement(ExportBar, {
total: 10,
selectedCount: 10,
exporting: false,
onToggleAll,
onExport,
...props,
}),
);
});
return {
container,
unmount: () => {
act(() => root.unmount());
container.remove();
},
};
}
describe("ExportBar", () => {
beforeEach(() => {
vi.useRealTimers();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("renders the total and selected count", () => {
const { container, unmount } = renderBar({ total: 24, selectedCount: 18 });
expect(container.textContent).toContain("18");
expect(container.textContent).toContain("24");
unmount();
});
it("the select-all checkbox is checked when all items are selected", () => {
const { container, unmount } = renderBar({ total: 5, selectedCount: 5 });
const cb = container.querySelector<HTMLInputElement>(
'input[type="checkbox"]',
)!;
expect(cb.checked).toBe(true);
expect(cb.indeterminate).toBe(false);
unmount();
});
it("the select-all checkbox is unchecked when nothing is selected", () => {
const { container, unmount } = renderBar({ total: 5, selectedCount: 0 });
const cb = container.querySelector<HTMLInputElement>(
'input[type="checkbox"]',
)!;
expect(cb.checked).toBe(false);
expect(cb.indeterminate).toBe(false);
unmount();
});
it("the select-all checkbox is indeterminate when some items are selected", () => {
const { container, unmount } = renderBar({ total: 5, selectedCount: 2 });
const cb = container.querySelector<HTMLInputElement>(
'input[type="checkbox"]',
)!;
expect(cb.checked).toBe(false);
expect(cb.indeterminate).toBe(true);
unmount();
});
it("clicking the select-all checkbox calls onToggleAll", () => {
const onToggleAll = vi.fn();
const { container, unmount } = renderBar({ onToggleAll });
const cb = container.querySelector<HTMLInputElement>(
'input[type="checkbox"]',
)!;
act(() => {
cb.click();
});
expect(onToggleAll).toHaveBeenCalledTimes(1);
unmount();
});
it("the export button is disabled when selectedCount is 0", () => {
const { container, unmount } = renderBar({ selectedCount: 0 });
const btn = container.querySelector<HTMLButtonElement>("button")!;
expect(btn.disabled).toBe(true);
unmount();
});
it("the export button is enabled when selectedCount > 0", () => {
const { container, unmount } = renderBar({ selectedCount: 3 });
const btn = container.querySelector<HTMLButtonElement>("button")!;
expect(btn.disabled).toBe(false);
unmount();
});
it("the export button is disabled while exporting", () => {
const { container, unmount } = renderBar({ selectedCount: 3, exporting: true });
const btn = container.querySelector<HTMLButtonElement>("button")!;
expect(btn.disabled).toBe(true);
unmount();
});
it("clicking the export button calls onExport", () => {
const onExport = vi.fn();
const { container, unmount } = renderBar({ onExport, selectedCount: 3 });
const btn = container.querySelector<HTMLButtonElement>("button")!;
act(() => {
btn.click();
});
expect(onExport).toHaveBeenCalledTimes(1);
unmount();
});
it("renders the selected count in the export button label", () => {
const { container, unmount } = renderBar({ selectedCount: 7 });
const btn = container.querySelector<HTMLButtonElement>("button")!;
expect(btn.textContent).toContain("7");
unmount();
});
});
+127
View File
@@ -0,0 +1,127 @@
// ExportBar — sticky bar that sits at the top of the Upload page's
// streaming section and lets the user select-all / deselect-all the
// streamed claims and trigger the batch X12 export.
//
// Tri-state "Select all" checkbox:
// - all selected → checked
// - none selected → unchecked
// - mixed → indeterminate (the dash / line state)
//
// The Export button is disabled when the selection is empty (no point
// sending an empty bundle) or when an export is already in flight
// (don't double-fire the network call).
import { CheckSquare, Download, Loader2, Square } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export interface ExportBarProps {
/** Total claims in the stream (denominator for "X of Y selected"). */
total: number;
/** Current selection size (numerator). */
selectedCount: number;
/** When true, the export button shows a spinner and is disabled. */
exporting: boolean;
/** Called when the user toggles the select-all checkbox. */
onToggleAll: () => void;
/** Called when the user clicks the Export button. */
onExport: () => void;
}
export function ExportBar({
total,
selectedCount,
exporting,
onToggleAll,
onExport,
}: ExportBarProps) {
const allSelected = total > 0 && selectedCount === total;
const noneSelected = selectedCount === 0;
// Indeterminate = some selected, but not all. Bound to the input via
// a ref because the `indeterminate` attribute is not a real HTML
// attribute — it's a JS property only.
const indeterminate = !allSelected && !noneSelected;
return (
<div
className={cn(
"flex items-center gap-4 px-3 py-2 rounded-md border bg-background/40",
"flex-wrap",
)}
style={{ borderColor: "hsl(var(--border) / 0.6)" }}
data-testid="export-bar"
>
<label
className="inline-flex items-center gap-2 cursor-pointer select-none text-[12px] mono uppercase tracking-[0.14em] font-semibold text-muted-foreground hover:text-foreground"
onClick={(e) => e.stopPropagation()}
data-testid="export-bar-toggle-all"
>
{allSelected ? (
<CheckSquare
className="h-3.5 w-3.5"
strokeWidth={1.75}
style={{ color: "hsl(var(--accent))" }}
/>
) : (
<Square
className="h-3.5 w-3.5"
strokeWidth={1.75}
style={{
color: indeterminate
? "hsl(var(--accent) / 0.7)"
: "hsl(var(--muted-foreground) / 0.6)",
}}
/>
)}
<input
type="checkbox"
checked={allSelected}
ref={(el) => {
if (el) el.indeterminate = indeterminate;
}}
onChange={onToggleAll}
className="sr-only"
aria-label={
allSelected
? "Deselect all claims"
: noneSelected
? "Select all claims"
: `Select all claims (${selectedCount} of ${total} currently selected)`
}
/>
{allSelected ? "Deselect all" : "Select all"}
</label>
<span
className="text-[11.5px] mono text-muted-foreground/70"
aria-live="polite"
>
<span className="text-foreground">{selectedCount}</span> of{" "}
<span className="text-foreground">{total}</span> selected
</span>
<div className="ml-auto flex items-center gap-2">
<Button
size="sm"
variant="default"
onClick={onExport}
disabled={noneSelected || exporting}
data-testid="export-bar-export"
aria-label={`Export ${selectedCount} claims as X12 ZIP`}
>
{exporting ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Exporting
</>
) : (
<>
<Download className="h-3.5 w-3.5" />
Export ({selectedCount})
</>
)}
</Button>
</div>
</div>
);
}
+18
View File
@@ -24,6 +24,24 @@ export function Layout() {
return (
<div className="relative min-h-screen z-10">
<SkipLink />
{/* Ambient page halo — a soft, fixed radial that creates a
subtle lighter zone behind the page content (centered
just below the top bar). Pure decoration; pointer-events
disabled so it never intercepts clicks. Two layered
radials: a neutral softbox centered above the page
header, and a cool accent in the upper-right that
mirrors the body::before composition. The halo sits
behind everything else (z-0 inside the z-10 root). */}
<div
aria-hidden
className="pointer-events-none fixed inset-x-0 top-0 z-0 h-[70vh]"
style={{
background: `
radial-gradient(ellipse 55% 30% at 50% 6%, hsla(220, 32%, 30%, 0.16), transparent 70%),
radial-gradient(ellipse 35% 25% at 82% 10%, hsla(212, 70%, 60%, 0.10), transparent 65%)
`,
}}
/>
<div
className="fixed top-0 left-0 right-0 z-50 h-px overflow-hidden pointer-events-none transition-opacity duration-200"
style={{ opacity: showScan ? 1 : 0 }}
+152
View File
@@ -0,0 +1,152 @@
// ---------------------------------------------------------------------------
// TickerTape — live vital-signs strip for the dashboard hero.
//
// A horizontal tape of small numeric readouts separated by hairline
// dividers, rendered on the dark canvas. Each cell shows a label
// (uppercase mono) and a value (mono). One cell can be marked
// `accent` to draw the eye, and the optional `live` cell shows a
// pulsing dot — the dashboard's only acknowledgement of time.
//
// This is a static ticker (not scrolling); the cells are just laid
// out left-to-right with dividers. We use it to give the dark hero
// data density without committing to a real chart.
// ---------------------------------------------------------------------------
import { cn } from "@/lib/utils";
export interface TickerCell {
/** Stable id used as React key. */
id: string;
/** Uppercase mono label (e.g. "Billed", "Pending", "Denial"). */
label: string;
/** The value, rendered verbatim in mono. Format it before passing. */
value: string;
/** Optional secondary value shown muted (e.g. delta). */
delta?: string;
/** Highlight this cell with the accent colour. */
accent?: boolean;
}
export interface TickerTapeProps {
cells: TickerCell[];
/** Show the "LIVE" pulse on the leading edge. */
live?: boolean;
/** Live label (default "Live"). */
liveLabel?: string;
className?: string;
}
export function TickerTape({
cells,
live = false,
liveLabel = "Live",
className,
}: TickerTapeProps) {
return (
<div
className={cn(
"relative flex items-stretch w-full overflow-hidden",
"rounded-md border border-border/50 bg-card/40 backdrop-blur",
className
)}
>
{/* Left rail — amber signal accent for the LIVE indicator */}
{live ? (
<div
className="flex items-center gap-2 px-3 border-r border-border/60 shrink-0"
style={{
background:
"linear-gradient(180deg, hsl(36 92% 56% / 0.06), transparent)",
}}
>
<span className="relative inline-flex h-1.5 w-1.5 shrink-0">
<span
className="absolute inline-flex h-full w-full rounded-full opacity-60 animate-ping"
style={{ backgroundColor: "hsl(var(--signal))" }}
/>
<span
className="relative inline-flex h-1.5 w-1.5 rounded-full"
style={{ backgroundColor: "hsl(var(--signal))" }}
/>
</span>
<span
className="mono uppercase tracking-[0.18em] font-semibold"
style={{
color: "hsl(var(--signal))",
fontSize: 10,
}}
>
{liveLabel}
</span>
</div>
) : null}
{/* Cells */}
<div className="flex items-stretch flex-1 min-w-0 overflow-x-auto">
{cells.map((c, i) => (
<div
key={c.id}
className={cn(
"flex-1 min-w-[110px] px-4 py-2.5 flex flex-col gap-0.5",
i > 0 ? "border-l border-border/40" : ""
)}
>
<span
className="mono uppercase tracking-[0.18em] font-medium"
style={{
color: c.accent
? "hsl(var(--accent))"
: "hsl(var(--muted-foreground))",
fontSize: 9.5,
}}
>
{c.label}
</span>
<div className="flex items-baseline gap-1.5">
<span
className="display mono tabular-nums"
style={{
color: c.accent
? "hsl(var(--foreground))"
: "hsl(var(--foreground))",
fontSize: 16,
letterSpacing: "-0.01em",
}}
>
{c.value}
</span>
{c.delta ? (
<span
className="mono tabular-nums"
style={{
color: "hsl(var(--muted-foreground))",
fontSize: 10.5,
}}
>
{c.delta}
</span>
) : null}
</div>
</div>
))}
</div>
{/* Right rail — perforated tick pattern, decoratively marks the
end of the tape without using a heavy border. */}
<div
aria-hidden
className="shrink-0 w-3 self-stretch"
style={{
backgroundImage:
"radial-gradient(circle, hsl(36 14% 88% / 0.16) 1px, transparent 1.5px)",
backgroundSize: "6px 6px",
backgroundPosition: "0 center",
maskImage:
"linear-gradient(to right, transparent, black 30%, black 100%)",
WebkitMaskImage:
"linear-gradient(to right, transparent, black 30%, black 100%)",
}}
/>
</div>
);
}
+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>
);
}
+381
View File
@@ -0,0 +1,381 @@
// ---------------------------------------------------------------------------
// BarChart — grouped vertical bar chart on cream paper.
//
// Renders N series (e.g. billed, received, AR) over M categories (months).
// Each category gets a cluster of bars, one per series. The chart grows
// the bars from 0 on mount with a staggered delay so the statement
// doesn't appear all at once. Hover shows the exact value as a small
// ledger callout. Designed to live INSIDE the paper statement — no dark
// chrome, hairline grid only.
//
// The paper variant: cream background, dark ink, hairline rule in
// --surface-line-soft. Three series uses --accent (billed), --success
// (received), --signal (AR outstanding) so each carries its own
// semantic colour rather than three random hues.
// ---------------------------------------------------------------------------
import { useEffect, useRef, useState } from "react";
export interface BarSeries {
/** Stable id used for keying, hover hit-tests, and legend swatches. */
id: string;
/** Display name shown in the legend. */
label: string;
/** Hex/HSL colour used for bar fill + legend swatch. */
color: string;
/** Values aligned with `categories`. */
values: number[];
/** Optional formatter for tooltip + axis labels. Defaults to identity. */
format?: (n: number) => string;
}
export interface BarChartProps {
/** Category labels along the X axis (e.g. month abbreviations). */
categories: string[];
/** Ordered series. First series renders leftmost in each cluster. */
series: BarSeries[];
/** Height in px. Default 260. */
height?: number;
/** Optional y-axis tick formatter. If omitted, the chart self-scales
* to a "round" max and labels with a compact thousands suffix. */
formatY?: (n: number) => string;
/** When true, draws a hairline rule for each y tick. Default true. */
showGrid?: boolean;
/** Force a specific Y-axis maximum. When omitted, the chart auto-scales
* to a "round" max with a small headroom. */
yMax?: number;
/** When true, prints the per-cluster total above each month. Default true. */
showTotals?: boolean;
/** Index of the "current" category to highlight (e.g. latest month).
* Adds a subtle background band and an eyebrow above. */
highlightIndex?: number;
}
const PAD_LEFT = 44;
const PAD_RIGHT = 16;
const PAD_TOP = 16;
const PAD_BOTTOM = 28;
const niceMax = (raw: number): number => {
if (raw <= 0) return 1;
const exp = Math.floor(Math.log10(raw));
const base = Math.pow(10, exp);
const norm = raw / base;
let nice: number;
if (norm <= 1) nice = 1;
else if (norm <= 2) nice = 2;
else if (norm <= 2.5) nice = 2.5;
else if (norm <= 5) nice = 5;
else nice = 10;
return nice * base;
};
const compact = (n: number): string => {
const abs = Math.abs(n);
if (abs >= 1_000_000) return `$${(n / 1_000_000).toFixed(1)}M`;
if (abs >= 1_000) return `$${(n / 1_000).toFixed(0)}k`;
return `$${n.toFixed(0)}`;
};
interface Hover {
cat: number;
series: number;
x: number;
y: number;
}
export function BarChart({
categories,
series,
height = 260,
formatY,
showGrid = true,
yMax: yMaxProp,
showTotals = true,
highlightIndex,
}: BarChartProps) {
const wrapRef = useRef<HTMLDivElement | null>(null);
const [width, setWidth] = useState(800);
const [hover, setHover] = useState<Hover | null>(null);
// Track container width so the SVG reflows with the layout grid.
useEffect(() => {
const el = wrapRef.current;
if (!el) return;
const ro = new ResizeObserver((entries) => {
const w = entries[0]?.contentRect.width ?? 800;
setWidth(Math.max(320, Math.floor(w)));
});
ro.observe(el);
setWidth(Math.max(320, Math.floor(el.clientWidth)));
return () => ro.disconnect();
}, []);
const innerW = width - PAD_LEFT - PAD_RIGHT;
const innerH = height - PAD_TOP - PAD_BOTTOM;
const allValues = series.flatMap((s) => s.values);
const rawMax = allValues.length ? Math.max(...allValues) : 1;
// If caller forces a yMax, use it; otherwise compute a "round" max
// with a small (5%) headroom so the tallest bar doesn't kiss the lid.
const yMax = yMaxProp ?? niceMax(rawMax * 1.05);
const yTicks = 4;
const clusterW = innerW / Math.max(1, categories.length);
const barGap = 4;
const barW = Math.max(
4,
(clusterW - barGap * (series.length - 1)) / Math.max(1, series.length)
);
const yToPx = (v: number) => PAD_TOP + innerH - (v / yMax) * innerH;
const catCenterX = (i: number) => PAD_LEFT + clusterW * i + clusterW / 2;
const fmtY = formatY ?? compact;
// Stagger the bars so they grow from baseline on first paint. We just
// emit a CSS animation-delay per bar via inline style.
const barIndex = (catI: number, serI: number) =>
catI * series.length + serI;
return (
<div ref={wrapRef} className="relative w-full" style={{ height }}>
<svg
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
className="block"
role="img"
aria-label="Monthly billed, received, and AR trend"
onMouseLeave={() => setHover(null)}
>
{/* Y-axis grid + labels */}
{showGrid &&
Array.from({ length: yTicks + 1 }, (_, i) => {
const v = (yMax / yTicks) * i;
const y = yToPx(v);
return (
<g key={i}>
<line
x1={PAD_LEFT}
x2={width - PAD_RIGHT}
y1={y}
y2={y}
stroke="hsl(30 14% 14% / 0.10)"
strokeWidth={1}
strokeDasharray={i === 0 ? "" : "2 3"}
/>
<text
x={PAD_LEFT - 10}
y={y + 4}
textAnchor="end"
className="mono"
fontSize={11}
fontWeight={500}
style={{ fill: "hsl(var(--surface-ink-2))" }}
>
{fmtY(v)}
</text>
</g>
);
})}
{/* Highlight band on the current month (subtle, like a sticky note) */}
{highlightIndex !== undefined &&
highlightIndex >= 0 &&
highlightIndex < categories.length ? (
<g>
<rect
x={catCenterX(highlightIndex) - clusterW / 2}
y={PAD_TOP}
width={clusterW}
height={innerH}
fill="hsl(36 92% 56% / 0.06)"
/>
<line
x1={catCenterX(highlightIndex)}
x2={catCenterX(highlightIndex)}
y1={PAD_TOP}
y2={PAD_TOP + innerH}
stroke="hsl(36 92% 56% / 0.45)"
strokeWidth={1}
strokeDasharray="2 3"
/>
<text
x={catCenterX(highlightIndex)}
y={PAD_TOP + 12}
textAnchor="middle"
className="mono"
fontSize={9}
fontWeight={600}
style={{ fill: "hsl(36 92% 36%)", letterSpacing: "0.18em" }}
>
NOW
</text>
</g>
) : null}
{/* Bars */}
{categories.map((cat, ci) =>
series.map((s, si) => {
const v = s.values[ci] ?? 0;
const x =
catCenterX(ci) -
(series.length * barW + (series.length - 1) * barGap) / 2 +
si * (barW + barGap);
const y = yToPx(v);
const h = Math.max(0, PAD_TOP + innerH - y);
const delay = barIndex(ci, si) * 35;
const isHover =
hover?.cat === ci && hover?.series === si ? true : false;
return (
<rect
key={`${cat}-${s.id}`}
x={x}
y={y}
width={barW}
height={h}
rx={1.5}
fill={s.color}
opacity={hover && !isHover ? 0.55 : 1}
style={{
transformOrigin: `${x + barW / 2}px ${PAD_TOP + innerH}px`,
animation: `bar-grow 600ms cubic-bezier(0.2,0.8,0.2,1) ${delay}ms both`,
}}
onMouseEnter={() =>
setHover({
cat: ci,
series: si,
x: x + barW / 2,
y,
})
}
/>
);
})
)}
{/* X-axis labels */}
{categories.map((cat, i) => (
<text
key={cat}
x={catCenterX(i)}
y={height - 14}
textAnchor="middle"
className="mono"
fontSize={11}
fontWeight={500}
style={{
fill:
highlightIndex === i
? "hsl(var(--surface-ink))"
: "hsl(var(--surface-ink-2))",
}}
>
{cat}
</text>
))}
{/* Per-cluster totals above each month — quick read of the spine. */}
{showTotals &&
categories.map((_, ci) => {
const total = series.reduce(
(s, sr) => s + (sr.values[ci] ?? 0),
0
);
const x = catCenterX(ci);
const topY = yToPx(
Math.max(...series.map((s) => s.values[ci] ?? 0))
);
const labelY = Math.max(14, topY - 8);
return (
<text
key={`total-${ci}`}
x={x}
y={labelY}
textAnchor="middle"
className="display mono"
fontSize={11}
fontWeight={600}
style={{
fill: "hsl(var(--surface-ink-2))",
opacity: 0.75,
}}
>
{fmtY(total)}
</text>
);
})}
{/* Hover callout */}
{hover ? (
(() => {
const s = series[hover.series]!;
const v = s.values[hover.cat] ?? 0;
const catLabel = categories[hover.cat] ?? "";
const text = `${s.label} · ${catLabel}`;
const valueText = s.format ? s.format(v) : fmtY(v);
// Position the chip to the right of the bar; flip left if it
// would overflow the canvas edge.
const flipLeft = hover.x > width - 160;
const chipX = flipLeft ? hover.x - 8 : hover.x + 8;
const anchor: "end" | "start" = flipLeft ? "end" : "start";
return (
<g style={{ pointerEvents: "none" }}>
<line
x1={hover.x}
x2={hover.x}
y1={PAD_TOP}
y2={PAD_TOP + innerH}
stroke={s.color}
strokeWidth={1}
strokeDasharray="2 3"
opacity={0.55}
/>
<text
x={chipX}
y={Math.max(14, hover.y - 10)}
textAnchor={anchor}
className="mono"
fontSize={10}
style={{ fill: "hsl(var(--surface-ink-3))" }}
>
{text}
</text>
<text
x={chipX}
y={Math.max(26, hover.y + 2)}
textAnchor={anchor}
className="display mono"
fontSize={16}
style={{ fill: "hsl(var(--surface-ink))" }}
>
{valueText}
</text>
</g>
);
})()
) : null}
</svg>
{/* Legend */}
<div className="flex items-center gap-4 mt-3 flex-wrap">
{series.map((s) => (
<div key={s.id} className="flex items-center gap-2">
<span
aria-hidden
className="inline-block h-2 w-2 rounded-sm"
style={{ backgroundColor: s.color }}
/>
<span
className="mono text-[10.5px] uppercase tracking-[0.16em]"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
{s.label}
</span>
</div>
))}
</div>
</div>
);
}
+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>
);
}
+119
View File
@@ -0,0 +1,119 @@
// ---------------------------------------------------------------------------
// SegmentedBar — thin horizontal status distribution strip.
//
// A single bar split into N color segments showing the share of each
// status. The legend below the bar shows the segment label + count +
// percentage, color-coded to the segment. Designed for 46 statuses.
// ---------------------------------------------------------------------------
import { fmt } from "@/lib/format";
export interface Segment {
id: string;
label: string;
count: number;
color: string;
}
export interface SegmentedBarProps {
segments: Segment[];
/** Optional caption shown above the bar (e.g. "Status distribution"). */
caption?: string;
}
export function SegmentedBar({ segments, caption }: SegmentedBarProps) {
const total = segments.reduce((s, x) => s + x.count, 0);
if (total <= 0) {
return (
<div
className="mono text-[11px] py-6 text-center"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
No claims yet.
</div>
);
}
return (
<div className="w-full">
{caption ? (
<div
className="mono text-[10.5px] uppercase tracking-[0.16em] mb-2"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
{caption}
</div>
) : null}
{/* The bar */}
<div
className="flex w-full h-[10px] overflow-hidden rounded-[3px]"
style={{ boxShadow: "inset 0 0 0 1px hsl(30 14% 14% / 0.08)" }}
>
{segments.map((s, i) => {
const pct = (s.count / total) * 100;
if (pct <= 0) return null;
return (
<div
key={s.id}
className="h-full"
style={{
width: `${pct}%`,
backgroundColor: s.color,
animation: `bar-grow-h 700ms cubic-bezier(0.2,0.8,0.2,1) ${
i * 50
}ms both`,
transformOrigin: "left center",
}}
title={`${s.label}: ${s.count} (${((s.count / total) * 100).toFixed(0)}%)`}
aria-label={`${s.label}: ${s.count} of ${total}`}
/>
);
})}
</div>
{/* Legend */}
<ul className="mt-3 grid grid-cols-2 gap-x-4 gap-y-1.5">
{segments.map((s) => {
const pct = (s.count / total) * 100;
if (s.count === 0) return null;
return (
<li
key={s.id}
className="flex items-center justify-between gap-2 text-[11.5px] min-w-0"
>
<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: s.color }}
/>
<span
className="truncate"
style={{ color: "hsl(var(--surface-ink-2))" }}
>
{s.label}
</span>
</div>
<div className="flex items-center gap-2 shrink-0 mono tabular-nums">
<span style={{ color: "hsl(var(--surface-ink))" }}>
{fmt.num(s.count)}
</span>
<span
style={{
color: "hsl(var(--surface-ink-3))",
fontSize: 10.5,
minWidth: 30,
textAlign: "right",
}}
>
{pct.toFixed(0)}%
</span>
</div>
</li>
);
})}
</ul>
</div>
);
}
+7 -2
View File
@@ -102,10 +102,15 @@ export function Lane({
<section
className="flex-1 min-w-[280px] flex flex-col rounded-md overflow-hidden"
style={{
background: "var(--tt-bg-elev)",
// "Lit from above" gradient — same treatment as the main
// .surface cards, in the inbox's cool/blue-grey hue family.
// Each lane becomes its own small lighter area against the
// surrounding ticker-tape background.
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.03), 0 1px 2px 0 hsl(0 0% 0% / 0.4)",
"inset 0 1px 0 0 hsl(0 0% 100% / 0.05), 0 1px 2px 0 hsl(0 0% 0% / 0.4)",
}}
data-lane={name.toLowerCase()}
>
+288
View File
@@ -0,0 +1,288 @@
// @vitest-environment happy-dom
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
true;
import React, { act } from "react";
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { createRoot, type Root } from "react-dom/client";
// Mock the api + download + toast surfaces so the hook can be exercised
// in isolation. Each test sets up its own expectations on these.
vi.mock("@/lib/api", () => ({
api: {
exportBatch837: vi.fn(),
},
}));
vi.mock("@/lib/download", () => ({
downloadBlob: vi.fn(),
}));
vi.mock("sonner", () => ({
toast: {
success: vi.fn(),
warning: vi.fn(),
error: vi.fn(),
},
}));
import { useBatchExport, type BatchExportItem } from "./useBatchExport";
import { api } from "@/lib/api";
import { downloadBlob } from "@/lib/download";
import { toast } from "sonner";
const mockExportBatch837 = vi.mocked(api.exportBatch837);
const mockDownloadBlob = vi.mocked(downloadBlob);
const mockToastSuccess = vi.mocked(toast.success);
const mockToastWarning = vi.mocked(toast.warning);
const mockToastError = vi.mocked(toast.error);
function makeItem(claimId: string): BatchExportItem {
return { kind: "837p", data: { claim_id: claimId } };
}
function make835Item(id: string): BatchExportItem {
return { kind: "835", data: { payer_claim_control_number: id } };
}
/**
* Render the hook with a fixed initial list and capture its return
* value. The `rerenderWith` helper re-creates the harness with a new
* initial list — that's how we simulate "new claims streamed in"
* without poking at internal useState setters from outside the
* component.
*/
function setup(initial: BatchExportItem[]) {
let latest: ReturnType<typeof useBatchExport> | null = null;
const container = document.createElement("div");
document.body.append(container);
function Harness({ items }: { items: BatchExportItem[] }) {
latest = useBatchExport(items);
return null;
}
const root: Root = createRoot(container);
function render(items: BatchExportItem[]) {
act(() => {
root.render(React.createElement(Harness, { items }));
});
}
render(initial);
return {
get latest(): ReturnType<typeof useBatchExport> {
if (!latest) throw new Error("hook result not yet captured");
return latest;
},
/** Re-render with a new items list (e.g. to simulate streaming). */
rerenderWith(items: BatchExportItem[]) {
render(items);
},
unmount() {
act(() => root.unmount());
container.remove();
},
};
}
describe("useBatchExport", () => {
beforeEach(() => {
mockExportBatch837.mockReset();
mockDownloadBlob.mockReset();
mockToastSuccess.mockReset();
mockToastWarning.mockReset();
mockToastError.mockReset();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("auto-selects all 837P claims passed in initially", () => {
const h = setup([makeItem("CLM-1"), makeItem("CLM-2"), makeItem("CLM-3")]);
expect(h.latest.selectedClaimIds.size).toBe(3);
expect(h.latest.total837InStream).toBe(3);
h.unmount();
});
it("ignores 835 items when counting / selecting", () => {
const h = setup([makeItem("CLM-1"), make835Item("PCN-1")]);
expect(h.latest.selectedClaimIds.size).toBe(1);
expect(h.latest.selectedClaimIds.has("CLM-1")).toBe(true);
expect(h.latest.total837InStream).toBe(1);
h.unmount();
});
it("auto-selects a new 837P claim that streams in", () => {
const h = setup([makeItem("CLM-1")]);
expect(h.latest.selectedClaimIds.size).toBe(1);
h.rerenderWith([makeItem("CLM-1"), makeItem("CLM-2")]);
expect(h.latest.selectedClaimIds.size).toBe(2);
expect(h.latest.selectedClaimIds.has("CLM-2")).toBe(true);
h.unmount();
});
it("does not deselect existing claims when a new one streams in", () => {
const h = setup([makeItem("CLM-1")]);
act(() => {
h.latest.onToggleSelect("CLM-1"); // deselect
});
expect(h.latest.selectedClaimIds.size).toBe(0);
h.rerenderWith([makeItem("CLM-1"), makeItem("CLM-2")]);
// CLM-1 is still deselected (user's choice is preserved), CLM-2
// is auto-selected.
expect(h.latest.selectedClaimIds.has("CLM-1")).toBe(false);
expect(h.latest.selectedClaimIds.has("CLM-2")).toBe(true);
h.unmount();
});
it("onToggleSelect adds and removes", () => {
const h = setup([makeItem("CLM-1"), makeItem("CLM-2")]);
act(() => {
h.latest.onToggleSelect("CLM-1"); // toggle off
});
expect(h.latest.selectedClaimIds.has("CLM-1")).toBe(false);
act(() => {
h.latest.onToggleSelect("CLM-1"); // toggle back on
});
expect(h.latest.selectedClaimIds.has("CLM-1")).toBe(true);
h.unmount();
});
it("onToggleAll deselects when everything is selected", () => {
const h = setup([makeItem("CLM-1"), makeItem("CLM-2")]);
expect(h.latest.selectedClaimIds.size).toBe(2);
act(() => {
h.latest.onToggleAll();
});
expect(h.latest.selectedClaimIds.size).toBe(0);
h.unmount();
});
it("onToggleAll selects everything when partial / none are selected", () => {
const h = setup([makeItem("CLM-1"), makeItem("CLM-2"), makeItem("CLM-3")]);
act(() => {
h.latest.onToggleSelect("CLM-1"); // partial: only CLM-2 and CLM-3
});
act(() => {
h.latest.onToggleSelect("CLM-2");
});
act(() => {
h.latest.onToggleAll();
});
expect(h.latest.selectedClaimIds.size).toBe(3);
h.unmount();
});
it("onExport does nothing when no batch id is recorded", async () => {
const h = setup([makeItem("CLM-1")]);
await h.latest.onExport();
expect(mockExportBatch837).not.toHaveBeenCalled();
expect(mockToastError).toHaveBeenCalled();
h.unmount();
});
it("onExport does nothing when selection is empty (after recordBatchId)", async () => {
const h = setup([makeItem("CLM-1")]);
h.latest.recordBatchId("server-uuid-123");
act(() => {
h.latest.onToggleSelect("CLM-1"); // deselect
});
await h.latest.onExport();
expect(mockExportBatch837).not.toHaveBeenCalled();
h.unmount();
});
it("onExport calls api.exportBatch837 with the recorded batch id and selected ids, then downloadBlob + success toast", async () => {
const h = setup([makeItem("CLM-1"), makeItem("CLM-2")]);
h.latest.recordBatchId("server-uuid-456");
const exportedBlob = new Blob([new Uint8Array([1, 2, 3])], {
type: "application/zip",
});
mockExportBatch837.mockResolvedValueOnce({
blob: exportedBlob,
filename: "batch-server-uuid-456-2-claims.zip",
serializeErrors: [],
});
await h.latest.onExport();
expect(mockExportBatch837).toHaveBeenCalledWith(
"server-uuid-456",
expect.arrayContaining(["CLM-1", "CLM-2"]),
);
expect(mockDownloadBlob).toHaveBeenCalledTimes(1);
// Regression: downloadBlob's signature is (filename, blob). An earlier
// build had the args swapped, which made URL.createObjectURL receive
// the filename string and throw "Overload resolution failed" — pin
// the order here so a swap can't sneak back in.
expect(mockDownloadBlob).toHaveBeenCalledWith(
"batch-server-uuid-456-2-claims.zip",
exportedBlob,
);
expect(mockToastSuccess).toHaveBeenCalledWith(
"Exported 2 claims as X12",
expect.objectContaining({ description: "batch-server-uuid-456-2-claims.zip" }),
);
expect(mockToastWarning).not.toHaveBeenCalled();
h.unmount();
});
it("onExport shows a warning toast with serialize-error details on partial failure", async () => {
const h = setup([makeItem("CLM-1"), makeItem("CLM-2")]);
h.latest.recordBatchId("server-uuid-789");
mockExportBatch837.mockResolvedValueOnce({
blob: new Blob([new Uint8Array([1])]),
filename: "batch-server-uuid-789-1-claims.zip",
serializeErrors: [{ claim_id: "CLM-2", reason: "no raw_json" }],
});
await h.latest.onExport();
expect(mockDownloadBlob).toHaveBeenCalledTimes(1);
expect(mockToastSuccess).not.toHaveBeenCalled();
expect(mockToastWarning).toHaveBeenCalledWith(
"Exported 1 · 1 couldn't be regenerated",
expect.objectContaining({
description: expect.stringContaining("CLM-2: no raw_json"),
}),
);
h.unmount();
});
it("onExport shows an error toast and resets exporting=false on network failure", async () => {
const h = setup([makeItem("CLM-1")]);
h.latest.recordBatchId("server-uuid-abc");
mockExportBatch837.mockRejectedValueOnce(new Error("network down"));
await h.latest.onExport();
expect(mockToastError).toHaveBeenCalledWith("network down");
expect(h.latest.exporting).toBe(false);
h.unmount();
});
it("exporting is true while the export is in flight, false after", async () => {
const h = setup([makeItem("CLM-1")]);
h.latest.recordBatchId("server-uuid-xyz");
let resolveExport: (v: unknown) => void = () => {};
mockExportBatch837.mockReturnValueOnce(
new Promise((res) => {
resolveExport = res;
}) as ReturnType<typeof api.exportBatch837>,
);
const exportPromise = h.latest.onExport();
// Yield once so the exporting=true state has flushed.
await act(async () => {
await Promise.resolve();
});
expect(h.latest.exporting).toBe(true);
resolveExport({
blob: new Blob(),
filename: "x.zip",
serializeErrors: [],
});
// The state update in onExport's `finally` block happens on a
// microtask after the promise resolves; wrapping the await in
// act() flushes it.
await act(async () => {
await exportPromise;
});
expect(h.latest.exporting).toBe(false);
h.unmount();
});
});
+174
View File
@@ -0,0 +1,174 @@
// useBatchExport — owns the per-claim selection state and the
// "click Export → POST → download ZIP" flow for the Upload page's
// streaming view.
//
// Extracted from Upload.tsx in SP9 (June 2026) so the logic is unit-
// testable in isolation from the page (and so the page itself stays
// focused on layout). All state is local to the hook — selection is
// not persisted across navigations, and the captured `batchId` is the
// most-recently-completed parse's server-side id.
import { useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { api } from "@/lib/api";
import { downloadBlob } from "@/lib/download";
/** Minimal shape of a streamed claim this hook needs to read the id. */
export interface BatchExportItem {
kind: "837p" | "835";
data: { claim_id?: string; payer_claim_control_number?: string };
}
export interface UseBatchExportResult {
/** Currently-selected 837P claim ids. */
selectedClaimIds: Set<string>;
/** True while an export POST is in flight. */
exporting: boolean;
/** Total 837P claims currently in the stream. */
total837InStream: number;
/** Toggle a single claim's selection. */
onToggleSelect: (claimId: string) => void;
/** Toggle between "all selected" and "none selected" across the stream. */
onToggleAll: () => void;
/** Fire the export. Returns the promise so tests can await it. */
onExport: () => Promise<void>;
/**
* Called by the parent after a successful parse to record the
* server-side batch id. Captured into a ref so a re-parse that
* completes mid-export can't swap in a different id under the
* export promise.
*/
recordBatchId: (batchId: string | null) => void;
}
export function useBatchExport(items: BatchExportItem[]): UseBatchExportResult {
const [selectedClaimIds, setSelectedClaimIds] = useState<Set<string>>(
() => new Set(),
);
const [exporting, setExporting] = useState(false);
const lastBatchIdRef = useRef<string | null>(null);
// Tracks every claim id that has ever been in the stream. Used by the
// auto-select effect to distinguish "new claim, default-select it"
// from "user previously deselected this claim — leave it alone".
// Without this, a re-render with the same items list would re-add
// every claim that the user explicitly deselected.
const seenClaimIdsRef = useRef<Set<string>>(new Set());
// Auto-select newly streamed 837P claims.
//
// Pre-compute the new claim ids OUTSIDE the setState callback so
// the ref is mutated exactly once per items.length change and the
// setState callback only runs when there's actually something new
// to add. The previous inline pattern (mutating the ref inside the
// setState callback) failed under React StrictMode's effect
// double-invoke: the second invoke saw an empty prev (the first
// invoke's queued update was dropped) AND a ref that already
// contained the ids, so the second invoke added nothing — and the
// selection ended up empty even though the stream populated.
useEffect(() => {
const newIds: string[] = [];
for (const item of items) {
if (item.kind === "837p" && item.data.claim_id) {
const id = item.data.claim_id;
// Only add if we've never seen this id before (so a
// re-render with the same items doesn't re-add anything the
// user just deselected) AND it's not already selected (no
// need to add it again).
if (!seenClaimIdsRef.current.has(id)) {
seenClaimIdsRef.current.add(id);
newIds.push(id);
}
}
}
if (newIds.length === 0) return;
setSelectedClaimIds((prev) => {
const next = new Set(prev);
for (const id of newIds) next.add(id);
return next;
});
// Key on items.length so a re-render with the same set of items
// (just new array identity) doesn't re-run the effect.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [items.length]);
const total837InStream = useMemo(
() => items.filter((i) => i.kind === "837p").length,
[items],
);
function onToggleSelect(claimId: string) {
setSelectedClaimIds((prev) => {
const next = new Set(prev);
if (next.has(claimId)) {
next.delete(claimId);
} else {
next.add(claimId);
}
return next;
});
}
function onToggleAll() {
const streamed837Ids = items
.filter((i): i is BatchExportItem & { data: { claim_id: string } } =>
i.kind === "837p" && !!i.data.claim_id,
)
.map((i) => i.data.claim_id);
if (
streamed837Ids.length > 0 &&
streamed837Ids.every((id) => selectedClaimIds.has(id))
) {
setSelectedClaimIds(new Set());
} else {
setSelectedClaimIds(new Set(streamed837Ids));
}
}
function recordBatchId(batchId: string | null) {
lastBatchIdRef.current = batchId;
}
async function onExport() {
const batchId = lastBatchIdRef.current;
if (!batchId) {
toast.error("No batch to export — parse a file first.");
return;
}
const ids = Array.from(selectedClaimIds);
if (ids.length === 0) return;
setExporting(true);
try {
const { blob, filename, serializeErrors } =
await api.exportBatch837(batchId, ids);
downloadBlob(filename, blob);
if (serializeErrors.length === 0) {
toast.success(`Exported ${ids.length} claims as X12`, {
description: filename,
});
} else {
toast.warning(
`Exported ${ids.length - serializeErrors.length} · ${serializeErrors.length} couldn't be regenerated`,
{
description: serializeErrors
.map((e) => `${e.claim_id}: ${e.reason}`)
.join("\n"),
},
);
}
} catch (err) {
toast.error(err instanceof Error ? err.message : "Export failed");
} finally {
setExporting(false);
}
}
return {
selectedClaimIds,
exporting,
total837InStream,
onToggleSelect,
onToggleAll,
onExport,
recordBatchId,
};
}
+48 -17
View File
@@ -127,36 +127,49 @@
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
/* Subtle "lit from above" wash — gives every page a slightly
lighter zone in the upper third without lifting the base
color. A vertical gradient + a soft top-center halo, both
very low opacity. The composition is additive: a soft glow
behind the page header, fading to nothing well before the
fold. Pointer-events unaffected (it's a background). */
background-image:
radial-gradient(ellipse 90% 38% at 50% -6%, hsla(220, 28%, 26%, 0.22), transparent 62%),
linear-gradient(180deg, hsla(220, 22%, 14%, 0.30) 0%, hsla(220, 22%, 8%, 0.10) 22%, transparent 42%);
background-attachment: fixed;
}
/* A single, precise light source anchored top-right.
One light, not two — an instrument, not a wash. */
/* A precise top-right accent + a complementary soft top-center
softbox. Two light sources, both very subtle — the result reads
as "the chrome is being lit from above" rather than "there's a
gradient in the background". The softbox adds the small lift
the cards sit in; the accent preserves the instrument character. */
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
z-index: 0;
background: radial-gradient(
55rem 35rem at 100% -15%,
hsla(212, 100%, 60%, 0.07),
transparent 62%
);
background:
radial-gradient(ellipse 70% 38% at 50% 0%, hsla(220, 32%, 28%, 0.16), transparent 65%),
radial-gradient(55rem 35rem at 100% -15%, hsla(212, 100%, 60%, 0.09), transparent 62%);
}
/* Hairline grid — the substrate of a precision instrument.
Sits behind everything, gives the dark surfaces a sense of
scale and the work a sense of "field". */
scale and the work a sense of "field". Slightly more present
than before so the texture actually reads against the softer
top wash. */
body::after {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
z-index: 0;
opacity: 0.5;
opacity: 0.6;
background-image:
linear-gradient(to right, hsl(222 10% 16% / 0.18) 1px, transparent 1px),
linear-gradient(to bottom, hsl(222 10% 16% / 0.18) 1px, transparent 1px);
linear-gradient(to right, hsl(222 10% 18% / 0.20) 1px, transparent 1px),
linear-gradient(to bottom, hsl(222 10% 18% / 0.20) 1px, transparent 1px);
background-size: 64px 64px;
mask-image: radial-gradient(ellipse 80% 60% at 50% 30%, black 30%, transparent 80%);
-webkit-mask-image: radial-gradient(ellipse 80% 60% at 50% 30%, black 30%, transparent 80%);
@@ -226,22 +239,40 @@
}
/* Soft card surface — a 1px hairline + a 1px inner highlight to
give the surface a sense of depth without a heavy shadow. */
give the surface a sense of depth without a heavy shadow.
The vertical gradient (lighter at top, darker at bottom) plus
a slightly more present inner highlight makes the card read
as a subtle "lit from above" panel — each card becomes its
own small lighter area without lifting the base card color. */
.surface {
background-color: hsl(var(--card));
background: linear-gradient(
180deg,
hsl(222 16% 10.5%) 0%,
hsl(222 16% 8.5%) 55%,
hsl(222 16% 7.5%) 100%
);
border: 1px solid hsl(var(--border));
box-shadow:
inset 0 1px 0 0 hsl(0 0% 100% / 0.04),
inset 0 1px 0 0 hsl(0 0% 100% / 0.06),
0 1px 2px 0 hsl(0 0% 0% / 0.4);
}
/* Subtle elevated surface — used for cards that should lift a
little more than the default. */
little more than the default. Same "lit from above" treatment
as .surface, with a touch more lift in the gradient stops and
a more visible inner highlight. The card body becomes a
slightly more present lighter area than the surrounding
chrome. */
.surface-2 {
background: linear-gradient(180deg, hsl(222 16% 9%) 0%, hsl(222 16% 7.5%) 100%);
background: linear-gradient(
180deg,
hsl(222 16% 11%) 0%,
hsl(222 16% 9%) 55%,
hsl(222 16% 7.5%) 100%
);
border: 1px solid hsl(var(--border));
box-shadow:
inset 0 1px 0 0 hsl(0 0% 100% / 0.05),
inset 0 1px 0 0 hsl(0 0% 100% / 0.08),
0 1px 3px 0 hsl(0 0% 0% / 0.5);
}
+135
View File
@@ -217,4 +217,139 @@ describe("api GET helpers", () => {
);
await expect(api.serializeClaim837("ghost")).rejects.toBeInstanceOf(ApiError);
});
it("exportBatch837 returns the blob and filename from a 200 ZIP response", async () => {
const zipBytes = new Uint8Array([0x50, 0x4b, 0x03, 0x04]); // ZIP magic
mockFetch.mockResolvedValueOnce(
new Response(zipBytes, {
status: 200,
headers: {
"content-type": "application/zip",
"content-disposition":
'attachment; filename="batch-abc123-3-claims.zip"',
},
})
);
const out = await api.exportBatch837("abc123", ["CLM-1", "CLM-2", "CLM-3"]);
expect(out.filename).toBe("batch-abc123-3-claims.zip");
expect(out.blob).toBeInstanceOf(Blob);
// POST to the batch-scoped endpoint with claim_ids in the body.
const called = mockFetch.mock.calls[0][0] as string;
expect(called).toContain("/api/batches/abc123/export-837");
const init = mockFetch.mock.calls[0][1] as RequestInit;
expect(init.method).toBe("POST");
const body = JSON.parse(init.body as string);
expect(body).toEqual({ claim_ids: ["CLM-1", "CLM-2", "CLM-3"] });
});
it("exportBatch837 parses the X-Cyclone-Serialize-Errors header into serializeErrors", async () => {
const zipBytes = new Uint8Array([0x50, 0x4b, 0x03, 0x04]);
const errs = [
{ claim_id: "CLM-2", reason: "no raw_json" },
{ claim_id: "CLM-3", reason: "raw_json invalid: ..." },
];
mockFetch.mockResolvedValueOnce(
new Response(zipBytes, {
status: 200,
headers: {
"content-type": "application/zip",
"content-disposition":
'attachment; filename="batch-abc123-1-claims.zip"',
"x-cyclone-serialize-errors": JSON.stringify(errs),
},
})
);
const out = await api.exportBatch837("abc123", ["CLM-1", "CLM-2", "CLM-3"]);
expect(out.serializeErrors).toEqual(errs);
});
it("exportBatch837 returns an empty serializeErrors array when the header is missing", async () => {
const zipBytes = new Uint8Array([0x50, 0x4b, 0x03, 0x04]);
mockFetch.mockResolvedValueOnce(
new Response(zipBytes, {
status: 200,
headers: {
"content-type": "application/zip",
"content-disposition": 'attachment; filename="batch-x-1-claims.zip"',
},
})
);
const out = await api.exportBatch837("x", ["CLM-1"]);
expect(out.serializeErrors).toEqual([]);
});
it("exportBatch837 falls back to a default filename when Content-Disposition is missing", async () => {
const zipBytes = new Uint8Array([0x50, 0x4b, 0x03, 0x04]);
mockFetch.mockResolvedValueOnce(
new Response(zipBytes, {
status: 200,
headers: { "content-type": "application/zip" },
})
);
const out = await api.exportBatch837("xyz", ["CLM-1"]);
expect(out.filename).toBe("batch-xyz-claims.zip");
});
it("exportBatch837 throws ApiError on 404", async () => {
mockFetch.mockResolvedValueOnce(
new Response(
JSON.stringify({ error: "Not found", detail: "unknown batch: ghost" }),
{ status: 404, headers: { "content-type": "application/json" } }
)
);
await expect(
api.exportBatch837("ghost", ["CLM-1"])
).rejects.toBeInstanceOf(ApiError);
});
it("exportBatch837 throws on empty claim_ids without making a request", async () => {
await expect(api.exportBatch837("any", [])).rejects.toThrow();
expect(mockFetch).not.toHaveBeenCalled();
});
it("getBatchDiff surfaces a clear ApiError when a backend 400 wraps detail in a nested object", async () => {
// The backend can wrap structured errors as
// `{"detail": {"error": "Missing param", "detail": "..."}}`. The
// client must walk into the nested `detail` so the user sees the
// human-readable message rather than a blob of raw JSON.
mockFetch.mockResolvedValueOnce(
new Response(
JSON.stringify({
detail: {
error: "Missing param",
detail: "Both ?a=<batch_id> and ?b=<batch_id> are required.",
},
}),
{ status: 400, headers: { "content-type": "application/json" } }
)
);
let caught: unknown;
try {
await api.getBatchDiff("a8ebd5f564a547908e7c60d6a129621e", "930f25e07eab4ac0908c9770689385e1");
} catch (err) {
caught = err;
}
expect(caught).toBeInstanceOf(ApiError);
expect((caught as ApiError).status).toBe(400);
// The unwrapped message must surface — not the raw JSON.
expect((caught as ApiError).message).toBe(
"Both ?a=<batch_id> and ?b=<batch_id> are required."
);
});
it("getBatchDiff throws ApiError(400) before hitting the network if either id is empty", async () => {
// Defense-in-depth: the hook's `enabled` guard should prevent this
// from ever firing, but if it ever does, the client must refuse to
// make a malformed request and surface a clear local error.
await expect(api.getBatchDiff("", "930f25e07eab4ac0908c9770689385e1")).rejects.toMatchObject({
status: 400,
message: expect.stringContaining("?a=<batch_id>"),
});
await expect(api.getBatchDiff("a8ebd5f564a547908e7c60d6a129621e", "")).rejects.toMatchObject({
status: 400,
message: expect.stringContaining("?b=<batch_id>"),
});
// Network must not be touched.
expect(mockFetch).not.toHaveBeenCalled();
});
});
+96 -5
View File
@@ -175,12 +175,24 @@ async function readErrorBody(res: Response): Promise<string> {
try {
const t = await res.text();
if (!t) return "";
// FastAPI errors are `{ "error": "...", "detail": "..." }`. Surface the
// detail if present, else the raw text.
// FastAPI errors can be flat `{ "error": "...", "detail": "..." }`
// or wrapped as `{ "detail": { "error": "...", "detail": "..." } }`
// (the wrapper our app uses for structured errors). Walk into nested
// `detail` objects until we hit a string field — guards against the
// raw JSON leaking into the user-visible error message.
try {
const obj = JSON.parse(t) as { detail?: unknown; error?: unknown };
if (typeof obj.detail === "string") return obj.detail;
if (typeof obj.error === "string") return obj.error;
let current: unknown = JSON.parse(t);
for (let depth = 0; depth < 5; depth++) {
if (!current || typeof current !== "object") break;
const obj = current as { detail?: unknown; error?: unknown };
if (typeof obj.detail === "string") return obj.detail;
if (typeof obj.error === "string") return obj.error;
if (obj.detail && typeof obj.detail === "object") {
current = obj.detail;
continue;
}
break;
}
} catch {
// not JSON; fall through
}
@@ -558,6 +570,16 @@ async function getRemittance<T = unknown>(id: string): Promise<T> {
*/
async function getBatchDiff(a: string, b: string): Promise<BatchDiff> {
if (!isConfigured) throw notConfiguredError();
// Defense-in-depth: even though the `useBatchDiff` hook gates on both
// ids being non-empty, refuse to fire the request if either is
// missing. Surfaces a clear local error instead of letting the
// backend return its generic "Missing param" 400.
if (typeof a !== "string" || a.length === 0) {
throw new ApiError(400, "Missing param: ?a=<batch_id> is required.");
}
if (typeof b !== "string" || b.length === 0) {
throw new ApiError(400, "Missing param: ?b=<batch_id> is required.");
}
const res = await fetch(
joinUrl(
`/api/batch-diff?${qs({ a, b })}`,
@@ -786,6 +808,74 @@ async function getAck(id: number): Promise<Ack & { rawJson: unknown }> {
return { ...mapAck(row), rawJson: row.raw_json };
}
/**
* Download a ZIP of regenerated X12 837 files for a parsed batch.
*
* Drives `POST /api/batches/{batchId}/export-837` with the requested
* claim_ids in the body. The backend returns a binary ZIP whose
* entries follow the HCPF X12 File Naming Standards template
* ``{tpid}-837P-{yyyymmddhhmmssSSS}-1of1.x12`` (one file per
* successfully serialized claim, with a per-claim millisecond offset
* so every entry has a unique 17-digit timestamp). The ZIP itself is
* named ``batch-{batchId}-{N}-claims.zip`` via Content-Disposition,
* where N is the success count.
*
* Per-claim serialization failures are surfaced via the
* `X-Cyclone-Serialize-Errors` response header (JSON-encoded array of
* `{claim_id, reason}`). The ZIP still contains the successful claims;
* the failures are returned alongside so the UI can show a partial-
* success toast like "Exported 18 · 2 couldn't be regenerated".
*
* Throws `ApiError` on non-2xx — 404 (batch missing) is the most
* likely case. The client-side guard rejects empty `claimIds` without
* making a request.
*/
export interface BatchExportResult {
blob: Blob;
filename: string;
serializeErrors: Array<{ claim_id: string; reason: string }>;
}
async function exportBatch837(
batchId: string,
claimIds: string[],
): Promise<BatchExportResult> {
if (!isConfigured) throw notConfiguredError();
if (claimIds.length === 0) {
throw new Error("claimIds is empty");
}
const res = await fetch(
joinUrl(`/api/batches/${encodeURIComponent(batchId)}/export-837`),
{
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/zip",
},
body: JSON.stringify({ claim_ids: claimIds }),
},
);
if (!res.ok) {
const detail = await readErrorBody(res);
throw new ApiError(res.status, detail || res.statusText);
}
const blob = await res.blob();
const cd = res.headers.get("content-disposition") ?? "";
const match = /filename="?([^";]+)"?/i.exec(cd);
const filename = match?.[1] ?? `batch-${batchId}-claims.zip`;
const errHeader = res.headers.get("x-cyclone-serialize-errors");
let serializeErrors: Array<{ claim_id: string; reason: string }> = [];
if (errHeader) {
try {
serializeErrors = JSON.parse(errHeader);
} catch {
// Malformed header — treat as empty rather than failing the download.
serializeErrors = [];
}
}
return { blob, filename, serializeErrors };
}
export const api = {
isConfigured,
baseUrl: BASE_URL,
@@ -799,6 +889,7 @@ export const api = {
listClaims,
getClaimDetail,
serializeClaim837,
exportBatch837,
listRemittances,
getRemittance,
listProviders,
+392 -703
View File
File diff suppressed because it is too large Load Diff
+350 -573
View File
File diff suppressed because it is too large Load Diff
+159 -491
View File
@@ -4,7 +4,9 @@ import { Files, GitBranch, Layers, Receipt } from "lucide-react";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { EmptyState } from "@/components/ui/empty-state";
import { ErrorState } from "@/components/ui/error-state";
import { Skeleton } from "@/components/ui/skeleton";
import { Card, CardContent } from "@/components/ui/card";
import { KpiCard } from "@/components/KpiCard";
import { PageHeader } from "@/components/PageHeader";
import { BatchesList, BatchesListSkeleton } from "@/components/BatchesList";
import {
BatchDetailContent,
@@ -16,6 +18,7 @@ import { useBatches } from "@/hooks/useBatches";
import { useDrawerKeyboard } from "@/hooks/useDrawerKeyboard";
import { api, ApiError, type BatchSummary } from "@/lib/api";
import { cn } from "@/lib/utils";
import { fmt } from "@/lib/format";
import type { ParseResult837, ParseResult835 } from "@/types";
function readBatchId(): string | null {
@@ -120,12 +123,16 @@ function useBatchDetail(id: string | null): {
const HELP_NOOP = () => {};
/**
* Batches page — hybrid Magazine Spread treatment.
* Batches page — dark instrument treatment, in the same chrome
* language as Dashboard / Upload / Reconciliation / Acks.
*
* Dark editorial hero → torn-page fold → cream paper plane with the
* batch register (KPI strip + tabular list). Click a row to open the
* detail drawer (rendered above the page, dimmed via the body
* opacity).
* PageHeader hero (eyebrow + display title + status pill + ghost
* watermark) → KpiCard strip (4 tiles) → single Card wrapping the
* tabular register → quiet hairline footer.
*
* Click a row to open the detail drawer (rendered above the page,
* dimmed via the body opacity). Keyboard: ← / → step through
* batches while the drawer is open; Esc closes.
*/
export function Batches() {
const { data, isLoading, isError, error, refetch } = useBatches(100);
@@ -166,7 +173,7 @@ export function Batches() {
const errKind = batchErrorKind(detailError);
// -----------------------------------------------------------------
// Aggregate metrics for the § 01 KPI strip
// Aggregate metrics for the KPI strip + hero copy.
// -----------------------------------------------------------------
const totals = items.reduce(
(acc, b) => ({
@@ -178,14 +185,20 @@ export function Batches() {
{ count: 0, p837: 0, p835: 0, claims: 0 },
);
const lastParsed = items[0]?.parsedAt;
// The ghost watermark carries the on-file count, scaled like the
// other pages (clamp 72140px, 4.5% opacity, right-anchored).
const watermark = totals.count > 0 ? totals.count.toLocaleString() : "BATCH";
// -----------------------------------------------------------------
// Stagger choreography (matches Acks / Reconciliation / Upload)
// Stagger choreography — hero lands first, KPI strip at 140ms,
// register at 240ms, footer at 320ms. Total < 500ms.
// -----------------------------------------------------------------
const heroDelay = 0;
const foldDelay = 220;
const titleDelay = 320;
const kpiDelay = 460;
const tableDelay = 600;
const kpiDelay = 140;
const tableDelay = 240;
const footerDelay = 320;
return (
<>
@@ -219,541 +232,196 @@ export function Batches() {
<div
data-testid="batches-page-body"
className={cn(
"space-y-0 animate-fade-in transition-opacity",
"space-y-6 lg:space-y-10 animate-fade-in transition-opacity",
dimBackground && "pointer-events-none opacity-60",
)}
>
{/* =================================================================
HERO — DARK EDITORIAL HEADER
HERO — dark editorial header
================================================================= */}
<section
className="relative pt-6 pb-8 lg:pt-9 lg:pb-10"
className="relative animate-fade-in overflow-hidden"
style={{ animationDelay: `${heroDelay}ms` }}
>
{/* Ghost "PARSED" watermark — a print-shop stamp behind the title. */}
<div
aria-hidden="true"
className="pointer-events-none select-none absolute inset-x-0 top-[58%] -translate-y-1/2 whitespace-nowrap display text-center"
className="pointer-events-none select-none absolute -right-4 top-1/2 -translate-y-1/2 whitespace-nowrap display text-foreground"
style={{
fontSize: "clamp(160px, 20vw, 300px)",
letterSpacing: "-0.05em",
opacity: 0.05,
fontSize: "clamp(72px, 12vw, 140px)",
letterSpacing: "-0.04em",
opacity: 0.045,
lineHeight: 1,
color: "hsl(var(--surface-ink))",
}}
>
PARSED
{watermark}
</div>
<div className="relative z-10 grid grid-cols-1 lg:grid-cols-[1fr,auto] gap-8 items-end mb-7">
<div className="min-w-0 max-w-3xl">
<div className="flex items-center gap-3 mb-5">
<div className="h-px w-14 bg-foreground/25" />
<span className="mono text-[12px] uppercase tracking-[0.22em] text-muted-foreground">
Batches · Register
</span>
<span
className="mono text-[12px] uppercase tracking-[0.22em] text-muted-foreground/60 hidden sm:inline"
aria-hidden
>
· {totals.count} on file
</span>
</div>
<h1 className="display text-[48px] sm:text-[64px] lg:text-[80px] leading-[0.92] text-foreground tracking-[-0.04em]">
Parsed{" "}
<span className="italic text-muted-foreground/85">batches.</span>
</h1>
<div className="relative z-10 flex items-end justify-between gap-4 sm:gap-6 flex-wrap">
<div className="min-w-0">
<PageHeader
eyebrow={`Batches · Register · ${totals.count} on file`}
title={
<>
Parsed{" "}
<span className="italic text-muted-foreground/85">
batches.
</span>
</>
}
subtitle={
<>
Every 837P and 835 file the backend has ingested. Click a
row for envelope, summary, and a peek at the contained
claims.
</>
}
/>
</div>
<div className="flex flex-col items-start lg:items-end gap-3">
<div className="inline-flex items-center gap-2 rounded-full border border-border/60 bg-card/60 px-3.5 py-2 text-[11.5px] mono uppercase tracking-[0.14em] text-muted-foreground backdrop-blur">
<Files
className="h-3.5 w-3.5"
strokeWidth={1.75}
style={{ color: "hsl(var(--accent))" }}
/>
837P · 835 · envelope & summary
</div>
<kbd
className="mono text-[10.5px] uppercase tracking-[0.18em] text-muted-foreground/70"
>
Tap a row to open the drawer
</kbd>
<div className="inline-flex items-center gap-2 rounded-full border border-border/60 bg-card/60 px-3 py-1.5 text-[11px] mono uppercase tracking-[0.12em] text-muted-foreground backdrop-blur">
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-[hsl(var(--accent))]">
<span className="absolute inline-flex h-full w-full rounded-full bg-[hsl(var(--accent))] opacity-60 animate-ping" />
</span>
<Files className="h-3.5 w-3.5" strokeWidth={1.75} />
837P · 835 · envelope &amp; summary
</div>
</div>
<div className="relative z-10 max-w-2xl">
<p className="text-[14px] text-muted-foreground leading-relaxed">
Every 837P and 835 file the backend has ingested. Click a row
for envelope, summary, and a peek at the contained claims.
</p>
</div>
</section>
{/* =================================================================
FOLD — TORN PAGE
KPI STRIP — four standard KpiCards, accent tokens instead of
paper-tinted custom tiles.
================================================================= */}
<div
aria-hidden
className="relative h-14"
style={{ animationDelay: `${foldDelay}ms` }}
<section
aria-label="Batch register metrics"
className="grid gap-3.5 grid-cols-2 md:grid-cols-4 animate-fade-in-up"
style={{ animationDelay: `${kpiDelay}ms` }}
>
<div
className="absolute inset-x-0 top-0 h-1/2"
style={{
background:
"linear-gradient(to bottom, hsl(0 0% 0% / 0.55), transparent)",
}}
<KpiCard
label="On file"
value={fmt.num(totals.count)}
icon={Layers}
accent="default"
hint={totals.count === 1 ? "batch" : "batches"}
/>
<div
className="absolute inset-x-0 bottom-0 h-1/2"
style={{
background:
"linear-gradient(to top, hsl(30 14% 14% / 0.18), transparent)",
}}
<KpiCard
label="837P"
value={fmt.num(totals.p837)}
icon={GitBranch}
accent="accent"
hint="professional claims"
/>
<div
className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-px"
style={{
background:
"linear-gradient(to right, transparent, hsl(30 14% 14% / 0.5) 12%, hsl(30 14% 14% / 0.7) 50%, hsl(30 14% 14% / 0.5) 88%, transparent)",
}}
<KpiCard
label="835"
value={fmt.num(totals.p835)}
icon={Receipt}
accent="warning"
hint="remittance advice"
/>
<div
className="absolute inset-x-0 top-1/2 -translate-y-1/2 flex justify-between px-2"
style={{ pointerEvents: "none" }}
>
{Array.from({ length: 48 }, (_, i) => (
<span
key={i}
className="block h-[3px] w-[3px] rounded-full"
style={{ backgroundColor: "hsl(30 14% 14% / 0.22)" }}
/>
))}
</div>
<div className="relative z-10 mx-auto h-full flex items-center justify-center gap-3 bg-background px-4 w-fit">
<span
className="display italic"
style={{ color: "hsl(var(--muted-foreground))", fontSize: 15 }}
>
</span>
<span
className="mono uppercase tracking-[0.24em]"
style={{
color: "hsl(var(--muted-foreground))",
fontSize: 11,
fontWeight: 500,
}}
>
The register
</span>
<span
className="display italic"
style={{ color: "hsl(var(--muted-foreground))", fontSize: 15 }}
>
</span>
</div>
</div>
<KpiCard
label="Claims"
value={fmt.num(totals.claims)}
accent="success"
hint="total in register"
/>
</section>
{/* =================================================================
PAPER PLANE
REGISTER — single Card wrapping the tabular list. Same
chrome language as the rest of the app: eyebrow + display
title + sidecar hint, dark-tone table.
================================================================= */}
<div
className="relative max-w-[1280px] mx-auto"
style={{
animationDelay: `${titleDelay}ms`,
backgroundColor: "hsl(var(--surface))",
boxShadow:
"inset 0 1px 0 0 hsl(0 0% 100% / 0.5), 0 1px 0 0 hsl(30 14% 22% / 0.06), 0 30px 80px -24px hsl(0 0% 0% / 0.45)",
}}
<section
aria-label="Batch register"
className="animate-fade-in-up"
style={{ animationDelay: `${tableDelay}ms` }}
>
{/* Paper grain */}
<div
aria-hidden
className="pointer-events-none absolute inset-0 opacity-[0.04]"
style={{
backgroundImage:
"url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='160' height='160'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 0.1 0 0 0 0 0.08 0 0 0 0 0.05 0 0 0 0.9 0'/></filter><rect width='100%' height='100%' filter='url(%23n)'/></svg>\")",
backgroundSize: "160px 160px",
mixBlendMode: "multiply",
}}
/>
{/* Title block */}
<div
className="relative px-8 lg:px-14 pt-12 pb-9 border-b animate-fade-in-up"
style={{
animationDelay: `${titleDelay}ms`,
borderColor: "hsl(30 14% 14% / 0.12)",
}}
>
<div
aria-hidden
className="absolute left-7 lg:left-12 top-0 bottom-0 w-px"
style={{ backgroundColor: "hsl(30 14% 14% / 0.14)" }}
/>
<div className="flex items-end justify-between gap-8 flex-wrap">
<div>
<div
className="mono text-[12px] uppercase tracking-[0.24em] mb-4 font-medium"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
Register · Parsed batches
</div>
<h2
className="display leading-[0.92] tracking-[-0.04em]"
style={{
color: "hsl(var(--surface-ink))",
fontSize: "clamp(48px, 7vw, 96px)",
fontWeight: 400,
}}
>
The register.
</h2>
<div
className="mt-4 display italic"
style={{
color: "hsl(var(--surface-ink-2))",
fontSize: "clamp(15px, 1.3vw, 18px)",
lineHeight: 1.4,
maxWidth: "32ch",
}}
>
One row per ingested file the kind, the input filename,
how many claims it carried, and the day it was parsed.
</div>
</div>
<div className="text-right shrink-0">
<div
className="mono text-[11px] uppercase tracking-[0.24em] mb-1.5 font-medium"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
On file
</div>
<div
className="display tabular-nums"
style={{
color: "hsl(var(--surface-ink))",
fontSize: 26,
lineHeight: 1.1,
}}
>
{totals.count}
</div>
<div
className="mono text-[11px] mt-1"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
batch{totals.count === 1 ? "" : "es"} · {totals.claims}{" "}
claim{totals.claims === 1 ? "" : "s"}
</div>
</div>
</div>
</div>
{/* KPI strip — § 01 Vital signs */}
<section
aria-label="Batch register metrics"
className="relative px-8 lg:px-14 py-7 animate-fade-in-up"
style={{ animationDelay: `${kpiDelay}ms` }}
>
<div
aria-hidden
className="absolute left-7 lg:left-12 top-7 flex flex-col items-center gap-1"
>
<span
className="mono text-[10px] uppercase tracking-[0.18em] font-semibold"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
§ 01
</span>
<div
className="w-px h-10"
style={{ backgroundColor: "hsl(30 14% 14% / 0.18)" }}
/>
<span
className="display italic"
style={{
color: "hsl(var(--surface-ink-2))",
fontSize: 11,
writingMode: "vertical-rl",
transform: "rotate(180deg)",
letterSpacing: "0.16em",
}}
>
Vital signs
</span>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<BatchesKpiTile
label="On file"
value={totals.count}
icon={<Layers className="h-3 w-3" strokeWidth={1.75} />}
tone="ink"
/>
<BatchesKpiTile
label="837P"
value={totals.p837}
icon={<GitBranch className="h-3 w-3" strokeWidth={1.75} />}
tone="blue"
/>
<BatchesKpiTile
label="835"
value={totals.p835}
icon={<Receipt className="h-3 w-3" strokeWidth={1.75} />}
tone="amber"
/>
<BatchesKpiTile
label="Claims"
value={totals.claims}
tone="success"
/>
</div>
</section>
{/* Table — § 02 The register */}
<section
aria-label="Batch register"
className="relative px-8 lg:px-14 pb-8 lg:pb-10 animate-fade-in-up"
style={{ animationDelay: `${tableDelay}ms` }}
>
<div
aria-hidden
className="absolute left-7 lg:left-12 top-9 flex flex-col items-center gap-1"
>
<span
className="mono text-[10px] uppercase tracking-[0.18em] font-semibold"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
§ 02
</span>
<div
className="w-px h-12"
style={{ backgroundColor: "hsl(30 14% 14% / 0.18)" }}
/>
<span
className="display italic"
style={{
color: "hsl(var(--surface-ink-2))",
fontSize: 11,
writingMode: "vertical-rl",
transform: "rotate(180deg)",
letterSpacing: "0.16em",
}}
>
The register
</span>
</div>
<div
className="pt-6 border-t"
style={{
borderTopStyle: "double",
borderTopWidth: 3,
borderColor: "hsl(30 14% 14% / 0.12)",
}}
>
<div className="flex items-end justify-between gap-6 flex-wrap mb-5">
<div>
<div
className="mono text-[11.5px] uppercase tracking-[0.24em] font-semibold mb-2"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
<Card>
<CardContent className="p-6 lg:p-7 space-y-5">
<div className="flex items-end justify-between gap-6 flex-wrap">
<div className="min-w-0">
<div className="eyebrow flex items-center gap-2 mb-2">
<span className="inline-block h-px w-6 bg-foreground/20" />
The register
</div>
<h3
className="display leading-[0.98] tracking-[-0.03em]"
style={{
color: "hsl(var(--surface-ink))",
fontSize: "clamp(24px, 2.6vw, 32px)",
fontWeight: 400,
}}
>
<h2 className="display text-[26px] leading-[1.05] tracking-[-0.02em]">
All <span className="italic">ingests</span>, newest first.
</h3>
</h2>
</div>
<div
className="text-[12.5px] display italic"
style={{ color: "hsl(var(--surface-ink-2))", maxWidth: 380 }}
>
<p className="text-[12.5px] text-muted-foreground/80 max-w-sm">
Tap a row to open the envelope drawer. Use{" "}
<kbd
className="mono not-italic text-[10.5px] uppercase tracking-[0.14em] px-1 py-0.5 rounded-sm border"
style={{
borderColor: "hsl(30 14% 14% / 0.20)",
color: "hsl(var(--surface-ink))",
}}
>
<kbd className="rounded-sm border border-border/60 bg-card/40 px-1.5 py-0.5 not-italic text-foreground/80 mono text-[10.5px] uppercase tracking-[0.14em]">
</kbd>{" "}
/{" "}
<kbd
className="mono not-italic text-[10.5px] uppercase tracking-[0.14em] px-1 py-0.5 rounded-sm border"
style={{
borderColor: "hsl(30 14% 14% / 0.20)",
color: "hsl(var(--surface-ink))",
}}
>
<kbd className="rounded-sm border border-border/60 bg-card/40 px-1.5 py-0.5 not-italic text-foreground/80 mono text-[10.5px] uppercase tracking-[0.14em]">
</kbd>{" "}
inside the drawer to step.
</div>
</p>
</div>
{isError ? (
<div
className="rounded-md border p-5"
style={{
borderColor: "hsl(30 14% 14% / 0.16)",
backgroundColor: "hsl(36 22% 96%)",
}}
>
<div className="pt-5 border-t border-border/40">
{isError ? (
<ErrorState
message="Couldn't load batches from the backend."
detail={error instanceof Error ? error.message : String(error)}
onRetry={() => refetch()}
/>
</div>
) : isLoading ? (
<div
className="rounded-md border p-4 space-y-2"
style={{
borderColor: "hsl(30 14% 14% / 0.10)",
backgroundColor: "hsl(36 22% 96%)",
}}
>
) : isLoading ? (
<BatchesListSkeleton />
</div>
) : items.length === 0 ? (
<div
className="rounded-md border p-10 text-center"
style={{
borderColor: "hsl(30 14% 14% / 0.16)",
borderStyle: "dashed",
backgroundColor: "hsl(36 22% 96%)",
}}
>
<EmptyState
eyebrow="Batches · awaiting first ingest"
message="Upload an 837P or 835 file on the Upload page to populate this list."
/>
</div>
) : (
<div
className="rounded-md border overflow-hidden"
style={{
borderColor: "hsl(30 14% 14% / 0.10)",
backgroundColor: "hsl(36 22% 98%)",
boxShadow:
"inset 0 1px 0 0 hsl(0 0% 100% / 0.5)",
}}
>
<BatchesList
items={items}
openId={batchId}
onOpen={open}
tone="paper"
/>
</div>
)}
</div>
</section>
) : items.length === 0 ? (
<div className="rounded-md border border-dashed border-border/60 bg-card/40">
<EmptyState
eyebrow="Batches · awaiting first ingest"
message="Upload an 837P or 835 file on the Upload page to populate this list."
/>
</div>
) : (
<div className="rounded-md border border-border/60 overflow-hidden bg-card/40">
<BatchesList
items={items}
openId={batchId}
onOpen={open}
/>
</div>
)}
</div>
</CardContent>
</Card>
</section>
{/* Footer */}
<div
className="relative px-8 lg:px-14 py-5 border-t flex items-center justify-between mono text-[10.5px] uppercase tracking-[0.18em]"
style={{
borderColor: "hsl(30 14% 14% / 0.12)",
color: "hsl(var(--surface-ink-3))",
}}
>
<span>End of register</span>
<span>Cyclone · Batches</span>
{/* =================================================================
FOOTER — single hairline-separated status row. Replaces the
warm-paper "End of register" treatment with a quiet
instrument-style status line in the same rhythm as the
other pages.
================================================================= */}
<section
aria-label="Register status"
className="animate-fade-in-up flex items-center justify-between gap-4 flex-wrap pt-5 border-t border-border/40"
style={{ animationDelay: `${footerDelay}ms` }}
>
<div className="flex items-center gap-2 mono text-[10.5px] uppercase tracking-[0.18em] text-muted-foreground/60">
<span className="h-1.5 w-1.5 rounded-full bg-[hsl(var(--accent))]" />
Cyclone · Batches
</div>
<div className="flex items-center gap-3 mono text-[10.5px] uppercase tracking-[0.18em] text-muted-foreground/60">
<span>
{totals.count} {totals.count === 1 ? "row" : "rows"}
</span>
<span className="text-muted-foreground/30" aria-hidden>
·
</span>
<span>last parsed {lastParsed ? fmt.dateShort(lastParsed) : "—"}</span>
</div>
</div>
<div className="flex items-center gap-1.5 mono text-[10.5px] uppercase tracking-[0.18em] text-muted-foreground/60">
<span>open drawer</span>
<kbd className="rounded-sm border border-border/60 bg-card/40 px-1.5 py-0.5 not-italic text-foreground/80">
</kbd>
</div>
</section>
</div>
</>
);
}
// ---------------------------------------------------------------------------
// BatchesKpiTile — paper-toned metric for the batch register.
// Mirrors AckKpiTile / KpiTile in the other hybrid pages.
// ---------------------------------------------------------------------------
function BatchesKpiTile({
label,
value,
tone,
icon,
}: {
label: string;
value: number;
tone: "blue" | "amber" | "ink" | "success";
icon?: React.ReactNode;
}) {
const accentMap = {
blue: "hsl(212 100% 45%)",
amber: "hsl(36 92% 50%)",
ink: "hsl(var(--surface-ink))",
success: "hsl(152 64% 38%)",
} as const;
const tintMap = {
blue: "hsl(212 85% 92%)",
amber: "hsl(36 82% 92%)",
ink: "hsl(36 22% 90%)",
success: "hsl(152 50% 88%)",
} as const;
const accent = accentMap[tone];
const tint = tintMap[tone];
return (
<div
className="relative rounded-xl p-5 overflow-hidden border"
style={{
backgroundColor: "hsl(var(--surface))",
boxShadow:
"inset 0 1px 0 0 hsl(0 0% 100% / 0.45), 0 1px 0 0 hsl(30 14% 22% / 0.06), inset 3px 0 0 0 hsl(0 0% 100% / 0.4)",
borderColor: "hsl(30 14% 14% / 0.10)",
}}
>
<div
aria-hidden
className="absolute left-0 top-5 bottom-5 w-[3px] rounded-r-sm"
style={{ backgroundColor: accent, opacity: 0.85 }}
/>
<div className="flex items-center justify-between mb-2.5">
<div
className="mono text-[10px] uppercase tracking-[0.18em] font-semibold"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
{label}
</div>
<div
className="h-5 w-5 rounded-md flex items-center justify-center"
style={{ backgroundColor: tint, color: accent }}
>
{icon ?? (
<span
className="block h-1.5 w-1.5 rounded-full"
style={{ backgroundColor: accent }}
/>
)}
</div>
</div>
<div
className="display tabular-nums tracking-[-0.04em]"
style={{
color: "hsl(var(--surface-ink))",
fontSize: "clamp(28px, 3vw, 40px)",
lineHeight: 1,
fontWeight: 400,
}}
>
{value}
</div>
</div>
);
}
+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>
);
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -8,7 +8,8 @@ import React, { act, useEffect } from "react";
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { MemoryRouter, useLocation } from "react-router-dom";
import { createRoot, type Root } from "react-dom/client";
import { ClaimCard837, ClaimCard835 } from "./Upload";
import { ClaimCard837 } from "@/components/ClaimCard837";
import { ClaimCard835 } from "./Upload";
import { useAppStore } from "@/store";
import type { ClaimOutput, ClaimPayment, ParsedBatch } from "@/types";
+537 -1020
View File
File diff suppressed because it is too large Load Diff
+9
View File
@@ -299,6 +299,13 @@ export interface ParseResult837 {
envelope: Envelope | null;
claims: ClaimOutput[];
summary: BatchSummary;
/**
* Server-side batch id (UUID). Present on the JSON response from
* POST /api/parse-837 — null/missing on the NDJSON-streaming path
* (use the final `summary.batch_id` from the NDJSON stream instead).
* Required by /api/batches/{batch_id}/export-837.
*/
batch_id?: string | null;
}
// --- 835 ERA ---------------------------------------------------------------
@@ -379,6 +386,8 @@ export interface ParseResult835 {
claims: ClaimPayment[];
summary: BatchSummary;
validation?: ValidationReport | null;
/** See ParseResult837.batch_id. */
batch_id?: string | null;
}
// --- NDJSON streaming ------------------------------------------------------