feat(orders): add additional attachments panel to Send PO modal

This commit is contained in:
SJCoetz 2026-07-09 23:33:56 +02:00
parent 2b3b50596c
commit 14e1a12cfe

View file

@ -36,6 +36,7 @@ import {
RefreshCw,
History,
Send,
Paperclip,
} from "lucide-react";
import {
Dialog,
@ -108,6 +109,13 @@ export default function OrderDetailPage({ params }: { params: Promise<{ id: stri
// build a non-misleading body, so the textarea is disabled until
// the count is resolved.
const [sendMessageLoading, setSendMessageLoading] = useState(false);
// Additional attachments (supplier quote, drawing, NDA, etc.) sent
// alongside the PO PDF. PB stores these as file tokens in
// `po.additional_attachments`; the modal reads them on open and
// writes back via the upload/delete routes.
const [attachments, setAttachments] = useState<string[]>([]);
const [attachmentsLoading, setAttachmentsLoading] = useState(false);
const [attachmentUploading, setAttachmentUploading] = useState(false);
const [loading, setLoading] = useState(true);
const [deleteOpen, setDeleteOpen] = useState(false);
@ -331,6 +339,12 @@ export default function OrderDetailPage({ params }: { params: Promise<{ id: stri
toast.error("Couldn't load line count for preview; check the PO before sending.");
} finally {
setSendMessageLoading(false);
// Seed the attachments list from the PO record that just opened
// the modal. PB returns a multi-file field as either an array
// or (for legacy single-file rows) a bare string — normalise
// to string[] so the rest of the modal can treat it uniformly.
const rawAtt = po.additional_attachments ?? [];
setAttachments(Array.isArray(rawAtt) ? rawAtt : rawAtt ? [rawAtt] : []);
}
};
@ -402,6 +416,58 @@ export default function OrderDetailPage({ params }: { params: Promise<{ id: stri
}
};
// ─── Additional attachments handlers ──────────────────────────
// Upload one or more files to `po.additional_attachments` via the
// dedicated route. We refresh `attachments` from each successful
// response so the list always reflects the canonical PB state.
const uploadAttachments = async (files: FileList | null) => {
if (!sendPo || !files || files.length === 0) return;
setAttachmentUploading(true);
try {
for (const file of Array.from(files)) {
const fd = new FormData();
fd.append("file", file);
const res = await fetch(
`/api/v1/purchase-orders/${sendPo.id}/attachments/upload`,
{ method: "POST", body: fd },
);
if (!res.ok) {
const body = await res.json().catch(() => ({}));
toast.error(
`Upload failed for ${file.name}: ${body.error ?? res.status}`,
);
continue;
}
const data = (await res.json()) as { attachments: string[] };
setAttachments(data.attachments ?? []);
}
} finally {
setAttachmentUploading(false);
}
};
// Remove a single attachment by its PB token. The token is what
// appears in `additional_attachments[]`; it may differ from the
// operator's original filename because PB slug-normalises on disk.
const removeAttachment = async (token: string) => {
if (!sendPo) return;
setAttachmentUploading(true);
try {
const res = await fetch(
`/api/v1/purchase-orders/${sendPo.id}/attachments/${encodeURIComponent(token)}`,
{ method: "DELETE" },
);
if (!res.ok) {
toast.error("Couldn't remove attachment");
return;
}
const data = (await res.json()) as { attachments: string[] };
setAttachments(data.attachments ?? []);
} finally {
setAttachmentUploading(false);
}
};
// Order-level "Complete" — derived from items, not stored anywhere.
const complete = isOrderComplete(items);
@ -1100,6 +1166,59 @@ export default function OrderDetailPage({ params }: { params: Promise<{ id: stri
: `${sendMessage.length}/2000`}
</p>
</div>
<div className="space-y-2 rounded-md border p-3">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">Additional attachments</p>
<p className="text-xs text-muted-foreground">
Files sent alongside the PO PDF (supplier quotes, drawings, etc.).
</p>
</div>
<label className="inline-flex cursor-pointer items-center gap-1.5 rounded-md border bg-background px-2 py-1 text-xs hover:bg-accent">
<Paperclip className="h-3 w-3" />
{attachmentUploading ? "Uploading…" : "Attach files"}
<input
type="file"
multiple
className="hidden"
onChange={(e) => uploadAttachments(e.target.files)}
disabled={attachmentUploading}
/>
</label>
</div>
{attachments.length === 0 ? (
<p className="text-xs text-muted-foreground">No additional files attached.</p>
) : (
<ul className="space-y-1">
{attachments.map((token) => (
<li
key={token}
className="flex items-center justify-between gap-2 rounded border bg-muted/30 px-2 py-1 text-xs"
>
<a
href={sendPo ? `/api/v1/files/purchase_orders/${sendPo.id}/${encodeURIComponent(token)}` : "#"}
target="_blank"
rel="noopener noreferrer"
className="truncate text-foreground hover:underline"
>
{token}
</a>
<Button
type="button"
variant="ghost"
size="icon"
className="h-5 w-5 text-muted-foreground hover:text-destructive"
onClick={() => removeAttachment(token)}
disabled={attachmentUploading}
aria-label={`Remove ${token}`}
>
<Trash2 className="h-3 w-3" />
</Button>
</li>
))}
</ul>
)}
</div>
{sendPo?.status === "mail_failed" && sendPo?.email_error && (
<div className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive">
<span className="font-medium">Previous failure:</span>{" "}