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
+
+ );
+}