feat(buyer-tasks): extract send-po modal to shared component

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
SJCoetz 2026-07-29 20:40:50 +02:00
parent 1486e10bbe
commit 3cfb2ac00c
4 changed files with 520 additions and 392 deletions

View file

@ -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<PurchaseOrder | null>(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<PurchaseOrder | null>(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<string[]>([]);
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<OrderItem>({
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
<Button
variant="default"
size="sm"
onClick={() => openSendPo(po)}
onClick={() => setSendPo(po)}
title={
po.status === "mail_failed"
? "Retry send to supplier"
@ -1122,183 +912,18 @@ export default function OrderDetailPage({ params }: { params: Promise<{ id: stri
</Dialog>
{/* ─── Send PO email modal ─────────────────────────────────── */}
{/* Pre-send composition modal. The supplier's email is prefilled
(read-only — override via the "To" field if needed). The
operator edits subject/body and optionally adds CC/BCC.
Submitting POSTs to /api/v1/purchase-orders/[id]/send-email,
which validates and fires the n8n webhook. We don't wait for
SMTP — the cron pass + n8n's first node handle state
transitions and write-back. */}
<Dialog
{/* Shared modal — see `src/components/purchase-orders/send-po-modal.tsx`.
We gate visibility with `sendPo !== null` and refresh the order
after a successful send so the status badge updates immediately. */}
<SendPoModal
open={sendPo !== null}
onOpenChange={(open) => {
if (!open && !sendBusy) {
setSendPo(null);
setSendMessageLoading(false);
}
purchaseOrder={sendPo}
onClose={() => setSendPo(null)}
onSent={async () => {
setSendPo(null);
await fetchOrder();
}}
>
<DialogContent className="sm:max-w-3xl max-h-[90vh] grid-rows-[auto_1fr_auto] gap-0 p-0 overflow-hidden">
<DialogHeader className="px-6 pt-6 pb-4 border-b">
<DialogTitle>
Send {sendPo?.po_number ?? "PO"} to{" "}
{sendPo?.expand?.supplier?.suppliername ?? "supplier"}
</DialogTitle>
<DialogDescription>
The supplier will receive the current PO PDF as an
attachment. Replies and acknowledgements will appear in the
Supplier Email section once the IMAP poller is wired up.
</DialogDescription>
</DialogHeader>
{/* Body — scrolls independently. On wide screens the delivery
config lives on the left and the message + attachments
live on the right; on small screens everything stacks. */}
<div className="grid gap-6 px-6 py-4 overflow-y-auto lg:grid-cols-[minmax(0,1fr)_minmax(0,1.1fr)]">
<div className="space-y-3">
<div className="space-y-1.5">
<Label htmlFor="send-to">To</Label>
<Input
id="send-to"
value={sendTo}
onChange={(e) => setSendTo(e.target.value)}
placeholder="supplier@example.com"
autoFocus
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="send-cc">CC</Label>
<Input
id="send-cc"
value={sendCc}
onChange={(e) => setSendCc(e.target.value)}
placeholder="optional"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="send-bcc">BCC</Label>
<Input
id="send-bcc"
value={sendBcc}
onChange={(e) => setSendBcc(e.target.value)}
placeholder="optional"
/>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="send-subject">Subject</Label>
<Input
id="send-subject"
value={sendSubject}
onChange={(e) => setSendSubject(e.target.value)}
placeholder={`Purchase Order ${sendPo?.po_number ?? ""}`}
/>
</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>{" "}
{sendPo.email_error}
</div>
)}
</div>
<div className="space-y-3">
<div className="space-y-1.5">
<Label htmlFor="send-message">Message</Label>
<Textarea
id="send-message"
value={sendMessage}
onChange={(e) => setSendMessage(e.target.value)}
placeholder="Hi Supplier, please see attached PO. Confirm receipt and ETA. Thanks — WFDash Orders"
rows={8}
maxLength={2000}
disabled={sendMessageLoading}
className="min-h-32"
/>
<p className="text-xs text-muted-foreground">
{sendMessageLoading
? "Loading line count for preview…"
: `${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>
</div>
</div>
<DialogFooter className="px-6 py-4 border-t">
<Button
variant="outline"
onClick={() => setSendPo(null)}
disabled={sendBusy}
>
Cancel
</Button>
<Button
onClick={submitSendPo}
disabled={
sendBusy ||
sendMessageLoading ||
!sendTo.trim() ||
!sendSubject.trim()
}
>
{sendBusy && <Loader2 className="mr-1.5 h-4 w-4 animate-spin" />}
<Send className="mr-1.5 h-4 w-4" />
{sendPo?.status === "mail_failed" ? "Resend" : "Send"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
/>
</div>
);
}

View file

@ -6,6 +6,7 @@ import { toast } from "sonner";
import { BuyerTaskTabs, type BuyerTaskTab } from "./buyer-task-tabs";
import { BuyerTaskGroupCard } from "./buyer-task-group-card";
import { MarkReadyModal } from "./mark-ready-modal";
import { SendPoModal } from "@/components/purchase-orders/send-po-modal";
import type { BuyerTaskGroup } from "@/lib/server/buyer-tasks-service";
const EMPTY_COUNTS: Record<BuyerTaskTab, number> = {
@ -20,6 +21,9 @@ export function BuyerTasksPage() {
const [loading, setLoading] = useState(false);
const [busyGroupId, setBusyGroupId] = useState<string | null>(null);
const [markReadyFor, setMarkReadyFor] = useState<BuyerTaskGroup | null>(null);
// PO we're about to send. The shared SendPoModal owns its own form
// state; we just gate visibility with `sendPoFor !== null`.
const [sendPoFor, setSendPoFor] = useState<BuyerTaskGroup | null>(null);
const fetchTab = useCallback(async (t: BuyerTaskTab) => {
setLoading(true);
@ -86,8 +90,13 @@ export function BuyerTasksPage() {
setBusyGroupId(null);
}
} else if (group.step === "po_draft") {
// Send PO uses the shared modal — handled in Task 13.
toast.info("Send PO: wired in Task 13");
// Open the shared send-PO modal with the group's PO. The modal
// owns its form state and POSTs to the same send-email route the
// order detail page uses. We pass a minimal PurchaseOrder stub
// with just the id — the modal reads its own PO via the API
// when needed (supplier email, status, attachments are seeded
// lazily inside the modal).
setSendPoFor(group);
} else if (group.step === "po_sent") {
setMarkReadyFor(group);
}
@ -147,6 +156,17 @@ export function BuyerTasksPage() {
onCancel={() => setMarkReadyFor(null)}
busy={false}
/>
{sendPoFor?.purchaseOrderId && (
<SendPoModal
open={sendPoFor !== null}
purchaseOrder={{ id: sendPoFor.purchaseOrderId } as never}
onClose={() => setSendPoFor(null)}
onSent={() => {
setSendPoFor(null);
void fetchTab(tab);
}}
/>
)}
</div>
);
}

View file

@ -0,0 +1,51 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { SendPoModal } from "./send-po-modal";
const basePo = {
id: "po-1",
status: "draft",
order: "order-1",
supplier: "sup-1",
documents: [],
};
describe("SendPoModal", () => {
it("renders when open", () => {
render(
<SendPoModal
open
purchaseOrder={basePo as never}
onClose={vi.fn()}
onSent={vi.fn()}
/>
);
expect(screen.getByText(/send purchase order/i)).toBeInTheDocument();
});
it("calls onClose when cancel is clicked", () => {
const onClose = vi.fn();
render(
<SendPoModal
open
purchaseOrder={basePo as never}
onClose={onClose}
onSent={vi.fn()}
/>
);
fireEvent.click(screen.getByRole("button", { name: /cancel/i }));
expect(onClose).toHaveBeenCalledOnce();
});
it("does not render when closed", () => {
render(
<SendPoModal
open={false}
purchaseOrder={basePo as never}
onClose={vi.fn()}
onSent={vi.fn()}
/>
);
expect(screen.queryByText(/send purchase order/i)).not.toBeInTheDocument();
});
});

View file

@ -0,0 +1,432 @@
"use client";
import { useEffect, useState } from "react";
import { Loader2, Paperclip, Send, Trash2 } from "lucide-react";
import { toast } from "sonner";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import getPocketBase from "@/lib/pocketbase";
import { buildDefaultPoEmailBody } from "@/lib/server/po-email-template";
import type { OrderItem, PurchaseOrder } from "@/lib/types";
type Props = {
open: boolean;
purchaseOrder: PurchaseOrder | null;
onClose: () => void;
onSent: () => void;
};
/**
* Send-PO email composition modal. Lifted verbatim from the order
* detail page so it can be reused by the buyer-tasks page. The
* supplier email is prefilled (override via the "To" field if
* needed). Operator edits subject/body and optionally adds CC/BCC.
* Submitting POSTs to /api/v1/purchase-orders/[id]/send-email,
* which validates and fires the n8n webhook. We don't wait for
* SMTP — the cron pass + n8n's first node handle state
* transitions and write-back.
*
* Behaviour preserved from the order detail inline modal:
* - line-count-aware default body (loads async on open)
* - mail_failed banner with previous error
* - additional attachments (upload/delete) via the dedicated route
* - toast surfaces the route's failure / webhook-failure paths
*/
export function SendPoModal({ open, purchaseOrder, onClose, onSent }: Props) {
const [busy, setBusy] = useState(false);
const [to, setTo] = useState("");
const [cc, setCc] = useState("");
const [bcc, setBcc] = useState("");
const [subject, setSubject] = useState("");
const [message, setMessage] = 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 [messageLoading, setMessageLoading] = 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<string[]>([]);
const [attachmentUploading, setAttachmentUploading] = useState(false);
// Seed modal state whenever a new PO is opened. Idempotent — we
// only re-seed when the PO id changes so the operator's mid-edit
// changes aren't blown away if the parent re-renders.
useEffect(() => {
if (!open || !purchaseOrder) return;
let cancelled = false;
const po = purchaseOrder;
setTo(po.expand?.supplier?.email || "");
setCc("");
setBcc("");
setSubject(`Purchase Order ${po.po_number}`);
setMessage("");
setMessageLoading(true);
(async () => {
try {
const poItems = await getPocketBase()
.collection("order_items")
.getFullList<OrderItem>({
filter: `purchase_order = "${po.id}"`,
fields: "id",
});
if (cancelled) return;
// 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.
setMessage(
buildDefaultPoEmailBody({
poNumber: po.po_number || po.id,
supplierName: po.expand?.supplier?.suppliername || "",
lineCount: poItems.length,
}).text
);
} catch {
if (cancelled) return;
// 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.
setMessage(
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 {
if (cancelled) return;
setMessageLoading(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] : []);
}
})();
return () => {
cancelled = true;
};
}, [open, purchaseOrder]);
const handleClose = () => {
if (busy) return;
setMessageLoading(false);
onClose();
};
const handleSend = async () => {
if (!purchaseOrder) return;
setBusy(true);
try {
const res = await fetch(
`/api/v1/purchase-orders/${purchaseOrder.id}/send-email`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
to: to.trim(),
cc: cc.trim(),
bcc: bcc.trim(),
subject: subject.trim(),
message,
}),
}
);
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 = to.trim();
const supplierUpdateNote = data.supplierEmailUpdated
? " Updated the supplier email to match."
: "";
toast.success(
`${purchaseOrder.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.",
}
);
}
onSent();
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : "Send failed");
} finally {
setBusy(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 (!purchaseOrder || !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/${purchaseOrder.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 (!purchaseOrder) return;
setAttachmentUploading(true);
try {
const res = await fetch(
`/api/v1/purchase-orders/${purchaseOrder.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);
}
};
return (
<Dialog
open={open}
onOpenChange={(o) => {
if (!o) handleClose();
}}
>
<DialogContent className="sm:max-w-3xl max-h-[90vh] grid-rows-[auto_1fr_auto] gap-0 p-0 overflow-hidden">
<DialogHeader className="px-6 pt-6 pb-4 border-b">
<DialogTitle>
Send Purchase Order {purchaseOrder?.po_number ?? ""} to{" "}
{purchaseOrder?.expand?.supplier?.suppliername ?? "supplier"}
</DialogTitle>
<DialogDescription>
The supplier will receive the current PO PDF as an attachment.
Replies and acknowledgements will appear in the Supplier Email
section once the IMAP poller is wired up.
</DialogDescription>
</DialogHeader>
{/* Body — scrolls independently. On wide screens the delivery
config lives on the left and the message + attachments
live on the right; on small screens everything stacks. */}
<div className="grid gap-6 px-6 py-4 overflow-y-auto lg:grid-cols-[minmax(0,1fr)_minmax(0,1.1fr)]">
<div className="space-y-3">
<div className="space-y-1.5">
<Label htmlFor="send-to">To</Label>
<Input
id="send-to"
value={to}
onChange={(e) => setTo(e.target.value)}
placeholder="supplier@example.com"
autoFocus
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="send-cc">CC</Label>
<Input
id="send-cc"
value={cc}
onChange={(e) => setCc(e.target.value)}
placeholder="optional"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="send-bcc">BCC</Label>
<Input
id="send-bcc"
value={bcc}
onChange={(e) => setBcc(e.target.value)}
placeholder="optional"
/>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="send-subject">Subject</Label>
<Input
id="send-subject"
value={subject}
onChange={(e) => setSubject(e.target.value)}
placeholder={`Purchase Order ${purchaseOrder?.po_number ?? ""}`}
/>
</div>
{purchaseOrder?.status === "mail_failed" && purchaseOrder?.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>{" "}
{purchaseOrder.email_error}
</div>
)}
</div>
<div className="space-y-3">
<div className="space-y-1.5">
<Label htmlFor="send-message">Message</Label>
<Textarea
id="send-message"
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Hi Supplier, please see attached PO. Confirm receipt and ETA. Thanks — WFDash Orders"
rows={8}
maxLength={2000}
disabled={messageLoading}
className="min-h-32"
/>
<p className="text-xs text-muted-foreground">
{messageLoading
? "Loading line count for preview…"
: `${message.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={purchaseOrder ? `/api/v1/files/purchase_orders/${purchaseOrder.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>
</div>
</div>
<DialogFooter className="px-6 py-4 border-t">
<Button
variant="outline"
onClick={handleClose}
disabled={busy}
>
Cancel
</Button>
<Button
onClick={handleSend}
disabled={
busy ||
messageLoading ||
!to.trim() ||
!subject.trim()
}
>
{busy && <Loader2 className="mr-1.5 h-4 w-4 animate-spin" />}
<Send className="mr-1.5 h-4 w-4" />
{purchaseOrder?.status === "mail_failed" ? "Resend" : "Send"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}