From d8f3fb15f910d860c6b0c835c393eea5a2cc9540 Mon Sep 17 00:00:00 2001 From: Nora Date: Thu, 2 Jul 2026 11:40:50 -0600 Subject: [PATCH] feat(sp28): Acks page Claims badge column with orphan/single/many variants --- src/pages/Acks.test.tsx | 161 +++++++++++++++++++++++++++++++++++++++- src/pages/Acks.tsx | 84 ++++++++++++++++++++- 2 files changed, 243 insertions(+), 2 deletions(-) diff --git a/src/pages/Acks.test.tsx b/src/pages/Acks.test.tsx index 07d31de..8c2b295 100644 --- a/src/pages/Acks.test.tsx +++ b/src/pages/Acks.test.tsx @@ -6,6 +6,7 @@ import React, { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { describe, expect, it, vi, beforeEach } from "vitest"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { MemoryRouter } from "react-router-dom"; import { Acks } from "./Acks"; import { api } from "@/lib/api"; @@ -49,8 +50,20 @@ function renderIntoContainer(element: React.ReactElement): { const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const root: Root = createRoot(container); act(() => { + // SP28: the AckDrawer mounts the new panel + // which calls `useNavigate()` for cross-page navigation to + // /claims?claim=. The hook requires a router context, so + // the test harness wraps the page in . root.render( - React.createElement(QueryClientProvider, { client: qc }, element), + React.createElement( + QueryClientProvider, + { client: qc }, + React.createElement( + MemoryRouter, + null, + element, + ), + ), ); }); return { @@ -685,4 +698,150 @@ describe("Acks", () => { unmount(); }); + + // ------------------------------------------------------------------------- + // SP28: the "Claims" column on the 999 register. The badge carries + // the distinct-claim link count (per-AK2 granularity, D1). Empty + // link count → "Orphan" amber pill. Single link → muted "1 claim" + // text. Multi link → success "{count} claims" badge. + // ------------------------------------------------------------------------- + it("test_claims_badge_shows_orphan_when_no_linked_claims", async () => { + (api.listAcks as unknown as ReturnType).mockResolvedValue({ + items: [ + { + id: 42, + sourceBatchId: "b-orphan", + acceptedCount: 1, + rejectedCount: 0, + receivedCount: 1, + ackCode: "A", + parsedAt: "2026-06-20T12:00:00Z", + linkedClaimIds: [], + }, + ], + total: 1, + returned: 1, + has_more: false, + }); + + const { unmount } = renderIntoContainer(React.createElement(Acks)); + await waitForText("b-orphan"); + + // An orphan ack renders the warning "Orphan" badge — the + // operator's cue that the auto-link didn't resolve and they + // may need to drill in to run a manual match. + const orphanBadge = document.body.querySelector( + '[data-testid="claims-badge-orphan"]', + ); + expect(orphanBadge).not.toBeNull(); + expect(orphanBadge?.textContent).toContain("Orphan"); + + unmount(); + }); + + it("test_claims_badge_shows_single_link_text_for_one_claim", async () => { + (api.listAcks as unknown as ReturnType).mockResolvedValue({ + items: [ + { + id: 43, + sourceBatchId: "b-single", + acceptedCount: 2, + rejectedCount: 0, + receivedCount: 2, + ackCode: "A", + parsedAt: "2026-06-20T12:00:00Z", + linkedClaimIds: ["CLM-1"], + }, + ], + total: 1, + returned: 1, + has_more: false, + }); + + const { unmount } = renderIntoContainer(React.createElement(Acks)); + await waitForText("b-single"); + + // Single link → muted mono text "1 claim" (not a colored pill — + // a single resolved link is the common case and doesn't warrant + // visual emphasis). + const singleBadge = document.body.querySelector( + '[data-testid="claims-badge-single"]', + ); + expect(singleBadge).not.toBeNull(); + expect(singleBadge?.textContent).toContain("1 claim"); + // Not the orphan variant. + expect( + document.body.querySelector('[data-testid="claims-badge-orphan"]'), + ).toBeNull(); + + unmount(); + }); + + it("test_claims_badge_shows_count_for_multiple_linked_claims", async () => { + // A 999 with three AK2 set-responses, each linked to a different + // claim batch, gets a multi-link badge. + (api.listAcks as unknown as ReturnType).mockResolvedValue({ + items: [ + { + id: 44, + sourceBatchId: "b-multi", + acceptedCount: 3, + rejectedCount: 0, + receivedCount: 3, + ackCode: "A", + parsedAt: "2026-06-20T12:00:00Z", + linkedClaimIds: ["CLM-1", "CLM-2", "CLM-3"], + }, + ], + total: 1, + returned: 1, + has_more: false, + }); + + const { unmount } = renderIntoContainer(React.createElement(Acks)); + await waitForText("b-multi"); + + // Multi link → success "{N} claims" badge. + const manyBadge = document.body.querySelector( + '[data-testid="claims-badge-many"]', + ); + expect(manyBadge).not.toBeNull(); + expect(manyBadge?.textContent).toContain("3 claims"); + expect(manyBadge?.getAttribute("data-claims-count")).toBe("3"); + + unmount(); + }); + + it("test_claims_badge_treats_undefined_linked_claim_ids_as_orphan", async () => { + // Defensive: older ack rows (pre-SP28) might not carry + // `linkedClaimIds` at all. The page must render them as + // orphan (count == 0) rather than crashing on undefined.length. + (api.listAcks as unknown as ReturnType).mockResolvedValue({ + items: [ + { + id: 45, + sourceBatchId: "b-legacy", + acceptedCount: 1, + rejectedCount: 0, + receivedCount: 1, + ackCode: "A", + parsedAt: "2026-06-20T12:00:00Z", + // linkedClaimIds intentionally omitted (legacy row) + }, + ], + total: 1, + returned: 1, + has_more: false, + }); + + const { unmount } = renderIntoContainer(React.createElement(Acks)); + await waitForText("b-legacy"); + + const orphanBadge = document.body.querySelector( + '[data-testid="claims-badge-orphan"]', + ); + expect(orphanBadge).not.toBeNull(); + + unmount(); + }); }); \ No newline at end of file diff --git a/src/pages/Acks.tsx b/src/pages/Acks.tsx index dff35c9..9d272fd 100644 --- a/src/pages/Acks.tsx +++ b/src/pages/Acks.tsx @@ -1,5 +1,5 @@ import { useCallback, useMemo, useState } from "react"; -import { CheckCircle2, Download, Mail, ShieldCheck } from "lucide-react"; +import { CheckCircle2, Download, Link2, Mail, ShieldCheck } from "lucide-react"; import { Table, TableBody, @@ -374,6 +374,14 @@ export function Acks() { /> ID Source Batch + {/* SP28: "Claims" column. The badge carries + the distinct-claim link count (per-AK2 + granularity — D1 — so a 999 with two AK2s + and one linked claim still shows "1"). + Empty cell means orphan (no auto-link + resolved); on click the operator can run + a manual match from the AckDrawer. */} + Claims Accepted @@ -438,6 +446,9 @@ export function Acks() { a.sourceBatchId )} + + + {a.acceptedCount} @@ -750,6 +761,14 @@ function Ta1AcksSection() { Control # + {/* SP28: TA1 acks also surface a "Claims" badge + (it's really a Batches badge — TA1 is + envelope-level and links to originating + Batch rows per D4 — but the operator-facing + column header is "Claims" to match the 999 + register's vocabulary). Distinct-batch + count, just like the 999 column. */} + Batches Ack Note Interchange date @@ -776,6 +795,9 @@ function Ta1AcksSection() { {t.controlNumber || "—"} + + + @@ -842,3 +864,63 @@ function Ta1CodeBadge({ code }: { code: Ta1Ack["ackCode"] }) { ); } + +// --------------------------------------------------------------------------- +// ClaimsBadge — SP28. Cell content for the "Claims" column on the 999 +// register and the "Batches" column on the TA1 register. +// +// The badge carries the distinct-claim link count (per-AK2 granularity +// — D1 — so a 999 with two AK2s and one linked claim still shows "1"). +// +// - count == 0 → amber "Orphan" pill. The ack has no resolved links; +// the operator can drill in and run a manual match. +// - count == 1 → muted mono text "{count} claim". Single-link case; +// the operator can click through to the matched ClaimDrawer. +// - count > 1 → success "{count} claims" badge. Multiple distinct +// claims resolved — usually a 999 with several AK2s each pointing +// to a different claim batch. +// --------------------------------------------------------------------------- +function ClaimsBadge({ count }: { count: number }) { + if (count === 0) { + return ( + + + Orphan + + ); + } + if (count === 1) { + return ( + + 1 claim + + ); + } + return ( + + {count} claims + + ); +}