feat(sp28): ClaimDrawer acknowledgments panel + tests
This commit is contained in:
@@ -0,0 +1,280 @@
|
|||||||
|
import { ArrowRight } from "lucide-react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useClaimAcks } from "@/hooks/useClaimAcks";
|
||||||
|
import { useMergedTail } from "@/hooks/useMergedTail";
|
||||||
|
import { useTailStream } from "@/hooks/useTailStream";
|
||||||
|
import { EmptyState } from "@/components/ui/empty-state";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { fmt } from "@/lib/format";
|
||||||
|
import type { ClaimAck, ClaimAckKind } from "@/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Color palette for the per-AK2 / per-ClaimStatus accept-reject code
|
||||||
|
* pill. Mirrors the `Acks.tsx` `AckCodeBadge` palette so the
|
||||||
|
* operator's eye-flow is identical between the Acks page and this
|
||||||
|
* panel.
|
||||||
|
*
|
||||||
|
* - "A" (accepted) → success green
|
||||||
|
* - "R" (rejected) → destructive red
|
||||||
|
* - "E" (errors) → warning amber
|
||||||
|
* - "X" (rejected w/ 999 AK5) → destructive red
|
||||||
|
* - anything else → muted (treats STC category codes like A1/A2/A6 as
|
||||||
|
* operator-readable rather than literal A/E letters)
|
||||||
|
*/
|
||||||
|
function codeTone(code: string | null | undefined): {
|
||||||
|
fg: string;
|
||||||
|
bg: string;
|
||||||
|
border: string;
|
||||||
|
} {
|
||||||
|
if (!code) {
|
||||||
|
return {
|
||||||
|
fg: "hsl(var(--muted-foreground))",
|
||||||
|
bg: "hsl(var(--muted) / 0.30)",
|
||||||
|
border: "hsl(var(--border) / 0.5)",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const upper = code.toUpperCase();
|
||||||
|
if (upper === "A") {
|
||||||
|
return {
|
||||||
|
fg: "hsl(var(--success))",
|
||||||
|
bg: "hsl(var(--success) / 0.10)",
|
||||||
|
border: "hsl(var(--success) / 0.30)",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (upper === "R" || upper === "X") {
|
||||||
|
return {
|
||||||
|
fg: "hsl(var(--destructive))",
|
||||||
|
bg: "hsl(var(--destructive) / 0.10)",
|
||||||
|
border: "hsl(var(--destructive) / 0.30)",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (upper === "E" || upper === "P") {
|
||||||
|
return {
|
||||||
|
fg: "hsl(var(--warning))",
|
||||||
|
bg: "hsl(var(--warning) / 0.10)",
|
||||||
|
border: "hsl(var(--warning) / 0.30)",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// STC category code (A1/A2/A3/A4/A6/A7) — keep the operator-visible
|
||||||
|
// tone as warning so a 277CA "rejected" stands out, but render the
|
||||||
|
// literal code rather than the badge letter so the operator can
|
||||||
|
// tell which STC category triggered the link.
|
||||||
|
return {
|
||||||
|
fg: "hsl(var(--warning))",
|
||||||
|
bg: "hsl(var(--warning) / 0.10)",
|
||||||
|
border: "hsl(var(--warning) / 0.30)",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function AckKindBadge({ kind }: { kind: ClaimAckKind }) {
|
||||||
|
// The ack kind itself is one of "999" / "277ca" / "ta1". TA1 rows
|
||||||
|
// don't appear in this panel (filtered out per D4), but the type
|
||||||
|
// union still allows it — render it consistently with the Acks page.
|
||||||
|
const label =
|
||||||
|
kind === "999" ? "999" : kind === "277ca" ? "277CA" : "TA1";
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="mono inline-flex items-center rounded-sm border px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em]"
|
||||||
|
style={{
|
||||||
|
color: "hsl(var(--muted-foreground))",
|
||||||
|
backgroundColor: "hsl(var(--muted) / 0.30)",
|
||||||
|
borderColor: "hsl(var(--border) / 0.5)",
|
||||||
|
}}
|
||||||
|
data-testid="ack-kind-badge"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One compact row in the Acknowledgments panel. Renders the ack
|
||||||
|
* code (color-coded), the ack kind, the linked set_control_number
|
||||||
|
* (the operator-readable join key), the parsed_at date, and a `→`
|
||||||
|
* link to the matching AckDrawer.
|
||||||
|
*/
|
||||||
|
function AckLinkRow({
|
||||||
|
link,
|
||||||
|
onOpenAck,
|
||||||
|
}: {
|
||||||
|
link: ClaimAck;
|
||||||
|
onOpenAck: (ackId: number, kind: ClaimAckKind) => void;
|
||||||
|
}) {
|
||||||
|
const tone = codeTone(link.setAcceptRejectCode);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-3 rounded-md border px-3 py-2"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "hsl(var(--card) / 0.40)",
|
||||||
|
borderColor: "hsl(var(--border) / 0.5)",
|
||||||
|
}}
|
||||||
|
data-testid="acknowledgments-row"
|
||||||
|
>
|
||||||
|
{/* Accept/reject pill */}
|
||||||
|
<span
|
||||||
|
className="mono inline-flex items-center rounded-sm border px-2 py-0.5 text-[10.5px] font-semibold uppercase tracking-[0.14em]"
|
||||||
|
style={{
|
||||||
|
color: tone.fg,
|
||||||
|
backgroundColor: tone.bg,
|
||||||
|
borderColor: tone.border,
|
||||||
|
}}
|
||||||
|
data-testid="ack-code-pill"
|
||||||
|
>
|
||||||
|
{link.setAcceptRejectCode ?? "—"}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* AK2 index chip (999 only) — gives the operator a quick
|
||||||
|
"which set-response" reference for the per-AK2 granularity. */}
|
||||||
|
{link.ak2Index !== null ? (
|
||||||
|
<span
|
||||||
|
className="mono text-[10px] uppercase tracking-[0.14em] text-muted-foreground/60"
|
||||||
|
title="AK2 set-response index"
|
||||||
|
>
|
||||||
|
AK2 #{link.ak2Index}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<AckKindBadge kind={link.ackKind} />
|
||||||
|
|
||||||
|
{link.setControlNumber ? (
|
||||||
|
<span
|
||||||
|
className="mono truncate text-[11.5px] text-muted-foreground/80"
|
||||||
|
title={`set_control_number: ${link.setControlNumber}`}
|
||||||
|
>
|
||||||
|
{link.setControlNumber}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<span className="ml-auto mono text-[10.5px] text-muted-foreground/60">
|
||||||
|
{link.linkedAt ? fmt.dateShort(link.linkedAt) : "—"}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onOpenAck(link.ackId, link.ackKind)}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1 rounded-sm border px-2 py-1 text-[10.5px] mono font-semibold uppercase tracking-[0.14em] transition-colors",
|
||||||
|
"border-border/60 bg-card/40 text-muted-foreground hover:bg-card/80 hover:text-foreground",
|
||||||
|
)}
|
||||||
|
data-testid="open-ack-drawer"
|
||||||
|
aria-label={`Open ${link.ackKind} ACK ${link.ackId}`}
|
||||||
|
>
|
||||||
|
open
|
||||||
|
<ArrowRight className="h-3 w-3" strokeWidth={1.75} aria-hidden />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SP28: ClaimDrawer's new `<Acknowledgments />` panel.
|
||||||
|
*
|
||||||
|
* Mounted between `<ServiceLinesTable />` 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
|
||||||
|
* `<EmptyState />` 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 (
|
||||||
|
<section
|
||||||
|
className="flex flex-col gap-3 px-6 py-4"
|
||||||
|
data-testid="acknowledgments-panel"
|
||||||
|
aria-label="Acknowledgments"
|
||||||
|
>
|
||||||
|
<header className="flex items-center justify-between gap-3">
|
||||||
|
<div className="eyebrow flex items-center gap-2">
|
||||||
|
<span className="inline-block h-px w-6 bg-foreground/20" />
|
||||||
|
Acknowledgments
|
||||||
|
<span
|
||||||
|
className="mono ml-1 text-[10px] tabular-nums text-muted-foreground/70"
|
||||||
|
data-testid="acknowledgments-count"
|
||||||
|
>
|
||||||
|
({visible.length})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{isLoading && visible.length === 0 ? (
|
||||||
|
<p
|
||||||
|
className="text-[12.5px] text-muted-foreground/70"
|
||||||
|
data-testid="acknowledgments-loading"
|
||||||
|
>
|
||||||
|
Loading acknowledgments…
|
||||||
|
</p>
|
||||||
|
) : visible.length === 0 ? (
|
||||||
|
<div
|
||||||
|
className="rounded-md border border-dashed border-border/60 bg-card/40"
|
||||||
|
data-testid="acknowledgments-empty"
|
||||||
|
>
|
||||||
|
<EmptyState
|
||||||
|
eyebrow="Acknowledgments · none yet"
|
||||||
|
message="No acknowledgments received yet for this claim."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-2" data-testid="acknowledgments-list">
|
||||||
|
{visible.map((link) => (
|
||||||
|
<AckLinkRow
|
||||||
|
key={link.id}
|
||||||
|
link={link}
|
||||||
|
onOpenAck={(ackId, kind) => {
|
||||||
|
// Cross-page navigation: the AckDrawer is mounted on
|
||||||
|
// the /acks page, not on /claims. Push the user to
|
||||||
|
// /acks?ack=<id> 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=<id>.
|
||||||
|
//
|
||||||
|
// `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;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -32,6 +32,58 @@ vi.mock("@/lib/api", () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// SP28: the new <Acknowledgments /> panel mounts three hooks (data,
|
||||||
|
// live-tail, live-tail merge) and calls `useNavigate` to navigate to
|
||||||
|
// /acks?ack=<id> 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 <EmptyState /> — 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<typeof import("react-router-dom")>(
|
||||||
|
"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
|
* Minimal valid ClaimDetail fixture — every required key present so the
|
||||||
* component typechecks. Mirrors the SAMPLE_DETAIL in
|
* component typechecks. Mirrors the SAMPLE_DETAIL in
|
||||||
@@ -236,6 +288,26 @@ async function settle(
|
|||||||
describe("ClaimDrawer", () => {
|
describe("ClaimDrawer", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
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(() => {
|
afterEach(() => {
|
||||||
@@ -471,4 +543,196 @@ describe("ClaimDrawer", () => {
|
|||||||
|
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// SP28: the new <Acknowledgments /> panel.
|
||||||
|
// Tests assert:
|
||||||
|
// - panel renders when `useClaimAcks` returns a non-empty list
|
||||||
|
// - panel renders <EmptyState /> when the list is empty (default)
|
||||||
|
// - the "open" button navigates to /acks?ack=<id>
|
||||||
|
// - 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=<id> — 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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { DiagnosesList } from "./DiagnosesList";
|
|||||||
import { PartiesGrid } from "./PartiesGrid";
|
import { PartiesGrid } from "./PartiesGrid";
|
||||||
import { RawSegmentsPanel } from "./RawSegmentsPanel";
|
import { RawSegmentsPanel } from "./RawSegmentsPanel";
|
||||||
import { MatchedRemitCard } from "./MatchedRemitCard";
|
import { MatchedRemitCard } from "./MatchedRemitCard";
|
||||||
|
import { AcknowledgmentsPanel } from "./AcknowledgmentsPanel";
|
||||||
import { StateHistoryTimeline } from "./StateHistoryTimeline";
|
import { StateHistoryTimeline } from "./StateHistoryTimeline";
|
||||||
import { LineReconciliationTab } from "./LineReconciliationTab";
|
import { LineReconciliationTab } from "./LineReconciliationTab";
|
||||||
import { ClaimDrawerSkeleton } from "./ClaimDrawerSkeleton";
|
import { ClaimDrawerSkeleton } from "./ClaimDrawerSkeleton";
|
||||||
@@ -232,6 +233,13 @@ export function ClaimDrawer({
|
|||||||
serviceLines={data.serviceLines}
|
serviceLines={data.serviceLines}
|
||||||
lineReconciliation={data.lineReconciliation}
|
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). */}
|
||||||
|
<AcknowledgmentsPanel claimId={data.id} />
|
||||||
<DiagnosesList diagnoses={data.diagnoses} />
|
<DiagnosesList diagnoses={data.diagnoses} />
|
||||||
<PartiesGrid parties={data.parties} />
|
<PartiesGrid parties={data.parties} />
|
||||||
{data.matchedRemittance ? (
|
{data.matchedRemittance ? (
|
||||||
|
|||||||
Reference in New Issue
Block a user