9bca4b608a
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.
353 lines
13 KiB
TypeScript
353 lines
13 KiB
TypeScript
// 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>
|
|
);
|
|
}
|