diff --git a/src/components/AckDrawer/AckDrawer.test.tsx b/src/components/AckDrawer/AckDrawer.test.tsx new file mode 100644 index 0000000..afef197 --- /dev/null +++ b/src/components/AckDrawer/AckDrawer.test.tsx @@ -0,0 +1,192 @@ +// @vitest-environment happy-dom +// AckDrawer wires `useAckDetail` (TanStack Query) and renders a Radix +// Dialog portal — both need an act-aware, DOM-backed environment or +// React logs warnings and the portal can't mount. +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +import { afterEach, describe, it, expect, vi } from "vitest"; +import { cleanup, render } from "@testing-library/react"; +import { ApiError } from "@/lib/api"; +import { AckDrawer } from "@/components/AckDrawer"; +import type { Ack } from "@/types"; + +// Mock the hook BEFORE the import above is resolved (vitest hoists +// `vi.mock` to the top of the file regardless of where it appears +// syntactically). Mocking the hook directly — rather than mocking +// `api.getAck` — lets each test pin the hook's exact return shape +// without standing up a real `QueryClient`. +const { useAckDetail } = vi.hoisted(() => ({ + useAckDetail: vi.fn(), +})); +vi.mock("@/hooks/useAckDetail", () => ({ + useAckDetail, +})); + +/** + * Minimal valid `Ack` fixture — every required key present so the + * component typechecks. The wire shape extends with `raw_999_text` + * (and `rawJson`), per `useAckDetail`'s `AckDetail` type — populated + * when the backend serves it; absent on older rows. + */ +const SAMPLE_ACK: Ack & { raw_999_text: string } = { + id: 42, + sourceBatchId: "b-uuid-1", + acceptedCount: 3, + rejectedCount: 1, + receivedCount: 4, + ackCode: "P", + parsedAt: "2026-06-20T12:00:00Z", + raw_999_text: "ISA*...*~\nGS*...*~\nST*999*0001~", +}; + +/** + * Configure the mocked hook's return value 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 mockDetail( + overrides: Partial<{ + data: (Ack & { raw_999_text?: string }) | null; + isLoading: boolean; + isError: boolean; + error: Error | null; + refetch: () => void; + }> = {} +) { + useAckDetail.mockReturnValue({ + data: null, + isLoading: false, + isError: false, + error: null, + refetch: vi.fn(), + ...overrides, + }); +} + +// happy-dom keeps `document.body` between tests; without cleanup, +// `screen.getByText(...)` would find nodes from earlier renders. +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe("AckDrawer", () => { + it("test_renders_nothing_when_ackId_is_null", () => { + mockDetail({ data: null }); + render( {}} />); + + // No ack content should be in the document when the drawer is + // closed — Radix's Dialog gates the portal on `open`. + expect(document.body.textContent).not.toContain("b-uuid-1"); + }); + + it("test_calls_useAckDetail_with_ackId", () => { + mockDetail({ data: SAMPLE_ACK }); + render( {}} />); + + expect(useAckDetail).toHaveBeenCalledWith("42"); + }); + + it("test_renders_ack_summary_on_success", () => { + mockDetail({ data: SAMPLE_ACK }); + render( {}} />); + + // Header shows the source batch id as the title. + expect(document.body.textContent).toContain("b-uuid-1"); + // Counts (3 accepted, 1 rejected, 4 received) are surfaced as + // StatTile values. + expect(document.body.textContent).toContain("3"); + expect(document.body.textContent).toContain("1"); + expect(document.body.textContent).toContain("4"); + }); + + it("test_renders_ack_code_pill_with_human_label", () => { + mockDetail({ data: { ...SAMPLE_ACK, ackCode: "P" } }); + render( {}} />); + + // The pill renders a human label, not just the bare code letter. + expect(document.body.textContent).toContain("Partially accepted"); + }); + + it("test_renders_skeleton_while_loading", () => { + mockDetail({ isLoading: true }); + render( {}} />); + + // The `Skeleton` primitive sets `aria-busy="true"` — a stable + // hook for the loading state. + expect(document.querySelectorAll('[aria-busy="true"]').length).toBeGreaterThan(0); + // And the ack id should NOT have leaked in yet. + expect(document.body.textContent).not.toContain("b-uuid-1"); + }); + + it("test_renders_not_found_error_on_404", () => { + mockDetail({ + isError: true, + error: new ApiError(404, "Ack ghost not found"), + }); + render( {}} />); + + const errEl = document.querySelector('[data-testid="ack-drawer-error-not_found"]'); + expect(errEl).not.toBeNull(); + // Body should mention "doesn't exist" — the not_found COPY key. + expect(errEl?.textContent).toContain("doesn't exist"); + // And the retry button should NOT be present (not_found has no + // retry affordance — retrying a 404 won't help). + expect(document.querySelector('[data-testid="error-retry"]')).toBeNull(); + }); + + it("test_renders_network_error_with_retry", () => { + mockDetail({ isError: true, error: new Error("network down") }); + render( {}} />); + + const errEl = document.querySelector('[data-testid="ack-drawer-error-network"]'); + expect(errEl).not.toBeNull(); + expect(errEl?.textContent).toContain("Couldn't reach the server"); + + // Network variant shows a Retry button. + const retryBtn = document.querySelector( + '[data-testid="error-retry"]' + ) as HTMLButtonElement | null; + expect(retryBtn).not.toBeNull(); + }); + + it("test_close_button_calls_onClose", () => { + const onClose = vi.fn<() => void>(); + mockDetail({ isError: true, error: new Error("network down") }); + render(); + + const closeBtn = document.querySelector( + '[data-testid="error-close"]' + ) as HTMLButtonElement | null; + expect(closeBtn).not.toBeNull(); + closeBtn!.click(); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("test_renders_download_button_when_raw_999_text_present", () => { + mockDetail({ data: SAMPLE_ACK }); + render( {}} />); + + // The header action slot populates with the Download 999 button + // only when the ack detail carries raw_999_text. + const dlBtn = document.querySelector('[data-testid="ack-drawer-download"]'); + expect(dlBtn).not.toBeNull(); + }); + + it("test_omits_download_button_when_raw_999_text_absent", () => { + const data: Ack = { + id: 42, + sourceBatchId: "b-uuid-1", + acceptedCount: 3, + rejectedCount: 1, + receivedCount: 4, + ackCode: "A", + parsedAt: "2026-06-20T12:00:00Z", + }; + mockDetail({ data }); + render( {}} />); + + // No raw_999_text → no Download button. + expect(document.querySelector('[data-testid="ack-drawer-download"]')).toBeNull(); + }); +}); diff --git a/src/components/AckDrawer/AckDrawer.tsx b/src/components/AckDrawer/AckDrawer.tsx new file mode 100644 index 0000000..956859d --- /dev/null +++ b/src/components/AckDrawer/AckDrawer.tsx @@ -0,0 +1,324 @@ +import { useCallback, useState } from "react"; +import { Download } from "lucide-react"; +import { Dialog, DialogContent } from "@/components/ui/dialog"; +import { ApiError } from "@/lib/api"; +import { DrillDrawerHeader } from "@/components/drill/DrillDrawerHeader"; +import { Skeleton } from "@/components/ui/skeleton"; +import { cn } from "@/lib/utils"; +import { useAckDetail, type AckDetail } from "@/hooks/useAckDetail"; +import { SegmentStatusList } from "./SegmentStatusList"; + +interface Props { + /** + * Currently-open ack id (string), or `null` when the drawer is + * closed. The URL stores ids as strings so deep links round-trip + * cleanly; the hook does the `Number()` coercion before calling + * `api.getAck`. + */ + ackId: string | null; + /** Fired when the user dismisses the drawer (X button, Escape, etc.). */ + onClose: () => void; +} + +/** + * Roll a hex code into a "kind" so the drawer's error branch can + * pick the right copy. Mirrors `ProviderDrawer`'s `errorKind` + * computation. + */ +type ErrorKind = "not_found" | "network"; + +function AckDrawerError({ + kind, + onRetry, + onClose, +}: { + kind: ErrorKind; + onRetry?: () => void; + onClose: () => void; +}) { + const COPY = { + not_found: { + eyebrow: "NOT FOUND", + message: "This 999 ACK doesn't exist or has been removed.", + }, + network: { + eyebrow: "CONNECTION", + message: + "Couldn't reach the server. Check your connection and try again.", + }, + } as const; + const { eyebrow, message } = COPY[kind]; + return ( +
+ +
+ + {eyebrow} + +

