+ );
+}
+
+/**
+ * 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 ? (