feat(sp28): Acks page Claims badge column with orphan/single/many variants
This commit is contained in:
+160
-1
@@ -6,6 +6,7 @@ import React, { act } from "react";
|
|||||||
import { createRoot, type Root } from "react-dom/client";
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
import { Acks } from "./Acks";
|
import { Acks } from "./Acks";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
|
|
||||||
@@ -49,8 +50,20 @@ function renderIntoContainer(element: React.ReactElement): {
|
|||||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
const root: Root = createRoot(container);
|
const root: Root = createRoot(container);
|
||||||
act(() => {
|
act(() => {
|
||||||
|
// SP28: the AckDrawer mounts the new <MatchedClaim /> panel
|
||||||
|
// which calls `useNavigate()` for cross-page navigation to
|
||||||
|
// /claims?claim=<id>. The hook requires a router context, so
|
||||||
|
// the test harness wraps the page in <MemoryRouter>.
|
||||||
root.render(
|
root.render(
|
||||||
React.createElement(QueryClientProvider, { client: qc }, element),
|
React.createElement(
|
||||||
|
QueryClientProvider,
|
||||||
|
{ client: qc },
|
||||||
|
React.createElement(
|
||||||
|
MemoryRouter,
|
||||||
|
null,
|
||||||
|
element,
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
@@ -685,4 +698,150 @@ describe("Acks", () => {
|
|||||||
|
|
||||||
unmount();
|
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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
+83
-1
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { CheckCircle2, Download, Mail, ShieldCheck } from "lucide-react";
|
import { CheckCircle2, Download, Link2, Mail, ShieldCheck } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -374,6 +374,14 @@ export function Acks() {
|
|||||||
/>
|
/>
|
||||||
<TableHead>ID</TableHead>
|
<TableHead>ID</TableHead>
|
||||||
<TableHead>Source Batch</TableHead>
|
<TableHead>Source Batch</TableHead>
|
||||||
|
{/* 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. */}
|
||||||
|
<TableHead>Claims</TableHead>
|
||||||
<TableHead className="text-right">
|
<TableHead className="text-right">
|
||||||
Accepted
|
Accepted
|
||||||
</TableHead>
|
</TableHead>
|
||||||
@@ -438,6 +446,9 @@ export function Acks() {
|
|||||||
a.sourceBatchId
|
a.sourceBatchId
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<ClaimsBadge count={a.linkedClaimIds?.length ?? 0} />
|
||||||
|
</TableCell>
|
||||||
<TableCell className="text-right display mono text-[hsl(var(--success))]">
|
<TableCell className="text-right display mono text-[hsl(var(--success))]">
|
||||||
{a.acceptedCount}
|
{a.acceptedCount}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -750,6 +761,14 @@ function Ta1AcksSection() {
|
|||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="w-10" aria-label="Status" />
|
<TableHead className="w-10" aria-label="Status" />
|
||||||
<TableHead>Control #</TableHead>
|
<TableHead>Control #</TableHead>
|
||||||
|
{/* 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. */}
|
||||||
|
<TableHead>Batches</TableHead>
|
||||||
<TableHead>Ack</TableHead>
|
<TableHead>Ack</TableHead>
|
||||||
<TableHead>Note</TableHead>
|
<TableHead>Note</TableHead>
|
||||||
<TableHead>Interchange date</TableHead>
|
<TableHead>Interchange date</TableHead>
|
||||||
@@ -776,6 +795,9 @@ function Ta1AcksSection() {
|
|||||||
<TableCell className="display mono text-[13px] text-foreground">
|
<TableCell className="display mono text-[13px] text-foreground">
|
||||||
{t.controlNumber || "—"}
|
{t.controlNumber || "—"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<ClaimsBadge count={t.linkedClaimIds?.length ?? 0} />
|
||||||
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Ta1CodeBadge code={t.ackCode} />
|
<Ta1CodeBadge code={t.ackCode} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -842,3 +864,63 @@ function Ta1CodeBadge({ code }: { code: Ta1Ack["ackCode"] }) {
|
|||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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 (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1 rounded-sm border px-2 py-0.5 mono text-[10.5px] font-semibold uppercase tracking-[0.14em]"
|
||||||
|
style={{
|
||||||
|
color: "hsl(var(--warning))",
|
||||||
|
backgroundColor: "hsl(var(--warning) / 0.10)",
|
||||||
|
borderColor: "hsl(var(--warning) / 0.30)",
|
||||||
|
}}
|
||||||
|
data-testid="claims-badge-orphan"
|
||||||
|
title="No auto-link resolved — click to manually match"
|
||||||
|
>
|
||||||
|
<Link2 className="h-3 w-3" strokeWidth={1.75} aria-hidden />
|
||||||
|
Orphan
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (count === 1) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="mono text-[11.5px] text-foreground"
|
||||||
|
data-testid="claims-badge-single"
|
||||||
|
data-claims-count="1"
|
||||||
|
>
|
||||||
|
1 claim
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1 rounded-sm border px-2 py-0.5 mono text-[10.5px] font-semibold uppercase tracking-[0.14em]"
|
||||||
|
style={{
|
||||||
|
color: "hsl(var(--success))",
|
||||||
|
backgroundColor: "hsl(var(--success) / 0.10)",
|
||||||
|
borderColor: "hsl(var(--success) / 0.30)",
|
||||||
|
}}
|
||||||
|
data-testid="claims-badge-many"
|
||||||
|
data-claims-count={count}
|
||||||
|
>
|
||||||
|
{count} claims
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user