{message}

+
+ {kind === "network" && onRetry ? ( + + ) : null} + +
+
+
+ ); +} + +/** + * Inline ack code pill — same color palette as `AcksPage` + * (`Acks.tsx`) so the badge reads the same in both surfaces. + */ +function AckCodePill({ code }: { code: AckDetail["ackCode"] }) { + const cfg = + code === "A" + ? { + fg: "hsl(152 64% 30%)", + bg: "hsl(152 50% 88%)", + border: "hsl(152 64% 38% / 0.30)", + label: "Accepted", + } + : code === "R" + ? { + fg: "hsl(358 70% 36%)", + bg: "hsl(358 70% 92%)", + border: "hsl(358 70% 50% / 0.30)", + label: "Rejected", + } + : code === "P" + ? { + fg: "hsl(36 92% 30%)", + bg: "hsl(36 82% 88%)", + border: "hsl(36 92% 50% / 0.30)", + label: "Partially accepted", + } + : { + fg: "hsl(36 92% 30%)", + bg: "hsl(36 82% 88%)", + border: "hsl(36 92% 50% / 0.30)", + label: "Accepted w/ errors", + }; + return ( + + {cfg.label} + + ); +} + +function StatTile({ + label, + value, + tone, +}: { + label: string; + value: number | string; + tone: "success" | "destructive" | "ink"; +}) { + const color = + tone === "success" + ? "hsl(152 64% 30%)" + : tone === "destructive" + ? "hsl(358 70% 36%)" + : "hsl(var(--foreground))"; + return ( +
+
+ {label} +
+
+ {value} +
+
+ ); +} + +/** + * 999 ACK drill-down drawer (SP21 Phase 5 Task 5.2). + * + * Mirror of `ProviderDrawer` — same right-anchored side-panel shell, + * same `errorKind` + `useAckDetail` shape (404 vs network), same + * skeleton-first loading state. The body is slim: rolled-up counts + * at the top, the ack code pill, the source batch id, and a + * per-segment status list. The header carries a "Download 999" + * action so the user can grab the original X12 file from the drawer + * without a second round-trip to `/api/acks/{id}/raw`. + * + * Layout mirrors `ProviderDrawer`/`ClaimDrawer`: Radix Dialog + * repositioned to the right edge as a fixed-height side panel, with + * the shared `DrillDrawerHeader` on top and a scrollable body below. + * + * Error branching: + * - `ApiError(404)` → "not_found" (no retry, the ack is gone) + * - anything else → "network" (retry available) + */ +export function AckDrawer({ ackId, onClose }: Props) { + const { data, isLoading, isError, error, refetch } = useAckDetail(ackId); + + const errorKind: ErrorKind | null = isError + ? error instanceof ApiError && error.status === 404 + ? "not_found" + : "network" + : null; + + // Download affordance for the regenerated 999 X12 text. Lives in + // the drawer (not the page) so a deep-linked user can grab the file + // without bouncing back to /acks. `raw_999_text` may be empty for + // older rows without the field — we silently no-op rather than + // surfacing an error toast (the spec calls this a "low stakes" + // affordance). + const [downloading, setDownloading] = useState(false); + const onDownload = useCallback(async () => { + if (!data) return; + const raw = + (data as AckDetail & { raw_999_text?: string }).raw_999_text ?? ""; + if (!raw || downloading) return; + setDownloading(true); + try { + const blob = new Blob([raw], { type: "text/plain" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `ack-${data.sourceBatchId}.999`; + a.click(); + URL.revokeObjectURL(url); + } finally { + setDownloading(false); + } + }, [data, downloading]); + + const downloadAction = + data && (data as AckDetail & { raw_999_text?: string }).raw_999_text ? ( + + ) : null; + + return ( + { if (!o) onClose(); }}> + + {errorKind ? ( + { + void refetch(); + }} + onClose={onClose} + /> + ) : isLoading || !data ? ( +
+ +
+ + + +
+
+ ) : ( +
+ +
+
+
+ + + ID {data.id} + + + · {data.parsedAt ? data.parsedAt.slice(0, 10) : "—"} + +
+
+ + + +
+
+ +
+
+ )} +
+
+ ); +} diff --git a/src/components/AckDrawer/SegmentStatusList.tsx b/src/components/AckDrawer/SegmentStatusList.tsx new file mode 100644 index 0000000..41fa178 --- /dev/null +++ b/src/components/AckDrawer/SegmentStatusList.tsx @@ -0,0 +1,150 @@ +import { CheckCircle2, AlertTriangle, Minus } from "lucide-react"; + +/** + * One 999 ACK segment status row. The 999 spec labels each transaction + * set with a 3-char code: + * + * "A" = Accepted + * "E" = Accepted, but errors are noted (one or more segments had + * errors; the transaction set as a whole was accepted) + * "R" = Rejected + * "P" = Partially accepted (mixed — some segments accepted, some + * not; a degraded success the operator must investigate) + * + * The wire shape's `rawJson` (set by the parser, see backend + * `cyclone.parsers.parsers_999`) carries an array of segment rows. + * Until that array is parsed into a stable UI shape, we render the + * rolled-up counts on the ack row (`acceptedCount` / `rejectedCount`) + * and a placeholder explaining how to read it. + */ +interface SegmentRow { + /** Loop / segment reference like "ST*999*0001" or "AK2*HC*0001". */ + reference?: string; + /** "A" | "E" | "R" | "P". */ + status: "A" | "E" | "R" | "P"; + /** Free-form note from the parser (e.g. "SVC*HC:9450 missing"). */ + note?: string; +} + +interface Props { + segments: SegmentRow[]; +} + +/** + * Pill for one segment status. Color mirrors the badge palette used on + * the Acks page (`Acks.tsx`) so a status reads the same in both + * surfaces. + */ +function StatusPill({ status }: { status: SegmentRow["status"] }) { + const cfg = { + A: { + fg: "hsl(152 64% 30%)", + bg: "hsl(152 50% 88%)", + border: "hsl(152 64% 38% / 0.30)", + label: "Accepted", + Icon: CheckCircle2, + }, + E: { + fg: "hsl(36 92% 30%)", + bg: "hsl(36 82% 88%)", + border: "hsl(36 92% 50% / 0.30)", + label: "Accepted w/ errors", + Icon: AlertTriangle, + }, + R: { + fg: "hsl(358 70% 36%)", + bg: "hsl(358 70% 92%)", + border: "hsl(358 70% 50% / 0.30)", + label: "Rejected", + Icon: AlertTriangle, + }, + P: { + fg: "hsl(36 92% 30%)", + bg: "hsl(36 82% 88%)", + border: "hsl(36 92% 50% / 0.30)", + label: "Partially accepted", + Icon: Minus, + }, + }[status]; + + const Icon = cfg.Icon; + return ( + + + {cfg.label} + + ); +} + +/** + * Per-segment status list inside the AckDrawer body (SP21 Phase 5 + * Task 5.2). Renders one row per parsed 999 segment, each with the + * segment reference (when present), the parsed status code, and the + * parser's note (when present). An empty list renders a small + * explanatory note so the drawer doesn't look broken on old acks + * without the per-segment slice. + */ +export function SegmentStatusList({ segments }: Props) { + return ( +
+
+ + Segment status + + + {segments.length} segment{segments.length === 1 ? "" : "s"} + +
+ {segments.length === 0 ? ( +

+ No per-segment breakdown for this ack — the rolled-up accepted + / rejected counts above are the only signal. +

+ ) : ( +
    + {segments.map((seg, idx) => ( +
  • + + {idx + 1} + +
    +
    + + {seg.reference ? ( + + {seg.reference} + + ) : null} +
    + {seg.note ? ( +

    + {seg.note} +

    + ) : null} +
    +
  • + ))} +
+ )} +
+ ); +} diff --git a/src/components/AckDrawer/index.ts b/src/components/AckDrawer/index.ts new file mode 100644 index 0000000..cb96596 --- /dev/null +++ b/src/components/AckDrawer/index.ts @@ -0,0 +1,4 @@ +// Barrel export for the AckDrawer module (SP21 Phase 5 Task 5.2). + +export { AckDrawer } from "./AckDrawer"; +export { SegmentStatusList } from "./SegmentStatusList"; diff --git a/src/components/drill/DrillDrawerHeader.tsx b/src/components/drill/DrillDrawerHeader.tsx index 062e8ff..2e87aa6 100644 --- a/src/components/drill/DrillDrawerHeader.tsx +++ b/src/components/drill/DrillDrawerHeader.tsx @@ -1,9 +1,19 @@ +import type { ReactNode } from "react"; import { X } from "lucide-react"; interface Props { eyebrow: string; title: string; onClose: () => void; + /** + * Optional slot for a right-side action (e.g. "Download 999" on the + * AckDrawer, "Download 837" on the ClaimDrawer — added in SP21 + * Phase 5 Task 5.2/5.10). Rendered to the left of the close + * button with a small visual gap. Anything goes — a button, a + * status pill, an icon link. Default `null` so existing callers + * (ProviderDrawer) render unchanged. + */ + action?: ReactNode; } /** @@ -15,7 +25,7 @@ interface Props { * the app (see ``.eyebrow`` in ``src/index.css`` and the * ``DrillDrawerHeader`` usage in ``ClaimDrawerHeader``). */ -export function DrillDrawerHeader({ eyebrow, title, onClose }: Props) { +export function DrillDrawerHeader({ eyebrow, title, onClose, action }: Props) { return (
@@ -24,14 +34,17 @@ export function DrillDrawerHeader({ eyebrow, title, onClose }: Props) {

{title}

- +
+ {action} + +
); } diff --git a/src/hooks/useAckDetail.ts b/src/hooks/useAckDetail.ts new file mode 100644 index 0000000..6d2bd47 --- /dev/null +++ b/src/hooks/useAckDetail.ts @@ -0,0 +1,89 @@ +import { useQuery } from "@tanstack/react-query"; +import { api, ApiError } from "@/lib/api"; +import type { Ack } from "@/types"; + +/** + * UI-facing ack detail shape returned by `GET /api/acks/{id}`. + * + * Extends the base `Ack` shape with the `raw_999_text` field that + * `api.getAck` populates on top of the canonical row. The download + * button inside `AckDrawer` reads this string to hand the user the + * regenerated X12 file. + */ +export interface AckDetail extends Ack { + /** + * Full regenerated 999 X12 text. The backend re-emits the parsed + * transaction set so a user can grab the original file from the + * drawer without a second round-trip to `/api/acks/{id}/raw`. + */ + raw_999_text?: string; + /** + * Raw JSON envelope captured by the parser (the same dict the + * parser wrote into `raw_json`). Optional so older rows without + * it still typecheck. + */ + rawJson?: unknown; +} + +/** + * Per-ack detail drawer query (AckDrawer · SP21 Phase 5 Task 5.2). + * + * Twin of `useProviderDetail` and `useClaimDetail` — same return + * shape, same retry semantics, no in-memory fallback (the spec §5.2 + * calls out that ACKs are backend-only; `useAcks` has no sample-data + * path so there's nothing to fall back on). + * + * Returns `{ data, isLoading, isError, error, refetch }`: + * - `ackId === null` (drawer closed): the query is disabled and the + * hook short-circuits to the empty drawer state so a closed drawer + * doesn't burn a network request or a TanStack cache slot. + * - `ackId` is set: fetches `GET /api/acks/{id}` via `api.getAck`. + * Cached 60 s — the underlying ack rows don't change after + * parse-time. + * - On 404: the hook's retry predicate short-circuits (no retries) + * so the drawer's not-found state appears immediately rather than + * being masked by three back-to-back retry attempts. + * + * The drawer accepts `ackId: string | null` (the URL keeps the id as + * a string for clean deep-link round-tripping) and does the + * `Number()` coercion here, matching how `useProviderDetail` + * (`string`) and `useRemitDetail` (`string`) already work. + */ +export function useAckDetail(ackId: string | null): { + data: AckDetail | null; + isLoading: boolean; + isError: boolean; + error: Error | null; + refetch: () => void; +} { + const q = useQuery({ + queryKey: ["ack-detail", ackId], + queryFn: () => api.getAck(Number(ackId)), + enabled: ackId !== null, + staleTime: 60 * 1000, + retry: (failureCount, error) => { + if (error instanceof ApiError && error.status === 404) return false; + return failureCount < 3; + }, + }); + + if (ackId === null) { + return { + data: null, + isLoading: false, + isError: false, + error: null, + refetch: () => {}, + }; + } + + return { + data: q.data ?? null, + isLoading: q.isLoading, + isError: q.isError, + error: q.error, + refetch: () => { + void q.refetch(); + }, + }; +}