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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user