feat(sp7): CasAdjustmentsPanel — per-line + claim-level sections
This commit is contained in:
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user