From 3cfb2ac00cc9a87364fc17e9f0edbeec2ed807e2 Mon Sep 17 00:00:00 2001 From: SJCoetz Date: Wed, 29 Jul 2026 20:40:50 +0200 Subject: [PATCH] feat(buyer-tasks): extract send-po modal to shared component Co-authored-by: Orca --- src/app/(app)/orders/[id]/page.tsx | 405 +--------------- .../buyer-tasks/buyer-tasks-page.tsx | 24 +- .../purchase-orders/send-po-modal.test.tsx | 51 +++ .../purchase-orders/send-po-modal.tsx | 432 ++++++++++++++++++ 4 files changed, 520 insertions(+), 392 deletions(-) create mode 100644 src/components/purchase-orders/send-po-modal.test.tsx create mode 100644 src/components/purchase-orders/send-po-modal.tsx diff --git a/src/app/(app)/orders/[id]/page.tsx b/src/app/(app)/orders/[id]/page.tsx index 5072c75..326fe20 100644 --- a/src/app/(app)/orders/[id]/page.tsx +++ b/src/app/(app)/orders/[id]/page.tsx @@ -36,7 +36,6 @@ import { RefreshCw, History, Send, - Paperclip, } from "lucide-react"; import { Dialog, @@ -62,7 +61,7 @@ import { import { isOrderComplete } from "@/lib/server/order-completion"; import { ItemStatusPill } from "@/components/item-status-pill"; import { QuoteSearch } from "@/components/ui/quote-search"; -import { buildDefaultPoEmailBody } from "@/lib/server/po-email-template"; +import { SendPoModal } from "@/components/purchase-orders/send-po-modal"; import { AiInsightsPanel } from "@/components/ai-insights-panel"; import { AiItemHistoryTrigger } from "@/components/ai-item-history-trigger"; import type { Order, OrderItem, PurchaseOrder, Quote, ItemStatus } from "@/lib/types"; @@ -97,28 +96,12 @@ export default function OrderDetailPage({ params }: { params: Promise<{ id: stri // PO query below so the order detail page has everything in one read. // Regenerate modal — which PO + what reason. null = modal closed. const [regenPo, setRegenPo] = useState(null); + // Send PO modal — lifted to `src/components/purchase-orders/send-po-modal` + // so the buyer-tasks page can reuse it. We keep `sendPo` here as the + // gate (null = closed) and let the modal own its internal form state. const [sendPo, setSendPo] = useState(null); const [regenNotes, setRegenNotes] = useState(""); const [regenBusy, setRegenBusy] = useState(false); - const [sendBusy, setSendBusy] = useState(false); - const [sendTo, setSendTo] = useState(""); - const [sendCc, setSendCc] = useState(""); - const [sendBcc, setSendBcc] = useState(""); - const [sendSubject, setSendSubject] = useState(""); - const [sendMessage, setSendMessage] = useState(""); - // True while the modal is seeding the default body. We have to wait - // for the PO-level line count to come back from PB before we can - // 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. `attachmentUploading` - // covers both upload and delete — a single "working" flag is - // enough for the small panel. - const [attachments, setAttachments] = useState([]); - const [attachmentUploading, setAttachmentUploading] = useState(false); const [loading, setLoading] = useState(true); const [deleteOpen, setDeleteOpen] = useState(false); @@ -278,199 +261,6 @@ export default function OrderDetailPage({ params }: { params: Promise<{ id: stri } }; - // Open the Send PO modal pre-populated with the supplier's email + - // a sensible subject. CC/BCC blank. Body is seeded with the same - // generic template the route uses when the operator leaves it blank - // — the operator can edit it before sending, but the default is good - // enough that they can hit Send immediately. Seeding from the same - // source guarantees the previewed text matches what the supplier - // actually receives. - // - // The line count must come from THIS PO, not from the parent order - // (an order can have multiple POs with disjoint line sets). The - // `items` state on this page is order-level, so we fetch the PO's - // items on demand here. The textarea + Send button stay disabled - // until the count resolves, so the operator never sees a body that - // promises a different line count than the supplier will receive. - const openSendPo = async (po: PurchaseOrder) => { - setSendPo(po); - setSendTo(po.expand?.supplier?.email || ""); - setSendCc(""); - setSendBcc(""); - setSendSubject(`Purchase Order ${po.po_number}`); - setSendMessage(""); - setSendMessageLoading(true); - try { - const poItems = await getPocketBase() - .collection("order_items") - .getFullList({ - filter: `purchase_order = "${po.id}"`, - fields: "id", - }); - // Seed the message textarea with the supplier-personalised default. - // We deliberately pass ONLY the supplier-facing context (PO - // number, supplier name, line count). End-customer context is - // not surfaced here even if we have it — see the confidentiality - // note in `po-email-template.ts`. Operators wanting to add - // specifics can still type over the textarea before sending. - // - // The textarea is plain-text (operator can edit freely), so we - // seed it with the plain-text form of the default body. The - // route converts it to HTML at send time via the operator's - // current text — operators who want to preserve the rich - // formatting should not edit it; operators who want to type - // their own message get a clean single-paragraph wrap. - setSendMessage( - buildDefaultPoEmailBody({ - poNumber: po.po_number || po.id, - supplierName: po.expand?.supplier?.suppliername || "", - lineCount: poItems.length, - }).text - ); - } catch { - // Fall back to a generic body if the count lookup fails. The - // route still uses its own (correct) count at send time, so - // the worst case is the operator sees a slightly misleading - // preview — not a wrong email. - setSendMessage( - buildDefaultPoEmailBody({ - poNumber: po.po_number || po.id, - supplierName: po.expand?.supplier?.suppliername || "", - lineCount: 0, - }).text - ); - 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] : []); - } - }; - - const submitSendPo = async () => { - if (!sendPo) return; - setSendBusy(true); - try { - const res = await fetch( - `/api/v1/purchase-orders/${sendPo.id}/send-email`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - to: sendTo.trim(), - cc: sendCc.trim(), - bcc: sendBcc.trim(), - subject: sendSubject.trim(), - message: sendMessage, - }), - } - ); - const data = (await res.json().catch(() => ({}))) as { - failure?: { code?: string; message?: string }; - error?: string; - webhookFired?: boolean; - webhookError?: string; - supplierEmailUpdated?: boolean; - }; - if (res.status === 422 && data.failure) { - // Pre-send validation rejected (no PDF, bad email, no items…). - // The route already flipped the PO to mail_failed; surface that. - toast.error(`Send rejected: ${data.failure.message}`, { - description: `Code: ${data.failure.code}. PO is now mail_failed — retry from the PO list once the underlying issue is fixed.`, - }); - } else if (data.webhookFired === false) { - // The route accepted the send (status flipped to sending, email - // composition persisted) but the webhook fire FAILED. The route - // already flipped the PO back to mail_failed and recorded the - // reason — show the operator the real cause instead of a - // misleading "queued for send" success. - toast.error( - `PO queued but webhook didn't fire: ${data.webhookError ?? "unknown reason"}`, - { - description: - "Status reverted to mail_failed. The cron pass will retry, or fix the webhook URL and click Send again.", - } - ); - } else if (!res.ok) { - throw new Error(data.error || `HTTP ${res.status}`); - } else { - const recipient = sendTo.trim(); - const supplierUpdateNote = data.supplierEmailUpdated - ? " Updated the supplier email to match." - : ""; - toast.success( - `${sendPo.po_number} queued for send to ${recipient}.${supplierUpdateNote}`, - { - description: - "SMTP delivery happens asynchronously. The PO status will flip to Sent when the email is delivered.", - } - ); - } - setSendPo(null); - await fetchOrder(); - } catch (err: unknown) { - toast.error(err instanceof Error ? err.message : "Send failed"); - } finally { - setSendBusy(false); - } - }; - - // ─── 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); @@ -927,7 +717,7 @@ export default function OrderDetailPage({ params }: { params: Promise<{ id: stri