325 lines
10 KiB
TypeScript
325 lines
10 KiB
TypeScript
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>
|
|
);
|
|
}
|