feat(drill): AckDrawer with header + segment status list
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// Barrel export for the AckDrawer module (SP21 Phase 5 Task 5.2).
|
||||
|
||||
export { AckDrawer } from "./AckDrawer";
|
||||
export { SegmentStatusList } from "./SegmentStatusList";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user