feat(sp7): CasAdjustmentsPanel — per-line + claim-level sections

This commit is contained in:
Tyler
2026-06-20 19:46:30 -06:00
parent 77f61b675d
commit 00cdd068fc
2 changed files with 270 additions and 53 deletions
@@ -71,6 +71,68 @@ describe("CasAdjustmentsPanel", () => {
unmount(); unmount();
}); });
it("renders_per_line_section_with_procedure_and_line_number", () => {
const { container, unmount } = renderIntoContainer(
<CasAdjustmentsPanel
adjustments={[]}
perLine={[
{
slp: {
id: 1,
lineNumber: 1,
procedureQualifier: "HC",
procedureCode: "99213",
modifiers: [],
charge: "100.00",
payment: "80.00",
units: "1",
unitType: "UN",
serviceDate: "2026-06-01",
},
adjustments: [
{ groupCode: "CO", reasonCode: "45", amount: "20.00" },
],
},
]}
/>
);
const section = container.querySelector(
'[data-testid="per-line-cas-section"]'
);
expect(section).not.toBeNull();
const row = container.querySelector('[data-testid="per-line-cas-row"]');
expect(row).not.toBeNull();
expect(row?.getAttribute("data-line-number")).toBe("1");
expect(row?.textContent).toContain("99213");
expect(row?.textContent).toContain("#1");
expect(row?.textContent).toContain("CO-45");
unmount();
});
it("renders_claim_level_section_with_subheader", () => {
const { container, unmount } = renderIntoContainer(
<CasAdjustmentsPanel
adjustments={[]}
claimLevelAdjustments={[
{ id: 1, groupCode: "PR", reasonCode: "1", amount: "10.00", quantity: null },
]}
/>
);
const section = container.querySelector(
'[data-testid="claim-level-cas-section"]'
);
expect(section).not.toBeNull();
expect(section?.textContent ?? "").toMatch(/Claim-level adjustments/i);
const entry = container.querySelector('[data-testid="claim-level-cas-entry"]');
expect(entry).not.toBeNull();
expect(entry?.textContent).toContain("PR-1");
unmount();
});
it("toggle_collapses_and_expands_the_list", () => { it("toggle_collapses_and_expands_the_list", () => {
// A long list (> 3 groups) starts collapsed so the drawer doesn't // A long list (> 3 groups) starts collapsed so the drawer doesn't
// get buried under a wall of CARC rows. The user can re-expand by // get buried under a wall of CARC rows. The user can re-expand by
@@ -3,9 +3,36 @@ import { ChevronDown, ChevronRight } from "lucide-react";
import { fmt } from "@/lib/format"; import { fmt } from "@/lib/format";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { RemitDetail } from "@/hooks/useRemitDetail"; import type { RemitDetail } from "@/hooks/useRemitDetail";
import type {
RemittanceClaimLevelAdjustment,
ServiceLinePayment,
} from "@/types";
/**
* One row of CAS adjustments attached to a single SLP. The parent
* builds these by looking up CAS rows in the line-reconciliation
* endpoint's per-line `adjustments` arrays. We model the row as a
* flat tuple of `(slp, adjustments)` so the panel can sort by
* line number without a join.
*/
export type PerLineCasGroup = {
slp: ServiceLinePayment;
adjustments: Array<{
groupCode: string;
reasonCode: string;
amount: string;
}>;
};
type CasAdjustmentsPanelProps = { type CasAdjustmentsPanelProps = {
adjustments: NonNullable<RemitDetail["adjustments"]>; adjustments: NonNullable<RemitDetail["adjustments"]>;
/**
* SP7: optional per-line SVC breakdown. When provided, the panel
* renders a "Per-line" section above the aggregate rollup and a
* "Claim-level" section below it.
*/
perLine?: PerLineCasGroup[];
claimLevelAdjustments?: RemittanceClaimLevelAdjustment[];
}; };
/** /**
@@ -98,70 +125,198 @@ function CasRow({ group }: { group: CasGroup }) {
* list is longer so the drawer doesn't get buried under a wall of * list is longer so the drawer doesn't get buried under a wall of
* CARC rows. * CARC rows.
*/ */
export function CasAdjustmentsPanel({ adjustments }: CasAdjustmentsPanelProps) { export function CasAdjustmentsPanel({
adjustments,
perLine,
claimLevelAdjustments,
}: CasAdjustmentsPanelProps) {
const groups = groupAdjustments(adjustments); const groups = groupAdjustments(adjustments);
const [open, setOpen] = useState(groups.length <= 3); const [open, setOpen] = useState(groups.length <= 3);
if (groups.length === 0) return null; const hasPerLine = (perLine?.length ?? 0) > 0;
const hasClaimLevel = (claimLevelAdjustments?.length ?? 0) > 0;
const hasAny =
groups.length > 0 || hasPerLine || hasClaimLevel;
if (!hasAny) return null;
return ( return (
<section <section
className="flex flex-col gap-3 px-6 py-4" className="flex flex-col gap-3 px-6 py-4"
data-testid="cas-adjustments-panel" data-testid="cas-adjustments-panel"
> >
<button {hasPerLine ? (
type="button" <PerLineSection perLine={perLine ?? []} />
onClick={() => setOpen((v) => !v)} ) : null}
aria-expanded={open}
aria-controls="cas-adjustments-list"
className="flex items-center gap-2 text-left"
data-testid="cas-toggle"
>
{open ? (
<ChevronDown className="h-3.5 w-3.5 text-[color:var(--m-ink-tertiary)]" aria-hidden />
) : (
<ChevronRight className="h-3.5 w-3.5 text-[color:var(--m-ink-tertiary)]" aria-hidden />
)}
<h3
data-testid="section-label"
className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-[color:var(--m-ink-tertiary)]"
>
CAS Adjustments ({groups.length})
</h3>
</button>
{open ? ( {groups.length > 0 ? (
<div <>
id="cas-adjustments-list" <button
data-testid="cas-adjustments-list" type="button"
className={cn( onClick={() => setOpen((v) => !v)}
"flex flex-col rounded-lg border border-[color:var(--m-border-heavy)]/15", aria-expanded={open}
"bg-[color:var(--m-surface)]/40 overflow-hidden" aria-controls="cas-adjustments-list"
)} className="flex items-center gap-2 text-left"
> data-testid="cas-toggle"
{/* Header row — labels for the 4-column grid */} >
<div className="grid grid-cols-[6rem_1fr_5rem_6rem] gap-3 px-4 py-2 border-b border-[color:var(--m-border-heavy)]/15 bg-[color:var(--m-surface)]/60"> {open ? (
<span className="text-[10px] font-semibold uppercase tracking-[0.14em] text-[color:var(--m-ink-tertiary)]"> <ChevronDown className="h-3.5 w-3.5 text-[color:var(--m-ink-tertiary)]" aria-hidden />
CARC ) : (
</span> <ChevronRight className="h-3.5 w-3.5 text-[color:var(--m-ink-tertiary)]" aria-hidden />
<span className="text-[10px] font-semibold uppercase tracking-[0.14em] text-[color:var(--m-ink-tertiary)]"> )}
Reason <h3
</span> data-testid="section-label"
<span className="text-right text-[10px] font-semibold uppercase tracking-[0.14em] text-[color:var(--m-ink-tertiary)]"> className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-[color:var(--m-ink-tertiary)]"
Hits >
</span> CAS Adjustments ({groups.length})
<span className="text-right text-[10px] font-semibold uppercase tracking-[0.14em] text-[color:var(--m-ink-tertiary)]"> </h3>
Amount </button>
</span>
</div> {open ? (
{groups.map((g) => ( <div
<CasRow id="cas-adjustments-list"
key={`${g.group}-${g.reason}`} data-testid="cas-adjustments-list"
group={g} className={cn(
/> "flex flex-col rounded-lg border border-[color:var(--m-border-heavy)]/15",
))} "bg-[color:var(--m-surface)]/40 overflow-hidden"
</div> )}
>
{/* Header row — labels for the 4-column grid */}
<div className="grid grid-cols-[6rem_1fr_5rem_6rem] gap-3 px-4 py-2 border-b border-[color:var(--m-border-heavy)]/15 bg-[color:var(--m-surface)]/60">
<span className="text-[10px] font-semibold uppercase tracking-[0.14em] text-[color:var(--m-ink-tertiary)]">
CARC
</span>
<span className="text-[10px] font-semibold uppercase tracking-[0.14em] text-[color:var(--m-ink-tertiary)]">
Reason
</span>
<span className="text-right text-[10px] font-semibold uppercase tracking-[0.14em] text-[color:var(--m-ink-tertiary)]">
Hits
</span>
<span className="text-right text-[10px] font-semibold uppercase tracking-[0.14em] text-[color:var(--m-ink-tertiary)]">
Amount
</span>
</div>
{groups.map((g) => (
<CasRow
key={`${g.group}-${g.reason}`}
group={g}
/>
))}
</div>
) : null}
</>
) : null}
{hasClaimLevel ? (
<ClaimLevelSection adjustments={claimLevelAdjustments ?? []} />
) : null} ) : null}
</section> </section>
); );
} }
/**
* SP7: per-line section. One card per SVC composite, showing the
* procedure code + line number + its CAS rows.
*/
function PerLineSection({ perLine }: { perLine: PerLineCasGroup[] }) {
return (
<div
data-testid="per-line-cas-section"
className="flex flex-col gap-2"
>
<h3
data-testid="section-label"
className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-[color:var(--m-ink-tertiary)]"
>
Per-line Adjustments ({perLine.length})
</h3>
{perLine.map((g) => (
<PerLineRow key={g.slp.id} group={g} />
))}
</div>
);
}
function PerLineRow({ group }: { group: PerLineCasGroup }) {
const total = group.adjustments.reduce(
(acc, a) => acc + Number(a.amount || 0),
0,
);
return (
<div
data-testid="per-line-cas-row"
data-line-number={group.slp.lineNumber}
className="flex flex-col gap-1 px-3 py-2 rounded-md border border-[color:var(--m-border-heavy)]/10 bg-[color:var(--m-surface)]/40"
>
<div className="flex items-baseline justify-between font-mono text-sm">
<span className="text-[color:var(--m-ink-primary)]">
<span className="font-semibold">{group.slp.procedureCode}</span>
<span className="text-[color:var(--m-ink-tertiary)] ml-2">
#{group.slp.lineNumber}
</span>
</span>
<span
className="tabular-nums text-[color:var(--m-ink-secondary)]"
data-testid="per-line-cas-total"
>
{fmt.usdPrecise(total)}
</span>
</div>
{group.adjustments.length > 0 ? (
<ul className="text-xs font-mono text-[color:var(--m-ink-tertiary)] space-y-0.5">
{group.adjustments.map((a, i) => (
<li
key={i}
data-testid="per-line-cas-entry"
data-carc={`${a.groupCode}-${a.reasonCode}`}
>
{a.groupCode}-{a.reasonCode} · {fmt.usdPrecise(Number(a.amount))}
</li>
))}
</ul>
) : null}
</div>
);
}
/**
* SP7: claim-level (CLP-level) CAS bucket. Rendered as a flat list
* under a "Claim-level adjustments" sub-header, below the per-line
* section and the aggregate rollup.
*/
function ClaimLevelSection({
adjustments,
}: {
adjustments: RemittanceClaimLevelAdjustment[];
}) {
return (
<div
data-testid="claim-level-cas-section"
className="flex flex-col gap-2"
>
<h3
data-testid="section-label"
className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-[color:var(--m-ink-tertiary)]"
>
Claim-level adjustments ({adjustments.length})
</h3>
<ul className="text-xs font-mono space-y-0.5">
{adjustments.map((a) => (
<li
key={a.id}
data-testid="claim-level-cas-entry"
data-carc={`${a.groupCode}-${a.reasonCode}`}
className="flex justify-between px-3 py-1 rounded-md bg-[color:var(--m-surface)]/40"
>
<span className="text-[color:var(--m-ink-primary)]">
{a.groupCode}-{a.reasonCode}
</span>
<span className="tabular-nums text-[color:var(--m-ink-secondary)]">
{fmt.usdPrecise(Number(a.amount))}
</span>
</li>
))}
</ul>
</div>
);
}