diff --git a/src/app/api/v1/buyer-tasks/route.test.ts b/src/app/api/v1/buyer-tasks/route.test.ts index 306d126..fc86758 100644 --- a/src/app/api/v1/buyer-tasks/route.test.ts +++ b/src/app/api/v1/buyer-tasks/route.test.ts @@ -35,6 +35,8 @@ describe("GET /api/v1/buyer-tasks", () => { poNumber: null, supplierEmail: null, emailError: null, + emailPayload: null, + additionalAttachments: [], step: "needs_po", source: "customer", }, @@ -65,6 +67,8 @@ describe("GET /api/v1/buyer-tasks", () => { poNumber: "PO-26080301", supplierEmail: "orders@steelco.example", emailError: "[webhook] Webhook returned HTTP 502", + emailPayload: null, + additionalAttachments: [], step: "po_draft", source: "customer", }, diff --git a/src/components/buyer-tasks/buyer-task-group-card.test.tsx b/src/components/buyer-tasks/buyer-task-group-card.test.tsx index 6224c1c..5744a1f 100644 --- a/src/components/buyer-tasks/buyer-task-group-card.test.tsx +++ b/src/components/buyer-tasks/buyer-task-group-card.test.tsx @@ -19,6 +19,8 @@ const baseGroup = { poStatus: null, poNumber: null, emailError: null, + emailPayload: null, + additionalAttachments: [], supplierEmail: null, step: "needs_po" as const, source: "customer" as const, @@ -50,6 +52,87 @@ describe("BuyerTaskGroupCard", () => { } }); + it("renders two buttons on the PO Sent tab", () => { + const onResend = vi.fn(); + render( + + ); + expect(screen.getByTestId(/buyer-task-resend-/)).toBeInTheDocument(); + expect(screen.getByTestId(/buyer-task-action-.*-po_sent/)).toBeInTheDocument(); + }); + + it("Re-Send uses the info theme token by default", () => { + render( + + ); + const btn = screen.getByTestId(/buyer-task-resend-/); + expect(btn.className).toMatch(/bg-info/); + expect(btn.className).toMatch(/text-info-foreground/); + }); + + it("Re-Send switches to destructive styling when mail_failed", () => { + render( + + ); + const btn = screen.getByTestId(/buyer-task-resend-/) as HTMLButtonElement; + expect(btn.className).toMatch(/bg-destructive/); + expect(btn.textContent).toBe("Resend"); + }); + + it("Mark as ready uses the primary theme token (no hardcoded purple)", () => { + render( + + ); + const btn = screen.getByTestId(/buyer-task-action-.*-po_sent/); + expect(btn.className).toMatch(/bg-primary/); + expect(btn.className).not.toMatch(/purple-600/); + }); + + it("Re-Send click fires onResend; Mark as ready click fires onAction", () => { + const onResend = vi.fn(); + const onAction = vi.fn(); + render( + + ); + fireEvent.click(screen.getByTestId(/buyer-task-resend-/)); + expect(onResend).toHaveBeenCalledTimes(1); + expect(onAction).not.toHaveBeenCalled(); + fireEvent.click(screen.getByTestId(/buyer-task-action-.*-po_sent/)); + expect(onAction).toHaveBeenCalledTimes(1); + expect(onResend).toHaveBeenCalledTimes(1); // unchanged from first click + }); + + it("po_draft still renders a single Send PO button (no regression)", () => { + render( + + ); + expect(screen.queryByTestId(/buyer-task-resend-/)).not.toBeInTheDocument(); + expect(screen.getByTestId(/buyer-task-action-.*-po_draft/)).toBeInTheDocument(); + }); + it("calls onAction when the button is clicked", () => { const onAction = vi.fn(); render(); diff --git a/src/components/buyer-tasks/buyer-task-group-card.tsx b/src/components/buyer-tasks/buyer-task-group-card.tsx index 9df0a7f..bd23cd6 100644 --- a/src/components/buyer-tasks/buyer-task-group-card.tsx +++ b/src/components/buyer-tasks/buyer-task-group-card.tsx @@ -18,16 +18,26 @@ const ACTION_LABEL: Record = { const ACTION_CLASS: Record = { needs_po: "bg-blue-600 hover:bg-blue-700 text-white", po_draft: "bg-amber-500 hover:bg-amber-600 text-white", - po_sent: "bg-purple-600 hover:bg-purple-700 text-white", + po_sent: "bg-primary text-primary-foreground hover:bg-primary/90", }; +// Resend button styling for the po_sent step. Uses the themed `info` +// token so it works in light/dark themes and any future reskin. When +// the PO is mail_failed we override to destructive to keep the +// existing visual signal that something went wrong. +const RESEND_DEFAULT_CLASS = "bg-info text-info-foreground hover:bg-info/90"; +const RESEND_FAILED_CLASS = + "bg-destructive text-destructive-foreground hover:bg-destructive/90"; + export function BuyerTaskGroupCard({ group, onAction, + onResend, disabled, }: { group: BuyerTaskGroup; onAction: (group: BuyerTaskGroup) => void; + onResend?: (group: BuyerTaskGroup) => void; disabled?: boolean; }) { // Deterministic supplier stripe colour. Same supplier → same colour @@ -95,20 +105,53 @@ export function BuyerTaskGroupCard({ {group.itemCount} item{group.itemCount === 1 ? "" : "s"} · Cost R{group.totalCost.toFixed(2)} - + {group.step === "po_sent" ? ( + // PO Sent: two side-by-side buttons. Re-Send opens the resend modal + // (reuses the SendPoModal wired up for po_draft on the parent page). + // Mark as ready opens the MarkReadyDialog. The mail_failed override + // now lands on the Re-Send button (the Mark as ready button stays + // primary — collection can proceed independently of mail state). +
+ + +
+ ) : ( + // needs_po / po_draft: single button (unchanged behaviour). + + )} {group.poStatus === "mail_failed" && group.emailError && (
{ }); }); + it("clicking Re-Send on a po_sent card opens SendPoModal", async () => { + const poSentGroup = { + ...sampleGroup, + groupId: "order-1::sup-1::po-sent-open", + purchaseOrderId: "po-1", + poStatus: "sent" as const, + poNumber: "PO-TEST-001", + supplierEmail: "supplier@example.com", + step: "po_sent" as const, + }; + vi.spyOn(global, "fetch").mockImplementation(async (input) => { + const url = String(input); + const groups = url.includes("tab=po_sent") ? [poSentGroup] : []; + return new Response(JSON.stringify({ groups }), { status: 200 }); + }); + + render(); + fireEvent.click(screen.getByTestId("buyer-task-tab-po_sent")); + + const resendBtn = await screen.findByTestId(/buyer-task-resend-/); + fireEvent.click(resendBtn); + expect(await screen.findByText(/resend purchase order po-test-001/i)).toBeInTheDocument(); + }); + + it("clicking Re-Send pre-fills the modal with the previous email composition", async () => { + const poSentGroup = { + ...sampleGroup, + groupId: "order-1::sup-1::po-sent", + purchaseOrderId: "po-1", + poStatus: "sent" as const, + poNumber: "PO-TEST-001", + supplierEmail: "supplier@example.com", + emailPayload: { + to: "previous@example.com", + cc: "copy@example.com", + bcc: "blind@example.com", + subject: "Old subject", + body: "

Old body

", + bodyText: "Old body", + }, + additionalAttachments: ["drawing.pdf"], + step: "po_sent" as const, + }; + vi.spyOn(global, "fetch").mockImplementation(async (input) => { + const url = String(input); + const groups = url.includes("tab=po_sent") ? [poSentGroup] : []; + return new Response(JSON.stringify({ groups }), { status: 200 }); + }); + + render(); + fireEvent.click(screen.getByTestId("buyer-task-tab-po_sent")); + + const resendBtn = await screen.findByTestId(/buyer-task-resend-/); + fireEvent.click(resendBtn); + expect(await screen.findByText(/resend purchase order po-test-001/i)).toBeInTheDocument(); + + expect(document.querySelector("#send-to")).toHaveValue( + "previous@example.com", + ); + expect(document.querySelector("#send-cc")).toHaveValue( + "copy@example.com", + ); + expect(document.querySelector("#send-bcc")).toHaveValue( + "blind@example.com", + ); + expect(document.querySelector("#send-subject")).toHaveValue( + "Old subject", + ); + expect(document.querySelector("#send-message")).toHaveValue( + "Old body", + ); + }); + it("calls generate-po when the action button is clicked", async () => { vi.spyOn(global, "fetch") .mockResolvedValueOnce( diff --git a/src/components/buyer-tasks/buyer-tasks-page.tsx b/src/components/buyer-tasks/buyer-tasks-page.tsx index 8d2b216..ab1fde2 100644 --- a/src/components/buyer-tasks/buyer-tasks-page.tsx +++ b/src/components/buyer-tasks/buyer-tasks-page.tsx @@ -129,10 +129,9 @@ export function BuyerTasksPage() { } else if (group.step === "po_draft") { // 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). + // order detail page uses. Pass the PO fields needed for the + // supplier/status/email-composition/attachment prefill; the line + // count for the message preview is still loaded lazily by the modal. setSendPoFor(group); } else if (group.step === "po_sent") { setReadyError(null); @@ -140,6 +139,15 @@ export function BuyerTasksPage() { } } + function handleResend(group: BuyerTaskGroup) { + // The same SendPoModal flow used for po_draft handles po_sent resends + // — the modal pre-fills from the previous email_payload and shows + // "Resend" in the title/button when status is sent or mail_failed. + // We guard on step in case a caller passes the wrong step. + if (group.step !== "po_sent") return; + setSendPoFor(group); + } + async function handleMarkReadyConfirm(note: string) { if (!markReadyFor) return; const group = markReadyFor; @@ -210,6 +218,7 @@ export function BuyerTasksPage() { key={group.groupId} group={group} onAction={handleAction} + onResend={handleResend} disabled={busyGroupId === group.groupId} /> ))} @@ -237,6 +246,8 @@ export function BuyerTasksPage() { po_number: sendPoFor.poNumber ?? "", status: sendPoFor.poStatus ?? undefined, email_error: sendPoFor.emailError ?? undefined, + email_payload: sendPoFor.emailPayload ?? undefined, + additional_attachments: sendPoFor.additionalAttachments ?? [], expand: { supplier: { email: sendPoFor.supplierEmail ?? "", diff --git a/src/components/purchase-orders/send-po-modal.tsx b/src/components/purchase-orders/send-po-modal.tsx index 023004c..593f373 100644 --- a/src/components/purchase-orders/send-po-modal.tsx +++ b/src/components/purchase-orders/send-po-modal.tsx @@ -92,61 +92,68 @@ export function SendPoModal({ open, purchaseOrder, onClose, onSent }: Props) { setSubject(`Purchase Order ${po.po_number}`); setMessage(""); } - setMessageLoading(true); - (async () => { - try { - const poItems = await getPocketBase() - .collection("order_items") - .getFullList({ - 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] : []); - } - })(); + // Phase 2: async default-body seed. Skip only when the resend flow has a + // previous body to preserve so the generic template doesn't clobber it. + // If there is nothing to preserve (for example, a legacy sent PO without + // email_payload), fall back to the default body. Phase 2 also gates + // messageLoading so the textarea doesn't grey out mid-edit on resend. + if (!isResendFlow || !previous) { + setMessageLoading(true); + (async () => { + try { + const poItems = await getPocketBase() + .collection("order_items") + .getFullList({ + 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); + } + })(); + } + + // Additional-attachments seed — read synchronously from the PO record. + // Independent of phase 2, so we always run it (resend or draft). + const rawAtt = po.additional_attachments ?? []; + setAttachments(Array.isArray(rawAtt) ? rawAtt : rawAtt ? [rawAtt] : []); + return () => { cancelled = true; }; diff --git a/src/lib/server/buyer-tasks-service.test.ts b/src/lib/server/buyer-tasks-service.test.ts index 400f923..6af7095 100644 --- a/src/lib/server/buyer-tasks-service.test.ts +++ b/src/lib/server/buyer-tasks-service.test.ts @@ -392,6 +392,15 @@ describe("listBuyerTasks", () => { id: "po-1", status: "draft", po_number: "PO-26072901", + email_payload: { + to: "previous@example.com", + cc: "copy@example.com", + bcc: "blind@example.com", + subject: "Old subject", + body: "

Old body

", + bodyText: "Old body", + }, + additional_attachments: ["drawing.pdf"], expand: { supplier: { id: "sup-1", @@ -409,6 +418,15 @@ describe("listBuyerTasks", () => { expect(groups).toHaveLength(1); expect(groups[0].poNumber).toBe("PO-26072901"); expect(groups[0].supplierEmail).toBe("orders@steelco.example"); + expect(groups[0].emailPayload).toEqual({ + to: "previous@example.com", + cc: "copy@example.com", + bcc: "blind@example.com", + subject: "Old subject", + body: "

Old body

", + bodyText: "Old body", + }); + expect(groups[0].additionalAttachments).toEqual(["drawing.pdf"]); }); it("populates emailError from the related PO when the PO has an email_error", async () => { diff --git a/src/lib/server/buyer-tasks-service.ts b/src/lib/server/buyer-tasks-service.ts index 87bfdac..1281823 100644 --- a/src/lib/server/buyer-tasks-service.ts +++ b/src/lib/server/buyer-tasks-service.ts @@ -4,6 +4,7 @@ import { generatePurchaseOrderPdf, PurchaseOrderPdfValidationError, } from "@/lib/server/purchase-orders-service"; +import type { PurchaseOrderEmailPayload } from "@/lib/types"; export type BuyerTaskTab = "needs_po" | "po_draft" | "po_sent"; @@ -50,6 +51,10 @@ export type BuyerTaskGroup = { * layout. The full reason remains visible in the order page's send-modal * banner. */ emailError: string | null; + /** Last composed outbound email, used to prefill the resend modal. */ + emailPayload: PurchaseOrderEmailPayload | null; + /** File tokens for additional supplier attachments. */ + additionalAttachments: string[]; step: BuyerTaskTab; source: BuyerTaskGroupSource; }; @@ -111,7 +116,7 @@ export async function listBuyerTasks(tab: BuyerTaskTab): Promise> = []; - let stockItemMap = new Map>(); + const stockItemMap = new Map>(); if (tab === "po_sent") { stockPurchases = await admin.collection("stock_purchases").getFullList({ filter: `status = "sent" || status = "sending"`, @@ -153,7 +158,15 @@ export async function listBuyerTasks(tab: BuyerTaskTab): Promise sum + it.quantity * it.cost_price, 0, ); + const rawAdditionalAttachments = stock.additional_attachments; + const additionalAttachments = Array.isArray(rawAdditionalAttachments) + ? rawAdditionalAttachments + : rawAdditionalAttachments + ? [rawAdditionalAttachments] + : []; groups.set(groupId, { groupId, orderId: stock.id, @@ -267,6 +296,8 @@ export async function listBuyerTasks(tab: BuyerTaskTab): Promise