src/features/reviews/mail-attach.tsx
Linked repository file from this build.
"use client";
import { useRef, useState } from "react";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { fieldClass, fieldMultilineClass } from "@/components/ui/input";
import { useReview } from "./provider";
import type { StoredReview } from "./review-store";
import { buildMailedDocument, postReviewCommand } from "./search.helpers";
/**
* Admin mail desk cell: attach incoming PDFs to one review.
* Each file is named, uploaded and saved in turn; a failure stops the rest.
*/
export function MailAttachCell({
row,
}: {
row: { reviewKey: string; reviewNumber: string; fiscalYear: number };
}) {
const { storage } = useReview();
const fileInput = useRef<HTMLInputElement>(null);
const [pending, setPending] = useState(false);
const [selection, setSelection] = useState<{ file: File; name: string; description: string }[]>(
[],
);
const [error, setError] = useState("");
const [saved, setSaved] = useState("");
async function attach(files: typeof selection) {
if (!files.length || pending) return;
if (files.some((item) => !item.name.trim() || !item.description.trim())) {
setError("Enter a name and purpose for every PDF.");
return;
}
setPending(true);
setError("");
setSaved("");
let attached = 0;
let filename = "";
try {
for (const { file, name, description } of files) {
filename = file.name;
// Fetch only this review at the point of mutation, for its current version.
const response = await fetch(`/api/reviews/${row.reviewKey}`, { cache: "no-store" });
const data = await response.json();
if (!response.ok) throw new Error(data.error || "Could not load this review.");
const current = data as StoredReview;
const storageKey = await storage.put(file);
const result = await postReviewCommand(row.reviewKey, current.version, {
type: "document",
document: buildMailedDocument(file, storageKey, { name, description }),
});
if (!result.ok)
throw new Error(
result.conflict
? "This review changed while the PDF was uploading. Retry the remaining files."
: result.error,
);
attached++;
setSaved(`${attached} PDF${attached === 1 ? "" : "s"} attached.`);
}
} catch (cause) {
setError(
`${filename}: ${cause instanceof Error ? cause.message : "Could not attach this PDF."} Remaining files were not attached.`,
);
} finally {
setSelection(files.slice(attached));
setPending(false);
if (fileInput.current) fileInput.current.value = "";
}
}
return (
<td className="px-3 py-1">
<input
ref={fileInput}
type="file"
multiple
accept="application/pdf,.pdf"
aria-label={`Mailed PDFs for review ${row.reviewNumber}, FFY ${row.fiscalYear}`}
className="sr-only"
tabIndex={-1}
disabled={pending}
onChange={(event) => {
setError("");
setSaved("");
setSelection(
Array.from(event.target.files ?? []).map((file) => ({
file,
name: file.name,
description: "",
})),
);
}}
/>
<button
type="button"
className="rounded border px-2 text-xs leading-5 hover:bg-muted disabled:opacity-60"
disabled={pending}
onClick={() => fileInput.current?.click()}
>
{pending ? "Attaching…" : "Attach PDFs"}
</button>
<Sheet
open={selection.length > 0}
onOpenChange={(open) => {
if (!open && !pending) setSelection([]);
}}
>
<SheetContent className="w-full overflow-y-auto sm:max-w-xl">
<SheetHeader>
<SheetTitle>Name the incoming documents</SheetTitle>
<SheetDescription>
Review {row.reviewNumber} · FFY {row.fiscalYear} · Add a name and a short purpose for
each PDF before attaching.
</SheetDescription>
</SheetHeader>
<form
className="space-y-4 px-4 pb-4"
onSubmit={(event) => {
event.preventDefault();
void attach(selection);
}}
>
{error && (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
)}
{saved && (
<p role="status" className="text-sm">
{saved}
</p>
)}
{selection.map((item, index) => (
<fieldset
key={`${item.file.name}-${index}`}
disabled={pending}
className="space-y-3 rounded-lg border p-3"
>
<legend className="max-w-full break-all px-1 text-xs text-muted-foreground">
{item.file.name}
</legend>
<label className="grid gap-1 text-sm">
Document name
<input
required
aria-label={`Name for ${item.file.name}`}
className={fieldClass}
value={item.name}
onChange={(event) =>
setSelection((items) =>
items.map((entry, i) =>
i === index ? { ...entry, name: event.target.value } : entry,
),
)
}
/>
</label>
<label className="grid gap-1 text-sm">
Purpose
<textarea
required
rows={2}
aria-label={`Purpose for ${item.file.name}`}
placeholder="What does this document establish for the review?"
className={fieldMultilineClass}
value={item.description}
onChange={(event) =>
setSelection((items) =>
items.map((entry, i) =>
i === index ? { ...entry, description: event.target.value } : entry,
),
)
}
/>
</label>
</fieldset>
))}
<div className="flex justify-end gap-2">
<button
type="button"
disabled={pending}
className="rounded-md border px-3 py-2 text-sm"
onClick={() => setSelection([])}
>
Cancel selection
</button>
<button
type="submit"
disabled={pending}
className="rounded-md bg-primary px-3 py-2 text-sm font-medium text-primary-foreground disabled:opacity-50"
>
{pending ? "Attaching…" : "Attach selected PDFs"}
</button>
</div>
</form>
</SheetContent>
</Sheet>
{error && (
<p role="alert" className="max-w-64 whitespace-normal text-xs text-destructive">
{error}
</p>
)}
{saved && (
<p role="status" className="text-xs">
{saved}
</p>
)}
</td>
);
}