diff --git a/src/components/ClaimDrawer/ServiceLinesTable.test.tsx b/src/components/ClaimDrawer/ServiceLinesTable.test.tsx new file mode 100644 index 0000000..e51a001 --- /dev/null +++ b/src/components/ClaimDrawer/ServiceLinesTable.test.tsx @@ -0,0 +1,265 @@ +// @vitest-environment happy-dom +// Pure prop-driven component; match the act() convention used by the +// other ClaimDrawer test files (ClaimDrawerHeader / ClaimDrawerSkeleton / +// ClaimDrawerError / ValidationPanel). +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { describe, expect, it } from "vitest"; +import { ServiceLinesTable } from "./ServiceLinesTable"; +import type { ClaimDetailServiceLine } from "@/types"; + +function renderIntoContainer(element: React.ReactElement): { + container: HTMLDivElement; + unmount: () => void; +} { + const container = document.createElement("div"); + document.body.appendChild(container); + const root: Root = createRoot(container); + act(() => { + root.render(element); + }); + return { + container, + unmount: () => { + act(() => root.unmount()); + container.remove(); + }, + }; +} + +function makeLine( + overrides: Partial = {} +): ClaimDetailServiceLine { + return { + lineNumber: 1, + procedureQualifier: "HC", + procedureCode: "99213", + modifiers: [], + charge: 100.0, + units: 1, + unitType: "UN", + serviceDate: "2026-01-10", + ...overrides, + }; +} + +describe("ServiceLinesTable", () => { + it("test_section_label_includes_count", () => { + const lines = [ + makeLine({ lineNumber: 1 }), + makeLine({ lineNumber: 2, procedureCode: "99214" }), + makeLine({ lineNumber: 3, procedureCode: "90834" }), + ]; + + const { container, unmount } = renderIntoContainer( + + ); + + const label = container.querySelector('[data-testid="section-label"]'); + expect(label).not.toBeNull(); + + // `uppercase` is applied via Tailwind CSS, so textContent gives the + // source-case string "Service Lines"; assert case-insensitively and + // confirm the visual transform via the className sniff below. + const text = (label?.textContent ?? "").toLowerCase(); + expect(text).toContain("service lines"); + expect(text).toContain("(3)"); + + // Eyebrow style (uppercase + tracking) — sniff the className. + const cls = label?.className ?? ""; + expect(cls).toContain("uppercase"); + expect(cls).toContain("tracking-[0.18em]"); + + unmount(); + }); + + it("test_empty_state_renders_when_no_lines", () => { + const { container, unmount } = renderIntoContainer( + + ); + + const empty = container.querySelector('[data-testid="service-lines-empty"]'); + expect(empty).not.toBeNull(); + expect(empty?.textContent ?? "").toContain("No service lines"); + + // No table rendered in the empty state. + expect(container.querySelector('[data-testid="service-lines-table"]')).toBeNull(); + expect(container.querySelectorAll('[data-testid="service-line-row"]').length).toBe(0); + + // Section label still present, but with count "(0)". + const label = container.querySelector('[data-testid="section-label"]'); + expect(label?.textContent ?? "").toContain("(0)"); + + unmount(); + }); + + it("test_single_line_renders_one_row", () => { + const line = makeLine({ + lineNumber: 1, + procedureQualifier: "HC", + procedureCode: "99213", + modifiers: [], + charge: 150.0, + units: 1, + unitType: "UN", + serviceDate: "2026-01-10", + }); + + const { container, unmount } = renderIntoContainer( + + ); + + const rows = container.querySelectorAll('[data-testid="service-line-row"]'); + expect(rows.length).toBe(1); + + // Row carries the line number as a data attribute for test stability. + expect(rows[0].getAttribute("data-line-number")).toBe("1"); + + // Cells: line #, procedure, charge, units, date. + const cells = rows[0].querySelectorAll("td"); + expect(cells.length).toBe(5); + + const cellText = Array.from(cells).map((c) => c.textContent ?? ""); + expect(cellText[0]).toContain("1"); // line # + expect(cellText[1]).toContain("HC 99213"); // procedure + expect(cellText[2]).toContain("$150.00"); // charge + expect(cellText[3]).toContain("1"); // units count + expect(cellText[3]).toContain("UN"); // units type + expect(cellText[4]).toContain("2026"); // date (year at minimum) + + unmount(); + }); + + it("test_multiple_lines_render_in_order", () => { + const lines = [ + makeLine({ lineNumber: 1, procedureCode: "99213" }), + makeLine({ lineNumber: 2, procedureCode: "99214" }), + makeLine({ lineNumber: 3, procedureCode: "90834" }), + ]; + + const { container, unmount } = renderIntoContainer( + + ); + + const rows = Array.from( + container.querySelectorAll('[data-testid="service-line-row"]') + ); + expect(rows.length).toBe(3); + + const lineNumbers = rows.map((r) => r.getAttribute("data-line-number")); + expect(lineNumbers).toEqual(["1", "2", "3"]); + + // The procedure cell preserves insertion order across rows. + const procedureCells = rows.map((r) => + r.querySelectorAll("td")[1]?.textContent ?? "" + ); + expect(procedureCells[0]).toContain("99213"); + expect(procedureCells[1]).toContain("99214"); + expect(procedureCells[2]).toContain("90834"); + + unmount(); + }); + + it("test_procedure_column_shows_qualifier_code_and_modifiers", () => { + const line = makeLine({ + lineNumber: 1, + procedureQualifier: "HC", + procedureCode: "99213", + modifiers: ["25", "59"], + }); + + const { container, unmount } = renderIntoContainer( + + ); + + const procedureCell = container.querySelector( + '[data-testid="service-line-row"] td:nth-child(2)' + ); + expect(procedureCell).not.toBeNull(); + + const text = procedureCell?.textContent ?? ""; + // Base: qualifier + code. + expect(text).toContain("HC 99213"); + // Modifiers present (separated by middle dots per plan). + expect(text).toContain("25"); + expect(text).toContain("59"); + // The two modifiers should both appear after the procedure code; the + // separator used in the impl is the middle dot "·". + expect(text).toContain("· 25"); + expect(text).toContain("· 59"); + + unmount(); + }); + + it("test_charge_column_is_formatted_and_mono", () => { + const line = makeLine({ lineNumber: 1, charge: 123.45 }); + + const { container, unmount } = renderIntoContainer( + + ); + + const chargeCell = container.querySelector( + '[data-testid="service-line-row"] td:nth-child(3)' + ); + expect(chargeCell).not.toBeNull(); + + // Currency formatting: fmt.usdPrecise → "$123.45". + expect(chargeCell?.textContent ?? "").toContain("$123.45"); + + // Mono styling — either via Tailwind class or inline style. + const cls = chargeCell?.className ?? ""; + const style = chargeCell?.getAttribute("style") ?? ""; + const isMono = + cls.includes("font-mono") || + style.includes("--m-font-mono") || + style.includes("font-mono"); + expect(isMono).toBe(true); + + // Right-aligned (charge is the headline number, anchored to the right). + expect(cls).toContain("text-right"); + + // Tabular nums so charges align across rows. + expect(cls).toContain("tabular-nums"); + + unmount(); + }); + + it("test_units_and_date_columns", () => { + const line = makeLine({ + lineNumber: 1, + units: 2, + unitType: "UN", + serviceDate: "2026-01-10", + }); + + const { container, unmount } = renderIntoContainer( + + ); + + const cells = container.querySelectorAll( + '[data-testid="service-line-row"] td' + ); + const unitsCell = cells[3]; + const dateCell = cells[4]; + + // Units column: " ". + const unitsText = unitsCell?.textContent ?? ""; + expect(unitsText).toContain("2"); + expect(unitsText).toContain("UN"); + + // Date column: fmt.date formats "2026-01-10" as a localized + // "MMM D, YYYY" string. `new Date("YYYY-MM-DD")` is parsed as UTC + // midnight, so the day can roll over by ±1 in non-UTC test + // environments — assert only the year and month to stay + // timezone-robust without forking fmt.date. + const dateText = dateCell?.textContent ?? ""; + expect(dateText).toContain("2026"); + expect(dateText).toContain("Jan"); + // The date cell should NOT be empty (i.e. fmt.date was actually invoked). + expect(dateText.trim().length).toBeGreaterThan(0); + + unmount(); + }); +}); diff --git a/src/components/ClaimDrawer/ServiceLinesTable.tsx b/src/components/ClaimDrawer/ServiceLinesTable.tsx new file mode 100644 index 0000000..fb630c9 --- /dev/null +++ b/src/components/ClaimDrawer/ServiceLinesTable.tsx @@ -0,0 +1,86 @@ +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { fmt } from "@/lib/format"; +import type { ClaimDetail } from "@/types"; + +type ServiceLinesTableProps = { + serviceLines: ClaimDetail["serviceLines"]; +}; + +function formatProcedure(line: ClaimDetail["serviceLines"][number]): string { + const base = `${line.procedureQualifier} ${line.procedureCode}`; + if (line.modifiers.length === 0) return base; + return `${base} · ${line.modifiers.join(" · ")}`; +} + +function formatUnits(line: ClaimDetail["serviceLines"][number]): string { + return `${line.units ?? 0} ${line.unitType ?? ""}`.trimEnd(); +} + +/** + * Service-lines table for the claim detail drawer (SP4). + * + * Five columns: line #, procedure (qualifier + code + modifiers), + * charge (mono, prominent), units, date. Charge is the headline — + * big mono digits with tabular-nums so columns align across rows. + */ +export function ServiceLinesTable({ serviceLines }: ServiceLinesTableProps) { + return ( +
+

+ Service Lines ({serviceLines.length}) +

+ + {serviceLines.length === 0 ? ( +

+ No service lines +

+ ) : ( + + + + # + Procedure + Charge + Units + Date + + + + {serviceLines.map((line) => ( + + + {line.lineNumber} + + + {formatProcedure(line)} + + + {fmt.usdPrecise(line.charge)} + + + {formatUnits(line)} + + + {line.serviceDate ? fmt.date(line.serviceDate) : ""} + + + ))} + +
+ )} +
+ ); +}