diff --git a/src/components/ClaimDrawer/AcknowledgmentsPanel.tsx b/src/components/ClaimDrawer/AcknowledgmentsPanel.tsx new file mode 100644 index 0000000..6072175 --- /dev/null +++ b/src/components/ClaimDrawer/AcknowledgmentsPanel.tsx @@ -0,0 +1,280 @@ +import { ArrowRight } from "lucide-react"; +import { useNavigate } from "react-router-dom"; +import { useClaimAcks } from "@/hooks/useClaimAcks"; +import { useMergedTail } from "@/hooks/useMergedTail"; +import { useTailStream } from "@/hooks/useTailStream"; +import { EmptyState } from "@/components/ui/empty-state"; +import { cn } from "@/lib/utils"; +import { fmt } from "@/lib/format"; +import type { ClaimAck, ClaimAckKind } from "@/types"; + +/** + * Color palette for the per-AK2 / per-ClaimStatus accept-reject code + * pill. Mirrors the `Acks.tsx` `AckCodeBadge` palette so the + * operator's eye-flow is identical between the Acks page and this + * panel. + * + * - "A" (accepted) → success green + * - "R" (rejected) → destructive red + * - "E" (errors) → warning amber + * - "X" (rejected w/ 999 AK5) → destructive red + * - anything else → muted (treats STC category codes like A1/A2/A6 as + * operator-readable rather than literal A/E letters) + */ +function codeTone(code: string | null | undefined): { + fg: string; + bg: string; + border: string; +} { + if (!code) { + return { + fg: "hsl(var(--muted-foreground))", + bg: "hsl(var(--muted) / 0.30)", + border: "hsl(var(--border) / 0.5)", + }; + } + const upper = code.toUpperCase(); + if (upper === "A") { + return { + fg: "hsl(var(--success))", + bg: "hsl(var(--success) / 0.10)", + border: "hsl(var(--success) / 0.30)", + }; + } + if (upper === "R" || upper === "X") { + return { + fg: "hsl(var(--destructive))", + bg: "hsl(var(--destructive) / 0.10)", + border: "hsl(var(--destructive) / 0.30)", + }; + } + if (upper === "E" || upper === "P") { + return { + fg: "hsl(var(--warning))", + bg: "hsl(var(--warning) / 0.10)", + border: "hsl(var(--warning) / 0.30)", + }; + } + // STC category code (A1/A2/A3/A4/A6/A7) — keep the operator-visible + // tone as warning so a 277CA "rejected" stands out, but render the + // literal code rather than the badge letter so the operator can + // tell which STC category triggered the link. + return { + fg: "hsl(var(--warning))", + bg: "hsl(var(--warning) / 0.10)", + border: "hsl(var(--warning) / 0.30)", + }; +} + +function AckKindBadge({ kind }: { kind: ClaimAckKind }) { + // The ack kind itself is one of "999" / "277ca" / "ta1". TA1 rows + // don't appear in this panel (filtered out per D4), but the type + // union still allows it — render it consistently with the Acks page. + const label = + kind === "999" ? "999" : kind === "277ca" ? "277CA" : "TA1"; + return ( + + {label} + + ); +} + +/** + * One compact row in the Acknowledgments panel. Renders the ack + * code (color-coded), the ack kind, the linked set_control_number + * (the operator-readable join key), the parsed_at date, and a `→` + * link to the matching AckDrawer. + */ +function AckLinkRow({ + link, + onOpenAck, +}: { + link: ClaimAck; + onOpenAck: (ackId: number, kind: ClaimAckKind) => void; +}) { + const tone = codeTone(link.setAcceptRejectCode); + return ( +
+ {/* Accept/reject pill */} + + {link.setAcceptRejectCode ?? "—"} + + + {/* AK2 index chip (999 only) — gives the operator a quick + "which set-response" reference for the per-AK2 granularity. */} + {link.ak2Index !== null ? ( + + AK2 #{link.ak2Index} + + ) : null} + + + + {link.setControlNumber ? ( + + {link.setControlNumber} + + ) : null} + + + {link.linkedAt ? fmt.dateShort(link.linkedAt) : "—"} + + + +
+ ); +} + +/** + * SP28: ClaimDrawer's new `` panel. + * + * Mounted between `` and the trailing panels. + * Lists every `claim_acks` row that links to the current claim: + * - One row per AK2 set-response (for 999 acks — per-AK2 granularity) + * - One row per ClaimStatus (for 277CA) + * - TA1 batch-level rows are filtered out (D4 — claim_id IS NULL) + * + * Live-tail: subscribes to `useTailStream("claim-acks")` and merges + * via `useMergedTail` with the per-claim filter + * `link.claimId === claimId` so a manual match on the AckDrawer or a + * new auto-link from a 999 ack shows up on the ClaimDrawer in real + * time. Per `cyclone-frontend-page` convention #3, the subscription + * lives on the drawer (this component), not on `useClaimAcks`. + * + * Empty state: when the claim has no acks yet, render the existing + * `` with copy "No acknowledgments received yet for + * this claim." — so a fresh claim gets a clear non-error empty state + * rather than a quiet section. + */ +export function AcknowledgmentsPanel({ + claimId, +}: { + claimId: string; +}) { + const { data, isLoading } = useClaimAcks(claimId); + const navigate = useNavigate(); + // Live-tail: open the shared claim-acks stream and filter to this + // claim. The base list (`data ?? []`) is the page's authoritative + // snapshot; the merged result also includes any new rows that + // arrived since the snapshot fetch (manual match, new ack ingest). + useTailStream("claim-acks"); + const merged = useMergedTail( + "claim-acks", + data ?? [], + (link) => link.claimId === claimId, + ); + + // TA1 batch-level rows have `claimId === null`. The backend filters + // them out of the per-claim endpoint, but defensive belt-and- + // suspenders: skip any row that doesn't claim this claim id (the + // filterFn already does this for the tail slice; we apply the same + // to baseItems-derived merged items to be safe). + const visible = merged.filter((l) => l.claimId === claimId); + + return ( +
+
+
+ + Acknowledgments + + ({visible.length}) + +
+
+ + {isLoading && visible.length === 0 ? ( +

+ Loading acknowledgments… +

+ ) : visible.length === 0 ? ( +
+ +
+ ) : ( +
+ {visible.map((link) => ( + { + // Cross-page navigation: the AckDrawer is mounted on + // the /acks page, not on /claims. Push the user to + // /acks?ack= so the Acks page mounts the drawer + // with the deep-link restored (per + // `useAckDrawerUrlState`). Mirrors the Inbox → + // RemitDrawer pattern, where the inbox row click + // navigates to /inbox?remit=. + // + // `kind` is kept in the callback signature so a + // future enhancement can route to a TA1-specific + // page (today the AckDrawer page handles only 999 + // acks — TA1 navigation would need a different page + // or a TA1 drawer on the same Acks surface). + navigate(`/acks?ack=${encodeURIComponent(String(ackId))}`); + void kind; + }} + /> + ))} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/components/ClaimDrawer/ClaimDrawer.test.tsx b/src/components/ClaimDrawer/ClaimDrawer.test.tsx index facf7d7..ce3e4b1 100644 --- a/src/components/ClaimDrawer/ClaimDrawer.test.tsx +++ b/src/components/ClaimDrawer/ClaimDrawer.test.tsx @@ -32,6 +32,58 @@ vi.mock("@/lib/api", () => { }; }); +// SP28: the new panel mounts three hooks (data, +// live-tail, live-tail merge) and calls `useNavigate` to navigate to +// /acks?ack= when the operator clicks an ack row's "open" button. +// Mock each one at the module boundary so the test never opens a real +// fetch or a real navigation. The hook mocks return "empty" by default +// so the panel renders — tests that need to populate +// the panel override via `setClaimAcksRows(...)`. +const { useClaimAcks, useTailStream, useMergedTail, useNavigate } = + vi.hoisted(() => ({ + useClaimAcks: vi.fn(), + useTailStream: vi.fn(), + useMergedTail: vi.fn(), + useNavigate: vi.fn(), + })); +vi.mock("@/hooks/useClaimAcks", () => ({ useClaimAcks })); +vi.mock("@/hooks/useTailStream", () => ({ useTailStream })); +vi.mock("@/hooks/useMergedTail", () => ({ useMergedTail })); +vi.mock("react-router-dom", async () => { + const actual = + await vi.importActual( + "react-router-dom", + ); + return { + ...actual, + useNavigate, + }; +}); + +/** Default mock state — empty list, no errors, no navigation. Tests that + * need to populate the panel override via `useClaimAcks.mockReturnValue`. */ +function setClaimAcksRows( + rows: Array<{ + id: number; + claimId: string; + ackId: number; + ackKind: "999" | "277ca" | "ta1"; + ak2Index: number | null; + setControlNumber: string | null; + setAcceptRejectCode: string | null; + linkedAt: string; + }>, +) { + useClaimAcks.mockReturnValue({ + data: rows, + isLoading: false, + isError: false, + error: null, + refetch: vi.fn(), + }); + useMergedTail.mockReturnValue(rows); +} + /** * Minimal valid ClaimDetail fixture — every required key present so the * component typechecks. Mirrors the SAMPLE_DETAIL in @@ -236,6 +288,26 @@ async function settle( describe("ClaimDrawer", () => { beforeEach(() => { vi.clearAllMocks(); + // Default mocks — empty list, no errors, no-op navigate. Tests + // that exercise the SP28 Acknowledgments panel override via + // `setClaimAcksRows(...)`. + useClaimAcks.mockReturnValue({ + data: [], + isLoading: false, + isError: false, + error: null, + refetch: vi.fn(), + }); + useTailStream.mockReturnValue({ + status: "live", + lastEventAt: null, + error: null, + forceReconnect: vi.fn(), + }); + useMergedTail.mockImplementation( + (_resource: unknown, baseItems: unknown) => baseItems, + ); + useNavigate.mockReturnValue(vi.fn()); }); afterEach(() => { @@ -471,4 +543,196 @@ describe("ClaimDrawer", () => { unmount(); }); + + // ------------------------------------------------------------------------- + // SP28: the new panel. + // Tests assert: + // - panel renders when `useClaimAcks` returns a non-empty list + // - panel renders when the list is empty (default) + // - the "open" button navigates to /acks?ack= + // - the panel mounts the live-tail subscription (one useTailStream + // call per drawer open — the subscription lives on the drawer) + // ------------------------------------------------------------------------- + + it("test_acknowledgments_panel_renders_empty_state_when_no_acks", async () => { + const { unmount } = renderDrawer({ claimId: "CLM-1" }); + + const body = await settle((b) => + b.querySelector('[data-testid="acknowledgments-panel"]') !== null + ); + + // The panel must be present. + expect(body.querySelector('[data-testid="acknowledgments-panel"]')) + .not.toBeNull(); + // The empty-state copy must surface (no acks → friendly message). + expect(body.querySelector('[data-testid="acknowledgments-empty"]')) + .not.toBeNull(); + expect(body.textContent).toContain("No acknowledgments received yet"); + // No row rendered. + expect(body.querySelector('[data-testid="acknowledgments-row"]')).toBeNull(); + + unmount(); + }); + + it("test_acknowledgments_panel_renders_row_for_each_ack_link", async () => { + setClaimAcksRows([ + { + id: 1, + claimId: "CLM-1", + ackId: 99, + ackKind: "999", + ak2Index: 0, + setControlNumber: "991102989", + setAcceptRejectCode: "A", + linkedAt: "2026-07-02T12:00:00Z", + }, + { + id: 2, + claimId: "CLM-1", + ackId: 99, + ackKind: "999", + ak2Index: 1, + setControlNumber: "991102990", + setAcceptRejectCode: "R", + linkedAt: "2026-07-02T12:00:01Z", + }, + ]); + + const { unmount } = renderDrawer({ claimId: "CLM-1" }); + + const body = await settle( + (b) => + b.querySelectorAll('[data-testid="acknowledgments-row"]').length === 2, + ); + + // The panel must render one row per AK2 (per-AK2 granularity, D1). + const rows = body.querySelectorAll('[data-testid="acknowledgments-row"]'); + expect(rows.length).toBe(2); + + // Accept code pills render the literal set_accept_reject_code so + // the operator can see "A" (accepted) vs "R" (rejected) inline. + const codes = body.querySelectorAll('[data-testid="ack-code-pill"]'); + expect(codes[0]?.textContent).toContain("A"); + expect(codes[1]?.textContent).toContain("R"); + + // The header count must reflect the row count. + expect(body.querySelector('[data-testid="acknowledgments-count"]') + ?.textContent).toContain("(2)"); + + // The "open" button is wired up on every row. + const opens = body.querySelectorAll('[data-testid="open-ack-drawer"]'); + expect(opens.length).toBe(2); + + unmount(); + }); + + it("test_acknowledgments_panel_filters_out_rows_with_different_claim_id", async () => { + // Base snapshot has one row for CLM-1 and one orphan (claimId + // different from the active claim). The merge hook receives the + // base list and the tail slice, but the panel filter + // (`link.claimId === claimId`) drops the orphan. Test the + // defensive filter inside the panel. + setClaimAcksRows([ + { + id: 1, + claimId: "CLM-1", + ackId: 99, + ackKind: "999", + ak2Index: 0, + setControlNumber: "991102989", + setAcceptRejectCode: "A", + linkedAt: "2026-07-02T12:00:00Z", + }, + ]); + // Override useMergedTail to simulate an orphan tail row sneaking + // in (matches the prod merge contract — useMergedTail returns + // base + tail, and the panel filter strips non-matching rows). + useMergedTail.mockReturnValue([ + { + id: 1, + claimId: "CLM-1", + ackId: 99, + ackKind: "999", + ak2Index: 0, + setControlNumber: "991102989", + setAcceptRejectCode: "A", + linkedAt: "2026-07-02T12:00:00Z", + }, + { + id: 2, + claimId: "CLM-OTHER", + ackId: 100, + ackKind: "999", + ak2Index: 0, + setControlNumber: "991102999", + setAcceptRejectCode: "R", + linkedAt: "2026-07-02T12:00:01Z", + }, + ]); + + const { unmount } = renderDrawer({ claimId: "CLM-1" }); + + const body = await settle( + (b) => + b.querySelectorAll('[data-testid="acknowledgments-row"]').length === 1, + ); + + // Only the matching-claim row renders. + const rows = body.querySelectorAll('[data-testid="acknowledgments-row"]'); + expect(rows.length).toBe(1); + + unmount(); + }); + + it("test_acknowledgments_panel_open_button_navigates_to_acks_with_ack_id", async () => { + setClaimAcksRows([ + { + id: 1, + claimId: "CLM-1", + ackId: 99, + ackKind: "999", + ak2Index: 0, + setControlNumber: "991102989", + setAcceptRejectCode: "A", + linkedAt: "2026-07-02T12:00:00Z", + }, + ]); + + const navigate = vi.fn(); + useNavigate.mockReturnValue(navigate); + + const { unmount } = renderDrawer({ claimId: "CLM-1" }); + + const body = await settle( + (b) => b.querySelector('[data-testid="open-ack-drawer"]') !== null, + ); + + const openBtn = body.querySelector( + '[data-testid="open-ack-drawer"]', + ) as HTMLButtonElement | null; + expect(openBtn).not.toBeNull(); + openBtn!.click(); + + expect(navigate).toHaveBeenCalledTimes(1); + // Navigate to /acks?ack= — the Acks page mounts the + // AckDrawer with the deep-link restored. + expect(navigate).toHaveBeenCalledWith("/acks?ack=99"); + + unmount(); + }); + + it("test_acknowledgments_panel_mounts_live_tail_subscription", async () => { + const { unmount } = renderDrawer({ claimId: "CLM-1" }); + + await settle((b) => + b.querySelector('[data-testid="acknowledgments-panel"]') !== null + ); + + // The drawer mounts one claim-acks live-tail stream. Per + // cyclone-frontend-page convention #3, the subscription lives on + // the page (drawer here), not on the data hook. + expect(useTailStream).toHaveBeenCalledWith("claim-acks"); + + unmount(); + }); }); diff --git a/src/components/ClaimDrawer/ClaimDrawer.tsx b/src/components/ClaimDrawer/ClaimDrawer.tsx index b78a4a3..0204257 100644 --- a/src/components/ClaimDrawer/ClaimDrawer.tsx +++ b/src/components/ClaimDrawer/ClaimDrawer.tsx @@ -12,6 +12,7 @@ import { DiagnosesList } from "./DiagnosesList"; import { PartiesGrid } from "./PartiesGrid"; import { RawSegmentsPanel } from "./RawSegmentsPanel"; import { MatchedRemitCard } from "./MatchedRemitCard"; +import { AcknowledgmentsPanel } from "./AcknowledgmentsPanel"; import { StateHistoryTimeline } from "./StateHistoryTimeline"; import { LineReconciliationTab } from "./LineReconciliationTab"; import { ClaimDrawerSkeleton } from "./ClaimDrawerSkeleton"; @@ -232,6 +233,13 @@ export function ClaimDrawer({ serviceLines={data.serviceLines} lineReconciliation={data.lineReconciliation} /> + {/* SP28: per-AK2 acknowledgments panel. Sits between + ServiceLines and Diagnoses so the operator's + eye-flow is "what was claimed → what came back + from the payer → who/what is on the claim". The + panel itself mounts the live-tail subscription + (per cyclone-frontend-page convention #3). */} + {data.matchedRemittance ? (