feat(sp28): AckDrawer matched-claim panel + manual-match dropdown
This commit is contained in:
@@ -22,6 +22,84 @@ vi.mock("@/hooks/useAckDetail", () => ({
|
|||||||
useAckDetail,
|
useAckDetail,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// SP28: the new <MatchedClaim /> panel mounts three hooks (data,
|
||||||
|
// live-tail, live-tail merge) and calls `useNavigate` to navigate to
|
||||||
|
// /claims?claim=<id> when the operator clicks a claim card's
|
||||||
|
// "open claim" 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 the
|
||||||
|
// "no claim links yet" empty state — tests that need to populate
|
||||||
|
// the panel override via `setAckClaimsRows(...)`.
|
||||||
|
const {
|
||||||
|
useAckClaims,
|
||||||
|
useTailStream,
|
||||||
|
useMergedTail,
|
||||||
|
useNavigate,
|
||||||
|
matchAckToClaim,
|
||||||
|
unmatchAck,
|
||||||
|
listClaims,
|
||||||
|
} = vi.hoisted(() => ({
|
||||||
|
useAckClaims: vi.fn(),
|
||||||
|
useTailStream: vi.fn(),
|
||||||
|
useMergedTail: vi.fn(),
|
||||||
|
useNavigate: vi.fn(),
|
||||||
|
matchAckToClaim: vi.fn(),
|
||||||
|
unmatchAck: vi.fn(),
|
||||||
|
listClaims: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("@/hooks/useAckClaims", () => ({ useAckClaims }));
|
||||||
|
vi.mock("@/hooks/useTailStream", () => ({ useTailStream }));
|
||||||
|
vi.mock("@/hooks/useMergedTail", () => ({ useMergedTail }));
|
||||||
|
vi.mock("@/lib/api", async () => {
|
||||||
|
const actual =
|
||||||
|
await vi.importActual<typeof import("@/lib/api")>("@/lib/api");
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
matchAckToClaim,
|
||||||
|
unmatchAck,
|
||||||
|
listClaims,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
vi.mock("react-router-dom", async () => {
|
||||||
|
const actual =
|
||||||
|
await vi.importActual<typeof import("react-router-dom")>(
|
||||||
|
"react-router-dom",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useNavigate,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure the mocked `useAckClaims` + `useMergedTail` hooks for a
|
||||||
|
* single test. The `refetch` default is a fresh `vi.fn()` — tests
|
||||||
|
* that need to assert on it can override via `overrides.refetch`.
|
||||||
|
*/
|
||||||
|
function setAckClaimsRows(
|
||||||
|
rows: Array<{
|
||||||
|
id: number;
|
||||||
|
claimId: string | null;
|
||||||
|
batchId: string | null;
|
||||||
|
ackId: number;
|
||||||
|
ackKind: "999" | "277ca" | "ta1";
|
||||||
|
ak2Index: number | null;
|
||||||
|
setControlNumber: string | null;
|
||||||
|
setAcceptRejectCode: string | null;
|
||||||
|
linkedAt: string;
|
||||||
|
claimState?: string;
|
||||||
|
}>,
|
||||||
|
) {
|
||||||
|
useAckClaims.mockReturnValue({
|
||||||
|
data: rows,
|
||||||
|
isLoading: false,
|
||||||
|
isError: false,
|
||||||
|
error: null,
|
||||||
|
refetch: vi.fn(),
|
||||||
|
});
|
||||||
|
useMergedTail.mockReturnValue(rows);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Minimal valid `Ack` fixture — every required key present so the
|
* Minimal valid `Ack` fixture — every required key present so the
|
||||||
* component typechecks. The wire shape extends with `raw_999_text`
|
* component typechecks. The wire shape extends with `raw_999_text`
|
||||||
@@ -68,6 +146,38 @@ function mockDetail(
|
|||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
// Default SP28 mocks — empty list, no errors, no-op navigate. Tests
|
||||||
|
// that exercise the MatchedClaim panel override via
|
||||||
|
// `setAckClaimsRows(...)`.
|
||||||
|
useAckClaims.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());
|
||||||
|
matchAckToClaim.mockResolvedValue({
|
||||||
|
link_id: 1,
|
||||||
|
claim_id: "CLM-1",
|
||||||
|
ack_id: 42,
|
||||||
|
ack_kind: "999",
|
||||||
|
set_control_number: "991102989",
|
||||||
|
set_accept_reject_code: "A",
|
||||||
|
linked_at: "2026-07-02T12:00:00Z",
|
||||||
|
linked_by: "manual",
|
||||||
|
});
|
||||||
|
unmatchAck.mockResolvedValue(undefined);
|
||||||
|
listClaims.mockResolvedValue({ items: [], total: 0, returned: 0, has_more: false });
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("AckDrawer", () => {
|
describe("AckDrawer", () => {
|
||||||
@@ -189,4 +299,160 @@ describe("AckDrawer", () => {
|
|||||||
// No raw_999_text → no Download button.
|
// No raw_999_text → no Download button.
|
||||||
expect(document.querySelector('[data-testid="ack-drawer-download"]')).toBeNull();
|
expect(document.querySelector('[data-testid="ack-drawer-download"]')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// SP28: the new <MatchedClaim /> panel.
|
||||||
|
// Tests assert:
|
||||||
|
// - panel renders when `useAckClaims` returns a non-empty list
|
||||||
|
// - panel renders empty state with "Link to claim…" dropdown when
|
||||||
|
// the list is empty (default)
|
||||||
|
// - the "open claim" button navigates to /claims?claim=<id>
|
||||||
|
// - the panel mounts the live-tail subscription via
|
||||||
|
// `useTailStream("claim-acks")`
|
||||||
|
// - TA1 batch-level rows (claimId === null) render with the
|
||||||
|
// originating Batch id, not a claim card
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it("test_matched_claim_panel_renders_empty_state_when_no_links", () => {
|
||||||
|
mockDetail({ data: SAMPLE_ACK });
|
||||||
|
render(<AckDrawer ackId="42" onClose={() => {}} />);
|
||||||
|
|
||||||
|
// The panel must be present.
|
||||||
|
expect(document.querySelector('[data-testid="matched-claim-panel"]'))
|
||||||
|
.not.toBeNull();
|
||||||
|
// The empty-state copy must surface.
|
||||||
|
expect(document.querySelector('[data-testid="matched-claim-empty"]'))
|
||||||
|
.not.toBeNull();
|
||||||
|
// The "Link to claim…" dropdown is the manual-match affordance
|
||||||
|
// (per D5/D9 — any logged-in user can run it).
|
||||||
|
expect(document.querySelector('[data-testid="link-to-claim-dropdown"]'))
|
||||||
|
.not.toBeNull();
|
||||||
|
expect(document.querySelector('[data-testid="link-to-claim-toggle"]'))
|
||||||
|
.not.toBeNull();
|
||||||
|
// No row rendered.
|
||||||
|
expect(document.querySelector('[data-testid="matched-claim-card"]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_matched_claim_panel_renders_card_per_claim_link", () => {
|
||||||
|
setAckClaimsRows([
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
claimId: "CLM-1",
|
||||||
|
batchId: null,
|
||||||
|
ackId: 42,
|
||||||
|
ackKind: "999",
|
||||||
|
ak2Index: 0,
|
||||||
|
setControlNumber: "991102989",
|
||||||
|
setAcceptRejectCode: "A",
|
||||||
|
linkedAt: "2026-07-02T12:00:00Z",
|
||||||
|
claimState: "submitted",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
claimId: "CLM-2",
|
||||||
|
batchId: null,
|
||||||
|
ackId: 42,
|
||||||
|
ackKind: "999",
|
||||||
|
ak2Index: 1,
|
||||||
|
setControlNumber: "991102990",
|
||||||
|
setAcceptRejectCode: "R",
|
||||||
|
linkedAt: "2026-07-02T12:00:01Z",
|
||||||
|
claimState: "denied",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
mockDetail({ data: SAMPLE_ACK });
|
||||||
|
render(<AckDrawer ackId="42" onClose={() => {}} />);
|
||||||
|
|
||||||
|
// Per-AK2 granularity (D1): one card per link row.
|
||||||
|
const cards = document.querySelectorAll('[data-testid="matched-claim-card"]');
|
||||||
|
expect(cards.length).toBe(2);
|
||||||
|
|
||||||
|
// The header count must reflect the row count.
|
||||||
|
expect(document.querySelector('[data-testid="matched-claim-count"]')
|
||||||
|
?.textContent).toContain("(2)");
|
||||||
|
|
||||||
|
// Each card renders its claim id.
|
||||||
|
const ids = document.querySelectorAll('[data-testid="matched-claim-id"]');
|
||||||
|
expect(ids[0]?.textContent).toContain("CLM-1");
|
||||||
|
expect(ids[1]?.textContent).toContain("CLM-2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_matched_claim_panel_renders_ta1_batch_row_with_originating_batch", () => {
|
||||||
|
// TA1 batch-level rows have claimId === null. The panel renders
|
||||||
|
// them with the originating Batch id, not a claim card (D4).
|
||||||
|
setAckClaimsRows([
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
claimId: null,
|
||||||
|
batchId: "B-TA1-1",
|
||||||
|
ackId: 42,
|
||||||
|
ackKind: "ta1",
|
||||||
|
ak2Index: null,
|
||||||
|
setControlNumber: null,
|
||||||
|
setAcceptRejectCode: null,
|
||||||
|
linkedAt: "2026-07-02T12:00:00Z",
|
||||||
|
claimState: "n/a",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
mockDetail({ data: SAMPLE_ACK });
|
||||||
|
render(<AckDrawer ackId="42" onClose={() => {}} />);
|
||||||
|
|
||||||
|
// The TA1 row renders as a Batch card, not a claim card.
|
||||||
|
expect(document.querySelector('[data-testid="matched-claim-card-ta1"]'))
|
||||||
|
.not.toBeNull();
|
||||||
|
expect(document.querySelector('[data-testid="matched-claim-card"]')).toBeNull();
|
||||||
|
// The originating batch id is surfaced verbatim.
|
||||||
|
expect(document.querySelector('[data-testid="matched-claim-batch-id"]')
|
||||||
|
?.textContent).toContain("B-TA1-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_matched_claim_panel_open_claim_button_navigates_to_claims_with_claim_id", () => {
|
||||||
|
setAckClaimsRows([
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
claimId: "CLM-1",
|
||||||
|
batchId: null,
|
||||||
|
ackId: 42,
|
||||||
|
ackKind: "999",
|
||||||
|
ak2Index: 0,
|
||||||
|
setControlNumber: "991102989",
|
||||||
|
setAcceptRejectCode: "A",
|
||||||
|
linkedAt: "2026-07-02T12:00:00Z",
|
||||||
|
claimState: "submitted",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const navigate = vi.fn();
|
||||||
|
useNavigate.mockReturnValue(navigate);
|
||||||
|
mockDetail({ data: SAMPLE_ACK });
|
||||||
|
render(<AckDrawer ackId="42" onClose={() => {}} />);
|
||||||
|
|
||||||
|
const openBtn = document.querySelector(
|
||||||
|
'[data-testid="open-claim-drawer"]',
|
||||||
|
) as HTMLButtonElement | null;
|
||||||
|
expect(openBtn).not.toBeNull();
|
||||||
|
openBtn!.click();
|
||||||
|
|
||||||
|
// Cross-page navigation — the ClaimDrawer is mounted on /claims.
|
||||||
|
expect(navigate).toHaveBeenCalledTimes(1);
|
||||||
|
expect(navigate).toHaveBeenCalledWith("/claims?claim=CLM-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_matched_claim_panel_mounts_live_tail_subscription", () => {
|
||||||
|
mockDetail({ data: SAMPLE_ACK });
|
||||||
|
render(<AckDrawer ackId="42" onClose={() => {}} />);
|
||||||
|
|
||||||
|
// The panel opens one claim-acks live-tail stream. Per
|
||||||
|
// cyclone-frontend-page convention #3, the subscription lives on
|
||||||
|
// the drawer, not on the data hook.
|
||||||
|
expect(useTailStream).toHaveBeenCalledWith("claim-acks");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_matched_claim_panel_calls_useAckClaims_with_kind_and_ack_id", () => {
|
||||||
|
mockDetail({ data: SAMPLE_ACK });
|
||||||
|
render(<AckDrawer ackId="42" onClose={() => {}} />);
|
||||||
|
|
||||||
|
// The panel must pass (kind="999", ackId=42) to useAckClaims so
|
||||||
|
// the hook builds the right query key.
|
||||||
|
expect(useAckClaims).toHaveBeenCalledWith("999", 42);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Skeleton } from "@/components/ui/skeleton";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useAckDetail, type AckDetail } from "@/hooks/useAckDetail";
|
import { useAckDetail, type AckDetail } from "@/hooks/useAckDetail";
|
||||||
import { SegmentStatusList } from "./SegmentStatusList";
|
import { SegmentStatusList } from "./SegmentStatusList";
|
||||||
|
import { MatchedClaimPanel } from "./MatchedClaimPanel";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/**
|
/**
|
||||||
@@ -314,6 +315,13 @@ export function AckDrawer({ ackId, onClose }: Props) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
{/* SP28: matched-claim panel — slot as the FIRST panel
|
||||||
|
after the summary so the operator's eye-flow is
|
||||||
|
"which claim does this ack acknowledge → what was the
|
||||||
|
envelope-level ack code → per-set responses". The
|
||||||
|
panel self-routes by ack kind + id (D5/D9 — any
|
||||||
|
logged-in user can manually link an orphan). */}
|
||||||
|
<MatchedClaimPanel kind="999" ackId={data.id} />
|
||||||
<SegmentStatusList segments={[]} />
|
<SegmentStatusList segments={[]} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,420 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { ArrowRight, Link2 } from "lucide-react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useAckClaims } from "@/hooks/useAckClaims";
|
||||||
|
import { useMergedTail } from "@/hooks/useMergedTail";
|
||||||
|
import { useTailStream } from "@/hooks/useTailStream";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import { ClaimStateBadge } from "@/components/ui/claim-state-badge";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { fmt } from "@/lib/format";
|
||||||
|
import type {
|
||||||
|
ClaimAck,
|
||||||
|
ClaimAckKind,
|
||||||
|
ClaimState,
|
||||||
|
} from "@/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Local UI state for the manual-match dropdown. The dropdown fetches
|
||||||
|
* the bare `ClaimSummary` projection from the backend (which lists
|
||||||
|
* every claim by patient_control_number-ish score) — for the first
|
||||||
|
* cut, the dropdown just lists the persisted claim population by id
|
||||||
|
* with a typed filter. The backend's actual candidate-list logic is
|
||||||
|
* out of scope for the SP28 frontend (mirrors the
|
||||||
|
* `/api/inbox/ack-orphans?kind=…` candidate list shape — D7).
|
||||||
|
*/
|
||||||
|
type CandidateClaim = {
|
||||||
|
id: string;
|
||||||
|
patientName: string;
|
||||||
|
billedAmount: number;
|
||||||
|
state: ClaimState;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-AK2 / per-ClaimStatus card. For 999/277CA rows, renders the
|
||||||
|
* linked claim (PCN, current state badge, charge, and a link to
|
||||||
|
* ClaimDrawer). For TA1 rows with `claimId === null`, renders the
|
||||||
|
* originating Batch instead.
|
||||||
|
*/
|
||||||
|
function ClaimLinkCard({
|
||||||
|
link,
|
||||||
|
onOpenClaim,
|
||||||
|
}: {
|
||||||
|
link: ClaimAck;
|
||||||
|
onOpenClaim: (claimId: string) => void;
|
||||||
|
}) {
|
||||||
|
if (link.claimId === null) {
|
||||||
|
// TA1 batch-level link — render the originating Batch id instead
|
||||||
|
// of a claim (D4). The `batchId` field is populated for TA1 rows
|
||||||
|
// only; other kinds leave it null.
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex flex-col gap-2 rounded-md border px-3 py-2.5"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "hsl(var(--card) / 0.40)",
|
||||||
|
borderColor: "hsl(var(--border) / 0.5)",
|
||||||
|
}}
|
||||||
|
data-testid="matched-claim-card-ta1"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="eyebrow text-[10px] text-muted-foreground/70">
|
||||||
|
Originating batch
|
||||||
|
</span>
|
||||||
|
{link.ak2Index !== null ? (
|
||||||
|
<span className="mono text-[10px] uppercase tracking-[0.14em] text-muted-foreground/60">
|
||||||
|
AK2 #{link.ak2Index}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="mono text-[12.5px] text-foreground"
|
||||||
|
data-testid="matched-claim-batch-id"
|
||||||
|
>
|
||||||
|
{link.batchId ?? "—"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex flex-col gap-2 rounded-md border px-3 py-2.5"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "hsl(var(--card) / 0.40)",
|
||||||
|
borderColor: "hsl(var(--border) / 0.5)",
|
||||||
|
}}
|
||||||
|
data-testid="matched-claim-card"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<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="matched-claim-set-control"
|
||||||
|
title={`set_control_number: ${link.setControlNumber ?? "—"}`}
|
||||||
|
>
|
||||||
|
{link.setControlNumber ?? "—"}
|
||||||
|
</span>
|
||||||
|
{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}
|
||||||
|
{link.setAcceptRejectCode ? (
|
||||||
|
<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)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{link.setAcceptRejectCode}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span
|
||||||
|
className="mono truncate text-[12.5px] text-foreground"
|
||||||
|
data-testid="matched-claim-id"
|
||||||
|
>
|
||||||
|
{link.claimId}
|
||||||
|
</span>
|
||||||
|
{link.claimState && link.claimState !== "n/a" ? (
|
||||||
|
<ClaimStateBadge
|
||||||
|
state={link.claimState as ClaimState}
|
||||||
|
data-testid="matched-claim-state-badge"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onOpenClaim(link.claimId as string)}
|
||||||
|
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-claim-drawer"
|
||||||
|
aria-label={`Open claim ${link.claimId}`}
|
||||||
|
>
|
||||||
|
open claim
|
||||||
|
<ArrowRight className="h-3 w-3" strokeWidth={1.75} aria-hidden />
|
||||||
|
</button>
|
||||||
|
{link.linkedAt ? (
|
||||||
|
<span className="ml-auto mono text-[10.5px] text-muted-foreground/60">
|
||||||
|
linked {fmt.dateShort(link.linkedAt)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inline "Link to claim…" affordance for the orphan case. Per D5/D9,
|
||||||
|
* any logged-in user can run the manual-match endpoint — the dropdown
|
||||||
|
* doesn't gate on role. On success the panel refetches and swaps to
|
||||||
|
* the populated card view (live-tail also delivers the new link).
|
||||||
|
*/
|
||||||
|
function LinkToClaimDropdown({
|
||||||
|
kind,
|
||||||
|
ackId,
|
||||||
|
onLinked,
|
||||||
|
}: {
|
||||||
|
kind: ClaimAckKind;
|
||||||
|
ackId: number;
|
||||||
|
onLinked: () => void;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [candidates, setCandidates] = useState<CandidateClaim[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [submitting, setSubmitting] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function loadCandidates() {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
// Use the existing listClaims endpoint as a coarse candidate
|
||||||
|
// pool. The backend's full /api/inbox/ack-orphans candidate
|
||||||
|
// scoring is a separate concern — for the dropdown we just need
|
||||||
|
// a typed claim id picker. The match is performed by the
|
||||||
|
// backend's `POST /api/acks/{kind}/{id}/match-claim` endpoint.
|
||||||
|
const res = await api.listClaims({ limit: 25 });
|
||||||
|
setCandidates(
|
||||||
|
res.items.map((c) => ({
|
||||||
|
id: (c as unknown as { id: string }).id,
|
||||||
|
patientName:
|
||||||
|
(c as unknown as { patientName?: string }).patientName ?? "",
|
||||||
|
billedAmount:
|
||||||
|
(c as unknown as { billedAmount?: number }).billedAmount ?? 0,
|
||||||
|
state:
|
||||||
|
(c as unknown as { state?: ClaimState }).state ?? "submitted",
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
setOpen(true);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function link(claimId: string) {
|
||||||
|
setSubmitting(claimId);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await api.matchAckToClaim(kind, ackId, claimId);
|
||||||
|
setOpen(false);
|
||||||
|
onLinked();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setSubmitting(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2" data-testid="link-to-claim-dropdown">
|
||||||
|
{!open ? (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void loadCandidates()}
|
||||||
|
disabled={loading}
|
||||||
|
data-testid="link-to-claim-toggle"
|
||||||
|
className="self-start"
|
||||||
|
>
|
||||||
|
<Link2 className="h-3.5 w-3.5" strokeWidth={1.75} aria-hidden />
|
||||||
|
{loading ? "Loading candidates…" : "Link to claim…"}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="eyebrow text-[10px] text-muted-foreground/70">
|
||||||
|
Pick a claim to link this {kind} ack to
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="flex max-h-64 flex-col overflow-y-auto rounded-md border"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "hsl(var(--card) / 0.40)",
|
||||||
|
borderColor: "hsl(var(--border) / 0.5)",
|
||||||
|
}}
|
||||||
|
data-testid="link-to-claim-list"
|
||||||
|
>
|
||||||
|
{candidates.length === 0 ? (
|
||||||
|
<p className="px-3 py-3 text-[12px] text-muted-foreground/70">
|
||||||
|
No claims available.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
candidates.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.id}
|
||||||
|
type="button"
|
||||||
|
disabled={submitting !== null}
|
||||||
|
onClick={() => void link(c.id)}
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col items-start gap-1 border-b px-3 py-2 text-left text-[12px] transition-colors last:border-b-0",
|
||||||
|
"hover:bg-card/80 disabled:opacity-50",
|
||||||
|
)}
|
||||||
|
style={{ borderColor: "hsl(var(--border) / 0.4)" }}
|
||||||
|
data-testid="link-to-claim-option"
|
||||||
|
data-claim-id={c.id}
|
||||||
|
>
|
||||||
|
<span className="mono text-foreground">{c.id}</span>
|
||||||
|
<span className="text-[11px] text-muted-foreground/80">
|
||||||
|
{c.patientName || "—"} · {fmt.money(c.billedAmount)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
className="self-start text-[11px] text-muted-foreground/70 hover:text-foreground"
|
||||||
|
data-testid="link-to-claim-cancel"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error ? (
|
||||||
|
<p
|
||||||
|
className="text-[11px] text-destructive"
|
||||||
|
data-testid="link-to-claim-error"
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SP28: AckDrawer's new `<MatchedClaim />` panel.
|
||||||
|
*
|
||||||
|
* Mounted as the FIRST panel (before "Set responses") so the
|
||||||
|
* operator's eye-flow on an ack row is "which claim does this ack
|
||||||
|
* acknowledge → what was the envelope-level ack code → what were
|
||||||
|
* the per-set responses". Per-AK2 granularity: each AK2 / ClaimStatus
|
||||||
|
* gets its own card so a 999 with two AK2s renders two cards.
|
||||||
|
*
|
||||||
|
* For 999/277CA: renders one card per linked claim.
|
||||||
|
* For TA1: renders one card per envelope linking to the originating
|
||||||
|
* `Batch` (D4 — `claimId` is null, `batchId` is populated).
|
||||||
|
*
|
||||||
|
* Live-tail: subscribes to `useTailStream("claim-acks")` and merges
|
||||||
|
* via `useMergedTail` with the per-ack filter
|
||||||
|
* `link.ackId === ackId` so a manual match made elsewhere shows up
|
||||||
|
* here in real time. Per `cyclone-frontend-page` convention #3, the
|
||||||
|
* subscription lives on the drawer (this component), not on the
|
||||||
|
* data hook.
|
||||||
|
*
|
||||||
|
* Empty state: when no link exists yet, render the
|
||||||
|
* "Link to claim…" dropdown that calls `api.matchAckToClaim` and
|
||||||
|
* refetches on success (per D5/D9 — any logged-in user can manually
|
||||||
|
* link an orphan).
|
||||||
|
*/
|
||||||
|
export function MatchedClaimPanel({
|
||||||
|
kind,
|
||||||
|
ackId,
|
||||||
|
}: {
|
||||||
|
kind: ClaimAckKind;
|
||||||
|
ackId: number;
|
||||||
|
}) {
|
||||||
|
const { data, isLoading, refetch } = useAckClaims(kind, ackId);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
// Live-tail: open the shared claim-acks stream and filter to this
|
||||||
|
// ack. The base list (`data ?? []`) is the page's authoritative
|
||||||
|
// snapshot; the merged result also includes any new rows that
|
||||||
|
// arrived since the snapshot fetch.
|
||||||
|
useTailStream("claim-acks");
|
||||||
|
const merged = useMergedTail(
|
||||||
|
"claim-acks",
|
||||||
|
data ?? [],
|
||||||
|
(link) => link.ackId === ackId,
|
||||||
|
);
|
||||||
|
|
||||||
|
const visible = merged.filter((l) => l.ackId === ackId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className="flex flex-col gap-3 px-6 py-4"
|
||||||
|
data-testid="matched-claim-panel"
|
||||||
|
aria-label="Matched claim"
|
||||||
|
>
|
||||||
|
<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" />
|
||||||
|
Matched claim
|
||||||
|
<span
|
||||||
|
className="mono ml-1 text-[10px] tabular-nums text-muted-foreground/70"
|
||||||
|
data-testid="matched-claim-count"
|
||||||
|
>
|
||||||
|
({visible.length})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{isLoading && visible.length === 0 ? (
|
||||||
|
<div
|
||||||
|
className="flex flex-col gap-2"
|
||||||
|
data-testid="matched-claim-loading"
|
||||||
|
>
|
||||||
|
<Skeleton variant="row" />
|
||||||
|
<Skeleton variant="row" />
|
||||||
|
</div>
|
||||||
|
) : visible.length === 0 ? (
|
||||||
|
<div
|
||||||
|
className="flex flex-col gap-2 rounded-md border border-dashed border-border/60 bg-card/40 px-4 py-3"
|
||||||
|
data-testid="matched-claim-empty"
|
||||||
|
>
|
||||||
|
<p className="text-[12.5px] text-muted-foreground/80">
|
||||||
|
{kind === "ta1"
|
||||||
|
? "This TA1 envelope hasn't been linked to a Batch yet."
|
||||||
|
: "No claim links yet for this ack."}
|
||||||
|
</p>
|
||||||
|
{/* Per D5/D9: any logged-in user can run the manual-match
|
||||||
|
endpoint. The dropdown lets the operator pick from a
|
||||||
|
coarse candidate pool; the backend does the actual join. */}
|
||||||
|
<LinkToClaimDropdown
|
||||||
|
kind={kind}
|
||||||
|
ackId={ackId}
|
||||||
|
onLinked={() => {
|
||||||
|
void refetch();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-2" data-testid="matched-claim-list">
|
||||||
|
{visible.map((link) => (
|
||||||
|
<ClaimLinkCard
|
||||||
|
key={link.id}
|
||||||
|
link={link}
|
||||||
|
onOpenClaim={(claimId) => {
|
||||||
|
// Cross-page navigation — mirrors the Inbox →
|
||||||
|
// ClaimDrawer pattern. The ClaimDrawer is mounted on
|
||||||
|
// the /claims page; navigating there with ?claim=<id>
|
||||||
|
// restores the drawer with the deep-link.
|
||||||
|
navigate(
|
||||||
|
`/claims?claim=${encodeURIComponent(claimId)}`,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,3 +2,4 @@
|
|||||||
|
|
||||||
export { AckDrawer } from "./AckDrawer";
|
export { AckDrawer } from "./AckDrawer";
|
||||||
export { SegmentStatusList } from "./SegmentStatusList";
|
export { SegmentStatusList } from "./SegmentStatusList";
|
||||||
|
export { MatchedClaimPanel } from "./MatchedClaimPanel";
|
||||||
|
|||||||
Reference in New Issue
Block a user