feat(drill): AckDrawer with header + segment status list

This commit is contained in:
Tyler
2026-06-21 17:26:03 -06:00
parent 6fdbceefc2
commit 33fa899217
6 changed files with 781 additions and 9 deletions
+192
View File
@@ -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(<AckDrawer ackId={null} onClose={() => {}} />);
// 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(<AckDrawer ackId="42" onClose={() => {}} />);
expect(useAckDetail).toHaveBeenCalledWith("42");
});
it("test_renders_ack_summary_on_success", () => {
mockDetail({ data: SAMPLE_ACK });
render(<AckDrawer ackId="42" onClose={() => {}} />);
// 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(<AckDrawer ackId="42" onClose={() => {}} />);
// 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(<AckDrawer ackId="42" onClose={() => {}} />);
// 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(<AckDrawer ackId="9999" onClose={() => {}} />);
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(<AckDrawer ackId="42" onClose={() => {}} />);
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(<AckDrawer ackId="42" onClose={onClose} />);
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(<AckDrawer ackId="42" onClose={() => {}} />);
// 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(<AckDrawer ackId="42" onClose={() => {}} />);
// No raw_999_text → no Download button.
expect(document.querySelector('[data-testid="ack-drawer-download"]')).toBeNull();
});
});
+324
View File
@@ -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 (
<div
className="flex h-full flex-col"
role="alert"
data-testid={`ack-drawer-error-${kind}`}
>
<DrillDrawerHeader eyebrow="999 ACK" title="—" onClose={onClose} />
<div className="flex flex-1 flex-col items-start gap-4 px-6 py-6">
<span className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-destructive">
{eyebrow}
</span>
<p className="text-sm text-muted-foreground max-w-sm">{message}</p>
<div className="flex items-center gap-2">
{kind === "network" && onRetry ? (
<button
type="button"
onClick={onRetry}
data-testid="error-retry"
className="rounded-md border px-2.5 py-1 text-[12.5px] font-medium hover:bg-muted"
>
Retry
</button>
) : null}
<button
type="button"
onClick={onClose}
data-testid="error-close"
className="rounded-md border px-2.5 py-1 text-[12.5px] font-medium hover:bg-muted"
>
Close
</button>
</div>
</div>
</div>
);
}
/**
* 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 (
<span
className="inline-flex items-center rounded-sm border px-2 py-0.5 mono text-[10.5px] font-semibold uppercase tracking-[0.14em]"
style={{ color: cfg.fg, backgroundColor: cfg.bg, borderColor: cfg.border }}
>
{cfg.label}
</span>
);
}
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 (
<div
className="rounded-lg border px-3.5 py-3"
style={{
backgroundColor: "hsl(var(--card) / 0.4)",
borderColor: "hsl(var(--border) / 0.5)",
}}
data-testid="ack-stat"
>
<div className="text-[10px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
{label}
</div>
<div
className="display mono tabular-nums mt-1"
style={{ color, fontSize: 22, lineHeight: 1.1 }}
>
{value}
</div>
</div>
);
}
/**
* 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 ? (
<button
type="button"
onClick={onDownload}
disabled={downloading}
aria-label="Download 999 file"
title="Download 999 file"
data-testid="ack-drawer-download"
className={cn(
"inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-[11px] font-medium uppercase tracking-[0.14em] mono transition-colors",
downloading && "opacity-50 cursor-not-allowed",
)}
style={{
borderColor: "hsl(var(--border) / 0.7)",
color: "hsl(var(--foreground))",
backgroundColor: "hsl(var(--card) / 0.5)",
}}
>
<Download className="h-3.5 w-3.5" strokeWidth={1.75} aria-hidden />
999
</button>
) : null;
return (
<Dialog open={ackId !== null} onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent
className="fixed right-0 top-0 flex h-full w-full max-w-2xl flex-col translate-x-0 translate-y-0 rounded-none border-l border-border bg-card p-0"
aria-describedby={undefined}
data-testid="ack-drawer"
>
{errorKind ? (
<AckDrawerError
kind={errorKind}
onRetry={() => {
void refetch();
}}
onClose={onClose}
/>
) : isLoading || !data ? (
<div className="flex h-full flex-col overflow-y-auto">
<DrillDrawerHeader
eyebrow="999 ACK"
title="Loading…"
onClose={onClose}
/>
<div className="space-y-2 p-6">
<Skeleton variant="row" />
<Skeleton variant="row" />
<Skeleton variant="row" />
</div>
</div>
) : (
<div
className="flex h-full flex-col overflow-y-auto"
data-testid="ack-drawer-content"
>
<DrillDrawerHeader
eyebrow="999 ACK"
title={data.sourceBatchId}
onClose={onClose}
action={downloadAction}
/>
<div className="flex flex-col divide-y divide-border/40">
<section
className="flex flex-col gap-4 px-6 py-4"
data-testid="ack-summary"
>
<div className="flex items-center gap-3 flex-wrap">
<AckCodePill code={data.ackCode} />
<span className="mono text-[12px] text-muted-foreground">
ID {data.id}
</span>
<span className="mono text-[12px] text-muted-foreground">
· {data.parsedAt ? data.parsedAt.slice(0, 10) : "—"}
</span>
</div>
<div className="grid grid-cols-3 gap-3">
<StatTile
label="Accepted"
value={data.acceptedCount}
tone="success"
/>
<StatTile
label="Rejected"
value={data.rejectedCount}
tone="destructive"
/>
<StatTile
label="Received"
value={data.receivedCount}
tone="ink"
/>
</div>
</section>
<SegmentStatusList segments={[]} />
</div>
</div>
)}
</DialogContent>
</Dialog>
);
}
@@ -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 (
<span
className="inline-flex items-center gap-1.5 rounded-sm border px-2 py-0.5 mono text-[10.5px] font-semibold uppercase tracking-[0.14em]"
style={{ color: cfg.fg, backgroundColor: cfg.bg, borderColor: cfg.border }}
>
<Icon className="h-3 w-3" strokeWidth={1.75} aria-hidden />
{cfg.label}
</span>
);
}
/**
* 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 (
<section
className="flex flex-col gap-3 px-6 py-4"
data-testid="ack-segment-list"
>
<div className="flex items-baseline justify-between gap-3">
<span className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Segment status
</span>
<span
className="mono text-[10.5px] text-muted-foreground"
data-testid="ack-segment-count"
>
{segments.length} segment{segments.length === 1 ? "" : "s"}
</span>
</div>
{segments.length === 0 ? (
<p
className="text-[12.5px] text-muted-foreground"
data-testid="ack-segment-empty"
>
No per-segment breakdown for this ack the rolled-up accepted
/ rejected counts above are the only signal.
</p>
) : (
<ul className="flex flex-col divide-y divide-border/40 border-y border-border/30">
{segments.map((seg, idx) => (
<li
key={`${seg.reference ?? idx}`}
className="flex items-start gap-3 py-2.5"
data-testid="ack-segment-row"
>
<span
className="mono text-[12px] tabular-nums text-muted-foreground shrink-0"
style={{ minWidth: 32 }}
>
{idx + 1}
</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<StatusPill status={seg.status} />
{seg.reference ? (
<span className="mono text-[12px] truncate">
{seg.reference}
</span>
) : null}
</div>
{seg.note ? (
<p className="text-[12.5px] text-muted-foreground mt-1 leading-snug">
{seg.note}
</p>
) : null}
</div>
</li>
))}
</ul>
)}
</section>
);
}
+4
View File
@@ -0,0 +1,4 @@
// Barrel export for the AckDrawer module (SP21 Phase 5 Task 5.2).
export { AckDrawer } from "./AckDrawer";
export { SegmentStatusList } from "./SegmentStatusList";
+14 -1
View File
@@ -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 (
<div className="flex items-center justify-between border-b border-border/30 px-6 py-4">
<div>
@@ -24,6 +34,8 @@ export function DrillDrawerHeader({ eyebrow, title, onClose }: Props) {
</div>
<h2 className="text-[18px] font-semibold tracking-tight mt-0.5">{title}</h2>
</div>
<div className="flex items-center gap-2">
{action}
<button
type="button"
onClick={onClose}
@@ -33,5 +45,6 @@ export function DrillDrawerHeader({ eyebrow, title, onClose }: Props) {
<X className="h-4 w-4" aria-hidden />
</button>
</div>
</div>
);
}
+89
View File
@@ -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<AckDetail>({
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();
},
};
}