feat(sp28): ClaimDrawer acknowledgments panel + tests

This commit is contained in:
Nora
2026-07-02 11:33:47 -06:00
parent b094231995
commit 490a1b9478
3 changed files with 552 additions and 0 deletions
@@ -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
* 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 <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 { 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). */}
<AcknowledgmentsPanel claimId={data.id} />
<DiagnosesList diagnoses={data.diagnoses} />
<PartiesGrid parties={data.parties} />
{data.matchedRemittance ? (