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.
167 lines
5.7 KiB
TypeScript
167 lines
5.7 KiB
TypeScript
import { fmt } from "@/lib/format";
|
|
import type {
|
|
LineReconciliationResponse,
|
|
LineReconciliationRow,
|
|
} from "@/types";
|
|
|
|
/**
|
|
* Per-line reconciliation tab for the ClaimDrawer (SP7 §6.2).
|
|
*
|
|
* Two-column layout: left = "Billed (837 SV1)", right = "Adjudicated
|
|
* (835 SVC)". Each row is a service line, matched when both sides
|
|
* are present; unmatched 835 lines appear at the bottom with an
|
|
* oxblood accent rail. Per-line CAS adjustments are listed under each
|
|
* row.
|
|
*/
|
|
export function LineReconciliationTab({
|
|
data,
|
|
}: {
|
|
data: LineReconciliationResponse;
|
|
}) {
|
|
const { summary, lines } = data;
|
|
const allMatched = summary.matchedLines === summary.totalLines;
|
|
const leftRows = lines.filter((l) => l.status !== "unmatched_835_only");
|
|
const rightRows = lines;
|
|
|
|
return (
|
|
<section className="flex flex-col gap-4 px-6 py-4">
|
|
<header
|
|
className="flex items-baseline justify-between flex-wrap gap-3"
|
|
data-testid="line-reconciliation-summary"
|
|
>
|
|
<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-muted-foreground eyebrow">
|
|
Billed
|
|
</span>
|
|
<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-muted-foreground eyebrow">
|
|
Paid
|
|
</span>
|
|
<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-muted-foreground eyebrow">
|
|
Adjustments
|
|
</span>
|
|
<span className="text-foreground font-semibold">
|
|
{fmt.usdPrecise(Number(summary.adjustmentTotal))}
|
|
</span>
|
|
</span>
|
|
</div>
|
|
<span
|
|
className="font-mono text-xs uppercase tracking-[0.14em]"
|
|
style={{
|
|
color: allMatched ? "var(--tt-amber)" : "var(--tt-oxblood)",
|
|
}}
|
|
data-testid="match-summary"
|
|
>
|
|
{allMatched
|
|
? `matched ${summary.matchedLines} of ${summary.totalLines} lines`
|
|
: `matched ${summary.matchedLines} of ${summary.totalLines} lines (${summary.totalLines - summary.matchedLines} unmatched)`}
|
|
</span>
|
|
</header>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div data-testid="left-column">
|
|
<h3 className="eyebrow text-muted-foreground mb-2">
|
|
Billed (837 SV1)
|
|
</h3>
|
|
{leftRows.map((row) => (
|
|
<LineCard key={rowKey(row, "left")} row={row} side="left" />
|
|
))}
|
|
</div>
|
|
<div data-testid="right-column">
|
|
<h3 className="eyebrow text-muted-foreground mb-2">
|
|
Adjudicated (835 SVC)
|
|
</h3>
|
|
{rightRows.map((row) => (
|
|
<LineCard key={rowKey(row, "right")} row={row} side="right" />
|
|
))}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function rowKey(row: LineReconciliationRow, side: "left" | "right"): string {
|
|
const ln =
|
|
row.claimServiceLine?.lineNumber ??
|
|
row.serviceLinePayment?.lineNumber ??
|
|
-1;
|
|
return `${side}-${row.status}-${ln}`;
|
|
}
|
|
|
|
function LineCard({
|
|
row,
|
|
side,
|
|
}: {
|
|
row: LineReconciliationRow;
|
|
side: "left" | "right";
|
|
}) {
|
|
const cl = row.claimServiceLine;
|
|
const svc = row.serviceLinePayment;
|
|
const isUnmatched835 = row.status === "unmatched_835_only";
|
|
const accentColor = isUnmatched835 ? "var(--tt-oxblood)" : "var(--tt-ink-blue)";
|
|
const procCode = cl?.procedureCode ?? svc?.procedureCode ?? "—";
|
|
const lineNumber = cl?.lineNumber ?? svc?.lineNumber ?? null;
|
|
|
|
return (
|
|
<div
|
|
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)",
|
|
}}
|
|
data-line-status={row.status}
|
|
>
|
|
<div className="flex items-baseline justify-between">
|
|
<div className="font-mono text-sm">
|
|
<span className="font-semibold text-foreground">{procCode}</span>
|
|
{lineNumber !== null ? (
|
|
<span className="text-muted-foreground ml-2">
|
|
#{lineNumber}
|
|
</span>
|
|
) : null}
|
|
{isUnmatched835 ? (
|
|
<span
|
|
className="ml-2 text-xs italic"
|
|
style={{ color: "var(--tt-oxblood)" }}
|
|
>
|
|
no 837 line matched
|
|
</span>
|
|
) : null}
|
|
{row.status === "unmatched_837_only" ? (
|
|
<span
|
|
className="ml-2 text-xs italic"
|
|
style={{ color: "var(--tt-oxblood)" }}
|
|
>
|
|
no 835 line adjudicated this line
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
<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-muted-foreground mt-1 space-y-0.5">
|
|
{row.adjustments.map((a, i) => (
|
|
<li key={i}>
|
|
<span className="text-foreground/80">{a.groupCode}-{a.reasonCode}</span>
|
|
<span className="ml-2 tabular-nums">{fmt.usdPrecise(Number(a.amount))}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|