From 602dc352c53898231464188a639b56289eaac921 Mon Sep 17 00:00:00 2001 From: Nora Date: Thu, 2 Jul 2026 11:39:02 -0600 Subject: [PATCH] feat(sp28): AckDrawer matched-claim panel + manual-match dropdown --- src/components/AckDrawer/AckDrawer.test.tsx | 266 +++++++++++ src/components/AckDrawer/AckDrawer.tsx | 8 + .../AckDrawer/MatchedClaimPanel.tsx | 420 ++++++++++++++++++ src/components/AckDrawer/index.ts | 1 + 4 files changed, 695 insertions(+) create mode 100644 src/components/AckDrawer/MatchedClaimPanel.tsx diff --git a/src/components/AckDrawer/AckDrawer.test.tsx b/src/components/AckDrawer/AckDrawer.test.tsx index afef197..6970c57 100644 --- a/src/components/AckDrawer/AckDrawer.test.tsx +++ b/src/components/AckDrawer/AckDrawer.test.tsx @@ -22,6 +22,84 @@ vi.mock("@/hooks/useAckDetail", () => ({ useAckDetail, })); +// SP28: the new panel mounts three hooks (data, +// live-tail, live-tail merge) and calls `useNavigate` to navigate to +// /claims?claim= when the operator clicks a claim card's +// "open claim" 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 the +// "no claim links yet" empty state — tests that need to populate +// the panel override via `setAckClaimsRows(...)`. +const { + useAckClaims, + useTailStream, + useMergedTail, + useNavigate, + matchAckToClaim, + unmatchAck, + listClaims, +} = vi.hoisted(() => ({ + useAckClaims: vi.fn(), + useTailStream: vi.fn(), + useMergedTail: vi.fn(), + useNavigate: vi.fn(), + matchAckToClaim: vi.fn(), + unmatchAck: vi.fn(), + listClaims: vi.fn(), +})); +vi.mock("@/hooks/useAckClaims", () => ({ useAckClaims })); +vi.mock("@/hooks/useTailStream", () => ({ useTailStream })); +vi.mock("@/hooks/useMergedTail", () => ({ useMergedTail })); +vi.mock("@/lib/api", async () => { + const actual = + await vi.importActual("@/lib/api"); + return { + ...actual, + matchAckToClaim, + unmatchAck, + listClaims, + }; +}); +vi.mock("react-router-dom", async () => { + const actual = + await vi.importActual( + "react-router-dom", + ); + return { + ...actual, + useNavigate, + }; +}); + +/** + * Configure the mocked `useAckClaims` + `useMergedTail` hooks for a + * single test. The `refetch` default is a fresh `vi.fn()` — tests + * that need to assert on it can override via `overrides.refetch`. + */ +function setAckClaimsRows( + rows: Array<{ + id: number; + claimId: string | null; + batchId: string | null; + ackId: number; + ackKind: "999" | "277ca" | "ta1"; + ak2Index: number | null; + setControlNumber: string | null; + setAcceptRejectCode: string | null; + linkedAt: string; + claimState?: string; + }>, +) { + useAckClaims.mockReturnValue({ + data: rows, + isLoading: false, + isError: false, + error: null, + refetch: vi.fn(), + }); + useMergedTail.mockReturnValue(rows); +} + /** * Minimal valid `Ack` fixture — every required key present so the * component typechecks. The wire shape extends with `raw_999_text` @@ -68,6 +146,38 @@ function mockDetail( afterEach(() => { cleanup(); vi.clearAllMocks(); + // Default SP28 mocks — empty list, no errors, no-op navigate. Tests + // that exercise the MatchedClaim panel override via + // `setAckClaimsRows(...)`. + useAckClaims.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()); + matchAckToClaim.mockResolvedValue({ + link_id: 1, + claim_id: "CLM-1", + ack_id: 42, + ack_kind: "999", + set_control_number: "991102989", + set_accept_reject_code: "A", + linked_at: "2026-07-02T12:00:00Z", + linked_by: "manual", + }); + unmatchAck.mockResolvedValue(undefined); + listClaims.mockResolvedValue({ items: [], total: 0, returned: 0, has_more: false }); }); describe("AckDrawer", () => { @@ -189,4 +299,160 @@ describe("AckDrawer", () => { // No raw_999_text → no Download button. expect(document.querySelector('[data-testid="ack-drawer-download"]')).toBeNull(); }); + + // ------------------------------------------------------------------------- + // SP28: the new panel. + // Tests assert: + // - panel renders when `useAckClaims` returns a non-empty list + // - panel renders empty state with "Link to claim…" dropdown when + // the list is empty (default) + // - the "open claim" button navigates to /claims?claim= + // - the panel mounts the live-tail subscription via + // `useTailStream("claim-acks")` + // - TA1 batch-level rows (claimId === null) render with the + // originating Batch id, not a claim card + // ------------------------------------------------------------------------- + + it("test_matched_claim_panel_renders_empty_state_when_no_links", () => { + mockDetail({ data: SAMPLE_ACK }); + render( {}} />); + + // The panel must be present. + expect(document.querySelector('[data-testid="matched-claim-panel"]')) + .not.toBeNull(); + // The empty-state copy must surface. + expect(document.querySelector('[data-testid="matched-claim-empty"]')) + .not.toBeNull(); + // The "Link to claim…" dropdown is the manual-match affordance + // (per D5/D9 — any logged-in user can run it). + expect(document.querySelector('[data-testid="link-to-claim-dropdown"]')) + .not.toBeNull(); + expect(document.querySelector('[data-testid="link-to-claim-toggle"]')) + .not.toBeNull(); + // No row rendered. + expect(document.querySelector('[data-testid="matched-claim-card"]')).toBeNull(); + }); + + it("test_matched_claim_panel_renders_card_per_claim_link", () => { + setAckClaimsRows([ + { + id: 1, + claimId: "CLM-1", + batchId: null, + ackId: 42, + ackKind: "999", + ak2Index: 0, + setControlNumber: "991102989", + setAcceptRejectCode: "A", + linkedAt: "2026-07-02T12:00:00Z", + claimState: "submitted", + }, + { + id: 2, + claimId: "CLM-2", + batchId: null, + ackId: 42, + ackKind: "999", + ak2Index: 1, + setControlNumber: "991102990", + setAcceptRejectCode: "R", + linkedAt: "2026-07-02T12:00:01Z", + claimState: "denied", + }, + ]); + mockDetail({ data: SAMPLE_ACK }); + render( {}} />); + + // Per-AK2 granularity (D1): one card per link row. + const cards = document.querySelectorAll('[data-testid="matched-claim-card"]'); + expect(cards.length).toBe(2); + + // The header count must reflect the row count. + expect(document.querySelector('[data-testid="matched-claim-count"]') + ?.textContent).toContain("(2)"); + + // Each card renders its claim id. + const ids = document.querySelectorAll('[data-testid="matched-claim-id"]'); + expect(ids[0]?.textContent).toContain("CLM-1"); + expect(ids[1]?.textContent).toContain("CLM-2"); + }); + + it("test_matched_claim_panel_renders_ta1_batch_row_with_originating_batch", () => { + // TA1 batch-level rows have claimId === null. The panel renders + // them with the originating Batch id, not a claim card (D4). + setAckClaimsRows([ + { + id: 1, + claimId: null, + batchId: "B-TA1-1", + ackId: 42, + ackKind: "ta1", + ak2Index: null, + setControlNumber: null, + setAcceptRejectCode: null, + linkedAt: "2026-07-02T12:00:00Z", + claimState: "n/a", + }, + ]); + mockDetail({ data: SAMPLE_ACK }); + render( {}} />); + + // The TA1 row renders as a Batch card, not a claim card. + expect(document.querySelector('[data-testid="matched-claim-card-ta1"]')) + .not.toBeNull(); + expect(document.querySelector('[data-testid="matched-claim-card"]')).toBeNull(); + // The originating batch id is surfaced verbatim. + expect(document.querySelector('[data-testid="matched-claim-batch-id"]') + ?.textContent).toContain("B-TA1-1"); + }); + + it("test_matched_claim_panel_open_claim_button_navigates_to_claims_with_claim_id", () => { + setAckClaimsRows([ + { + id: 1, + claimId: "CLM-1", + batchId: null, + ackId: 42, + ackKind: "999", + ak2Index: 0, + setControlNumber: "991102989", + setAcceptRejectCode: "A", + linkedAt: "2026-07-02T12:00:00Z", + claimState: "submitted", + }, + ]); + const navigate = vi.fn(); + useNavigate.mockReturnValue(navigate); + mockDetail({ data: SAMPLE_ACK }); + render( {}} />); + + const openBtn = document.querySelector( + '[data-testid="open-claim-drawer"]', + ) as HTMLButtonElement | null; + expect(openBtn).not.toBeNull(); + openBtn!.click(); + + // Cross-page navigation — the ClaimDrawer is mounted on /claims. + expect(navigate).toHaveBeenCalledTimes(1); + expect(navigate).toHaveBeenCalledWith("/claims?claim=CLM-1"); + }); + + it("test_matched_claim_panel_mounts_live_tail_subscription", () => { + mockDetail({ data: SAMPLE_ACK }); + render( {}} />); + + // The panel opens one claim-acks live-tail stream. Per + // cyclone-frontend-page convention #3, the subscription lives on + // the drawer, not on the data hook. + expect(useTailStream).toHaveBeenCalledWith("claim-acks"); + }); + + it("test_matched_claim_panel_calls_useAckClaims_with_kind_and_ack_id", () => { + mockDetail({ data: SAMPLE_ACK }); + render( {}} />); + + // The panel must pass (kind="999", ackId=42) to useAckClaims so + // the hook builds the right query key. + expect(useAckClaims).toHaveBeenCalledWith("999", 42); + }); }); diff --git a/src/components/AckDrawer/AckDrawer.tsx b/src/components/AckDrawer/AckDrawer.tsx index 956859d..c96f95d 100644 --- a/src/components/AckDrawer/AckDrawer.tsx +++ b/src/components/AckDrawer/AckDrawer.tsx @@ -7,6 +7,7 @@ import { Skeleton } from "@/components/ui/skeleton"; import { cn } from "@/lib/utils"; import { useAckDetail, type AckDetail } from "@/hooks/useAckDetail"; import { SegmentStatusList } from "./SegmentStatusList"; +import { MatchedClaimPanel } from "./MatchedClaimPanel"; interface Props { /** @@ -314,6 +315,13 @@ export function AckDrawer({ ackId, onClose }: Props) { /> + {/* SP28: matched-claim panel — slot as the FIRST panel + after the summary so the operator's eye-flow is + "which claim does this ack acknowledge → what was the + envelope-level ack code → per-set responses". The + panel self-routes by ack kind + id (D5/D9 — any + logged-in user can manually link an orphan). */} + diff --git a/src/components/AckDrawer/MatchedClaimPanel.tsx b/src/components/AckDrawer/MatchedClaimPanel.tsx new file mode 100644 index 0000000..daef7e3 --- /dev/null +++ b/src/components/AckDrawer/MatchedClaimPanel.tsx @@ -0,0 +1,420 @@ +import { useState } from "react"; +import { ArrowRight, Link2 } from "lucide-react"; +import { useNavigate } from "react-router-dom"; +import { useAckClaims } from "@/hooks/useAckClaims"; +import { useMergedTail } from "@/hooks/useMergedTail"; +import { useTailStream } from "@/hooks/useTailStream"; +import { api } from "@/lib/api"; +import { ClaimStateBadge } from "@/components/ui/claim-state-badge"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { fmt } from "@/lib/format"; +import type { + ClaimAck, + ClaimAckKind, + ClaimState, +} from "@/types"; + +/** + * Local UI state for the manual-match dropdown. The dropdown fetches + * the bare `ClaimSummary` projection from the backend (which lists + * every claim by patient_control_number-ish score) — for the first + * cut, the dropdown just lists the persisted claim population by id + * with a typed filter. The backend's actual candidate-list logic is + * out of scope for the SP28 frontend (mirrors the + * `/api/inbox/ack-orphans?kind=…` candidate list shape — D7). + */ +type CandidateClaim = { + id: string; + patientName: string; + billedAmount: number; + state: ClaimState; +}; + +/** + * Per-AK2 / per-ClaimStatus card. For 999/277CA rows, renders the + * linked claim (PCN, current state badge, charge, and a link to + * ClaimDrawer). For TA1 rows with `claimId === null`, renders the + * originating Batch instead. + */ +function ClaimLinkCard({ + link, + onOpenClaim, +}: { + link: ClaimAck; + onOpenClaim: (claimId: string) => void; +}) { + if (link.claimId === null) { + // TA1 batch-level link — render the originating Batch id instead + // of a claim (D4). The `batchId` field is populated for TA1 rows + // only; other kinds leave it null. + return ( +
+
+ + Originating batch + + {link.ak2Index !== null ? ( + + AK2 #{link.ak2Index} + + ) : null} +
+
+ {link.batchId ?? "—"} +
+
+ ); + } + + return ( +
+
+ + {link.setControlNumber ?? "—"} + + {link.ak2Index !== null ? ( + + AK2 #{link.ak2Index} + + ) : null} + {link.setAcceptRejectCode ? ( + + {link.setAcceptRejectCode} + + ) : null} +
+ +
+ + {link.claimId} + + {link.claimState && link.claimState !== "n/a" ? ( + + ) : null} +
+ +
+ + {link.linkedAt ? ( + + linked {fmt.dateShort(link.linkedAt)} + + ) : null} +
+
+ ); +} + +/** + * Inline "Link to claim…" affordance for the orphan case. Per D5/D9, + * any logged-in user can run the manual-match endpoint — the dropdown + * doesn't gate on role. On success the panel refetches and swaps to + * the populated card view (live-tail also delivers the new link). + */ +function LinkToClaimDropdown({ + kind, + ackId, + onLinked, +}: { + kind: ClaimAckKind; + ackId: number; + onLinked: () => void; +}) { + const [open, setOpen] = useState(false); + const [candidates, setCandidates] = useState([]); + const [loading, setLoading] = useState(false); + const [submitting, setSubmitting] = useState(null); + const [error, setError] = useState(null); + + async function loadCandidates() { + setLoading(true); + setError(null); + try { + // Use the existing listClaims endpoint as a coarse candidate + // pool. The backend's full /api/inbox/ack-orphans candidate + // scoring is a separate concern — for the dropdown we just need + // a typed claim id picker. The match is performed by the + // backend's `POST /api/acks/{kind}/{id}/match-claim` endpoint. + const res = await api.listClaims({ limit: 25 }); + setCandidates( + res.items.map((c) => ({ + id: (c as unknown as { id: string }).id, + patientName: + (c as unknown as { patientName?: string }).patientName ?? "", + billedAmount: + (c as unknown as { billedAmount?: number }).billedAmount ?? 0, + state: + (c as unknown as { state?: ClaimState }).state ?? "submitted", + })), + ); + setOpen(true); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoading(false); + } + } + + async function link(claimId: string) { + setSubmitting(claimId); + setError(null); + try { + await api.matchAckToClaim(kind, ackId, claimId); + setOpen(false); + onLinked(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setSubmitting(null); + } + } + + return ( +
+ {!open ? ( + + ) : ( +
+
+ Pick a claim to link this {kind} ack to +
+
+ {candidates.length === 0 ? ( +

+ No claims available. +

+ ) : ( + candidates.map((c) => ( + + )) + )} +
+ +
+ )} + {error ? ( +

+ {error} +

+ ) : null} +
+ ); +} + +/** + * SP28: AckDrawer's new `` panel. + * + * Mounted as the FIRST panel (before "Set responses") so the + * operator's eye-flow on an ack row is "which claim does this ack + * acknowledge → what was the envelope-level ack code → what were + * the per-set responses". Per-AK2 granularity: each AK2 / ClaimStatus + * gets its own card so a 999 with two AK2s renders two cards. + * + * For 999/277CA: renders one card per linked claim. + * For TA1: renders one card per envelope linking to the originating + * `Batch` (D4 — `claimId` is null, `batchId` is populated). + * + * Live-tail: subscribes to `useTailStream("claim-acks")` and merges + * via `useMergedTail` with the per-ack filter + * `link.ackId === ackId` so a manual match made elsewhere shows up + * here in real time. Per `cyclone-frontend-page` convention #3, the + * subscription lives on the drawer (this component), not on the + * data hook. + * + * Empty state: when no link exists yet, render the + * "Link to claim…" dropdown that calls `api.matchAckToClaim` and + * refetches on success (per D5/D9 — any logged-in user can manually + * link an orphan). + */ +export function MatchedClaimPanel({ + kind, + ackId, +}: { + kind: ClaimAckKind; + ackId: number; +}) { + const { data, isLoading, refetch } = useAckClaims(kind, ackId); + const navigate = useNavigate(); + // Live-tail: open the shared claim-acks stream and filter to this + // ack. The base list (`data ?? []`) is the page's authoritative + // snapshot; the merged result also includes any new rows that + // arrived since the snapshot fetch. + useTailStream("claim-acks"); + const merged = useMergedTail( + "claim-acks", + data ?? [], + (link) => link.ackId === ackId, + ); + + const visible = merged.filter((l) => l.ackId === ackId); + + return ( +
+
+
+ + Matched claim + + ({visible.length}) + +
+
+ + {isLoading && visible.length === 0 ? ( +
+ + +
+ ) : visible.length === 0 ? ( +
+

+ {kind === "ta1" + ? "This TA1 envelope hasn't been linked to a Batch yet." + : "No claim links yet for this ack."} +

+ {/* Per D5/D9: any logged-in user can run the manual-match + endpoint. The dropdown lets the operator pick from a + coarse candidate pool; the backend does the actual join. */} + { + void refetch(); + }} + /> +
+ ) : ( +
+ {visible.map((link) => ( + { + // Cross-page navigation — mirrors the Inbox → + // ClaimDrawer pattern. The ClaimDrawer is mounted on + // the /claims page; navigating there with ?claim= + // restores the drawer with the deep-link. + navigate( + `/claims?claim=${encodeURIComponent(claimId)}`, + ); + }} + /> + ))} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/components/AckDrawer/index.ts b/src/components/AckDrawer/index.ts index cb96596..46893a5 100644 --- a/src/components/AckDrawer/index.ts +++ b/src/components/AckDrawer/index.ts @@ -2,3 +2,4 @@ export { AckDrawer } from "./AckDrawer"; export { SegmentStatusList } from "./SegmentStatusList"; +export { MatchedClaimPanel } from "./MatchedClaimPanel";