feat(sp8): Claim drawer Download 837 button
Three pieces:
- src/lib/download.ts: generic downloadTextFile(filename, mime, text)
helper. Mirrors csv.ts:downloadCsv but takes an explicit MIME type and
drops the BOM prepend (which would corrupt the ISA segment).
- src/lib/api.ts: serializeClaim837(id) → {text, filename}. Fetches
GET /api/claims/{id}/serialize-837, pulls the suggested filename from
Content-Disposition (falls back to claim-{id}.x12 if the header is
missing). Throws ApiError on non-2xx so callers can branch on .status.
- ClaimDrawerHeader: Download icon button between the amount and the
close button. Click → api.serializeClaim837 → downloadTextFile.
Disabled + 'Downloading 837 file' aria-label while the fetch is in
flight so the click feels responsive. Optional onError prop surfaces
fetch failures; defaults to a no-op so existing callers stay clean.
Tests: 3 download.test.ts, 3 api.test.ts, 2 header.test.ts (happy
path + error path). Frontend: 350 passing (+8 from 342).
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
import { X } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Download, X } from "lucide-react";
|
||||
import { Badge, type BadgeProps } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { api } from "@/lib/api";
|
||||
import { downloadTextFile } from "@/lib/download";
|
||||
import { fmt } from "@/lib/format";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ClaimDetail } from "@/types";
|
||||
@@ -8,6 +11,13 @@ 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;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -44,7 +54,32 @@ function badgeVariantFor(state: string): BadgeProps["variant"] {
|
||||
* badge color encodes state so the user can read the drawer's status
|
||||
* at a glance without scrolling.
|
||||
*/
|
||||
export function ClaimDrawerHeader({ claim, onClose }: ClaimDrawerHeaderProps) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
@@ -68,8 +103,8 @@ export function ClaimDrawerHeader({ claim, onClose }: ClaimDrawerHeaderProps) {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Right: state badge + total amount + close */}
|
||||
<div className="flex items-start gap-4">
|
||||
{/* Right: state badge + total amount + download + close */}
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<Badge
|
||||
variant={badgeVariantFor(claim.state)}
|
||||
@@ -86,6 +121,17 @@ export function ClaimDrawerHeader({ claim, onClose }: ClaimDrawerHeaderProps) {
|
||||
{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>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
|
||||
Reference in New Issue
Block a user