diff --git a/src/app/(app)/orders/[id]/page.tsx b/src/app/(app)/orders/[id]/page.tsx index ef0c60a..afb5a25 100644 --- a/src/app/(app)/orders/[id]/page.tsx +++ b/src/app/(app)/orders/[id]/page.tsx @@ -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([]); + 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`}

+
+
+
+

Additional attachments

+

+ Files sent alongside the PO PDF (supplier quotes, drawings, etc.). +

+
+ +
+ {attachments.length === 0 ? ( +

No additional files attached.

+ ) : ( + + )} +
{sendPo?.status === "mail_failed" && sendPo?.email_error && (
Previous failure:{" "}