135 lines
4.4 KiB
TypeScript
135 lines
4.4 KiB
TypeScript
import { useState } from "react";
|
|
import { Download } from "lucide-react";
|
|
import { Badge, type BadgeProps } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { DrillDrawerHeader } from "@/components/drill/DrillDrawerHeader";
|
|
import { api } from "@/lib/api";
|
|
import { downloadTextFile } from "@/lib/download";
|
|
import { fmt } from "@/lib/format";
|
|
import type { ClaimDetail } from "@/types";
|
|
|
|
type ClaimDrawerHeaderProps = {
|
|
claim: ClaimDetail;
|
|
onClose: () => void;
|
|
/**
|
|
* Optional toast callback for surfacing download failures (network error,
|
|
* 404 if the claim was deleted out from under us, 422 if the stored
|
|
* payload can't be regenerated). Defaults to a no-op so consumers that
|
|
* don't wire a toast still get the happy-path download.
|
|
*/
|
|
onError?: (message: string) => void;
|
|
};
|
|
|
|
/**
|
|
* State → Badge variant. The state machine value (see
|
|
* `cyclone.db.ClaimState`) drives a color-coded chip so the user can
|
|
* scan the drawer's status at a glance:
|
|
*
|
|
* submitted → secondary (neutral, in-flight)
|
|
* accepted → default (brand-colored, accepted by payer)
|
|
* matched → success (paired with an ERA)
|
|
* paid → success (funds received)
|
|
* denied → destructive (needs attention)
|
|
* pending → warning (waiting on something)
|
|
* anything else → muted
|
|
*/
|
|
const STATE_VARIANT: Record<string, BadgeProps["variant"]> = {
|
|
submitted: "secondary",
|
|
accepted: "default",
|
|
matched: "success",
|
|
paid: "success",
|
|
denied: "destructive",
|
|
pending: "warning",
|
|
};
|
|
|
|
function badgeVariantFor(state: string): BadgeProps["variant"] {
|
|
return STATE_VARIANT[state] ?? "muted";
|
|
}
|
|
|
|
/**
|
|
* Header band for the claim detail drawer (SP4 → refactored SP21
|
|
* Phase 5 Task 5.10).
|
|
*
|
|
* The shell is now the shared `DrillDrawerHeader` (same as
|
|
* `ProviderDrawer` / `AckDrawer`) — eyebrow + title on the left,
|
|
* close button on the right. The right-side `action` slot carries
|
|
* the state badge, total billed amount, and the "Download 837"
|
|
* button, all of which used to live in a custom <header> block.
|
|
*
|
|
* Top-left: instrument-style "Claim" eyebrow + the claim ID.
|
|
* Top-right: state badge + total billed amount + download button.
|
|
* The badge color encodes state so the user can read the drawer's
|
|
* status at a glance without scrolling.
|
|
*/
|
|
export function ClaimDrawerHeader({
|
|
claim,
|
|
onClose,
|
|
onError,
|
|
}: ClaimDrawerHeaderProps) {
|
|
// Local "is downloading" flag so the button can show feedback while the
|
|
// fetch is in flight. The header is rendered above the fold, so a stuck
|
|
// spinner is the difference between "the click did something" and "did
|
|
// my click even register?"
|
|
const [downloading, setDownloading] = useState(false);
|
|
|
|
async function handleDownload() {
|
|
if (downloading) return;
|
|
setDownloading(true);
|
|
try {
|
|
const { text, filename } = await api.serializeClaim837(claim.id);
|
|
downloadTextFile(filename, "text/x12", text);
|
|
} catch (err) {
|
|
const message =
|
|
err instanceof Error ? err.message : "Failed to download 837 file.";
|
|
onError?.(message);
|
|
} finally {
|
|
setDownloading(false);
|
|
}
|
|
}
|
|
|
|
// The action slot is rendered by DrillDrawerHeader to the left of
|
|
// the close button. Group the three action pieces (badge, amount,
|
|
// download) in a single flex row so they read as a unit.
|
|
const action = (
|
|
<div className="flex items-center gap-2" data-testid="claim-header-actions">
|
|
<div className="flex items-center gap-2">
|
|
<Badge
|
|
variant={badgeVariantFor(claim.state)}
|
|
data-testid="header-state"
|
|
className="uppercase tracking-[0.14em]"
|
|
>
|
|
{claim.stateLabel}
|
|
</Badge>
|
|
<span
|
|
data-testid="header-amount"
|
|
className="mono text-sm tabular-nums text-muted-foreground"
|
|
>
|
|
{fmt.usdPrecise(claim.billedAmount)}
|
|
</span>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={handleDownload}
|
|
disabled={downloading}
|
|
aria-label={downloading ? "Downloading 837 file" : "Download 837 file"}
|
|
title="Download 837 file"
|
|
data-testid="header-download-837"
|
|
>
|
|
<Download className="h-4 w-4" strokeWidth={1.75} />
|
|
</Button>
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<header data-testid="claim-drawer-header">
|
|
<DrillDrawerHeader
|
|
eyebrow="Claim"
|
|
title={claim.id}
|
|
onClose={onClose}
|
|
action={action}
|
|
/>
|
|
</header>
|
|
);
|
|
}
|