feat(sp7): CasAdjustmentsPanel — per-line + claim-level sections
This commit is contained in:
@@ -71,6 +71,68 @@ describe("CasAdjustmentsPanel", () => {
|
||||
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", () => {
|
||||
// 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
|
||||
|
||||
@@ -3,9 +3,36 @@ import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { fmt } from "@/lib/format";
|
||||
import { cn } from "@/lib/utils";
|
||||
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 = {
|
||||
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
|
||||
* CARC rows.
|
||||
*/
|
||||
export function CasAdjustmentsPanel({ adjustments }: CasAdjustmentsPanelProps) {
|
||||
export function CasAdjustmentsPanel({
|
||||
adjustments,
|
||||
perLine,
|
||||
claimLevelAdjustments,
|
||||
}: CasAdjustmentsPanelProps) {
|
||||
const groups = groupAdjustments(adjustments);
|
||||
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 (
|
||||
<section
|
||||
className="flex flex-col gap-3 px-6 py-4"
|
||||
data-testid="cas-adjustments-panel"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
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>
|
||||
{hasPerLine ? (
|
||||
<PerLineSection perLine={perLine ?? []} />
|
||||
) : null}
|
||||
|
||||
{open ? (
|
||||
<div
|
||||
id="cas-adjustments-list"
|
||||
data-testid="cas-adjustments-list"
|
||||
className={cn(
|
||||
"flex flex-col rounded-lg border border-[color:var(--m-border-heavy)]/15",
|
||||
"bg-[color:var(--m-surface)]/40 overflow-hidden"
|
||||
)}
|
||||
>
|
||||
{/* 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>
|
||||
{groups.length > 0 ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
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 ? (
|
||||
<div
|
||||
id="cas-adjustments-list"
|
||||
data-testid="cas-adjustments-list"
|
||||
className={cn(
|
||||
"flex flex-col rounded-lg border border-[color:var(--m-border-heavy)]/15",
|
||||
"bg-[color:var(--m-surface)]/40 overflow-hidden"
|
||||
)}
|
||||
>
|
||||
{/* 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}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user