feat(buyer-tasks): extract send-po modal to shared component
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
1486e10bbe
commit
3cfb2ac00c
4 changed files with 520 additions and 392 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
51
src/components/purchase-orders/send-po-modal.test.tsx
Normal file
51
src/components/purchase-orders/send-po-modal.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
432
src/components/purchase-orders/send-po-modal.tsx
Normal file
432
src/components/purchase-orders/send-po-modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue