fix(buyer-tasks): surface PO email_payload + attachments on group for resend modal prefill
Co-Authored-By: Claude <noreply@anthropic.com> Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
6008f17c9a
commit
f61c173a1a
6 changed files with 124 additions and 7 deletions
|
|
@ -35,6 +35,8 @@ describe("GET /api/v1/buyer-tasks", () => {
|
||||||
poNumber: null,
|
poNumber: null,
|
||||||
supplierEmail: null,
|
supplierEmail: null,
|
||||||
emailError: null,
|
emailError: null,
|
||||||
|
emailPayload: null,
|
||||||
|
additionalAttachments: [],
|
||||||
step: "needs_po",
|
step: "needs_po",
|
||||||
source: "customer",
|
source: "customer",
|
||||||
},
|
},
|
||||||
|
|
@ -65,6 +67,8 @@ describe("GET /api/v1/buyer-tasks", () => {
|
||||||
poNumber: "PO-26080301",
|
poNumber: "PO-26080301",
|
||||||
supplierEmail: "orders@steelco.example",
|
supplierEmail: "orders@steelco.example",
|
||||||
emailError: "[webhook] Webhook returned HTTP 502",
|
emailError: "[webhook] Webhook returned HTTP 502",
|
||||||
|
emailPayload: null,
|
||||||
|
additionalAttachments: [],
|
||||||
step: "po_draft",
|
step: "po_draft",
|
||||||
source: "customer",
|
source: "customer",
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,8 @@ const baseGroup = {
|
||||||
poStatus: null,
|
poStatus: null,
|
||||||
poNumber: null,
|
poNumber: null,
|
||||||
emailError: null,
|
emailError: null,
|
||||||
|
emailPayload: null,
|
||||||
|
additionalAttachments: [],
|
||||||
supplierEmail: null,
|
supplierEmail: null,
|
||||||
step: "needs_po" as const,
|
step: "needs_po" as const,
|
||||||
source: "customer" as const,
|
source: "customer" as const,
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,16 @@ import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||||
import { BuyerTasksPage } from "./buyer-tasks-page";
|
import { BuyerTasksPage } from "./buyer-tasks-page";
|
||||||
|
|
||||||
|
vi.mock("@/lib/pocketbase", () => ({
|
||||||
|
default: () => ({
|
||||||
|
collection: () => ({
|
||||||
|
// Keep the line-count lookup pending so this test observes the
|
||||||
|
// synchronous resend prefill rather than the async default-body preview.
|
||||||
|
getFullList: () => new Promise(() => {}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
const sampleGroup = {
|
const sampleGroup = {
|
||||||
groupId: "order-1::sup-1",
|
groupId: "order-1::sup-1",
|
||||||
orderId: "order-1",
|
orderId: "order-1",
|
||||||
|
|
@ -19,6 +29,8 @@ const sampleGroup = {
|
||||||
poStatus: null,
|
poStatus: null,
|
||||||
poNumber: null,
|
poNumber: null,
|
||||||
supplierEmail: null,
|
supplierEmail: null,
|
||||||
|
emailPayload: null,
|
||||||
|
additionalAttachments: [],
|
||||||
step: "needs_po" as const,
|
step: "needs_po" as const,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -83,7 +95,7 @@ describe("BuyerTasksPage", () => {
|
||||||
it("clicking Re-Send on a po_sent card opens SendPoModal", async () => {
|
it("clicking Re-Send on a po_sent card opens SendPoModal", async () => {
|
||||||
const poSentGroup = {
|
const poSentGroup = {
|
||||||
...sampleGroup,
|
...sampleGroup,
|
||||||
groupId: "order-1::sup-1::po-sent",
|
groupId: "order-1::sup-1::po-sent-open",
|
||||||
purchaseOrderId: "po-1",
|
purchaseOrderId: "po-1",
|
||||||
poStatus: "sent" as const,
|
poStatus: "sent" as const,
|
||||||
poNumber: "PO-TEST-001",
|
poNumber: "PO-TEST-001",
|
||||||
|
|
@ -104,6 +116,55 @@ describe("BuyerTasksPage", () => {
|
||||||
expect(await screen.findByText(/resend purchase order po-test-001/i)).toBeInTheDocument();
|
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: "<p>Old body</p>",
|
||||||
|
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(<BuyerTasksPage />);
|
||||||
|
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<HTMLInputElement>("#send-to")).toHaveValue(
|
||||||
|
"previous@example.com",
|
||||||
|
);
|
||||||
|
expect(document.querySelector<HTMLInputElement>("#send-cc")).toHaveValue(
|
||||||
|
"copy@example.com",
|
||||||
|
);
|
||||||
|
expect(document.querySelector<HTMLInputElement>("#send-bcc")).toHaveValue(
|
||||||
|
"blind@example.com",
|
||||||
|
);
|
||||||
|
expect(document.querySelector<HTMLInputElement>("#send-subject")).toHaveValue(
|
||||||
|
"Old subject",
|
||||||
|
);
|
||||||
|
expect(document.querySelector<HTMLTextAreaElement>("#send-message")).toHaveValue(
|
||||||
|
"Old body",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("calls generate-po when the action button is clicked", async () => {
|
it("calls generate-po when the action button is clicked", async () => {
|
||||||
vi.spyOn(global, "fetch")
|
vi.spyOn(global, "fetch")
|
||||||
.mockResolvedValueOnce(
|
.mockResolvedValueOnce(
|
||||||
|
|
|
||||||
|
|
@ -129,10 +129,9 @@ export function BuyerTasksPage() {
|
||||||
} else if (group.step === "po_draft") {
|
} else if (group.step === "po_draft") {
|
||||||
// Open the shared send-PO modal with the group's PO. The modal
|
// 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
|
// owns its form state and POSTs to the same send-email route the
|
||||||
// order detail page uses. We pass a minimal PurchaseOrder stub
|
// order detail page uses. Pass the PO fields needed for the
|
||||||
// with just the id — the modal reads its own PO via the API
|
// supplier/status/email-composition/attachment prefill; the line
|
||||||
// when needed (supplier email, status, attachments are seeded
|
// count for the message preview is still loaded lazily by the modal.
|
||||||
// lazily inside the modal).
|
|
||||||
setSendPoFor(group);
|
setSendPoFor(group);
|
||||||
} else if (group.step === "po_sent") {
|
} else if (group.step === "po_sent") {
|
||||||
setReadyError(null);
|
setReadyError(null);
|
||||||
|
|
@ -247,6 +246,8 @@ export function BuyerTasksPage() {
|
||||||
po_number: sendPoFor.poNumber ?? "",
|
po_number: sendPoFor.poNumber ?? "",
|
||||||
status: sendPoFor.poStatus ?? undefined,
|
status: sendPoFor.poStatus ?? undefined,
|
||||||
email_error: sendPoFor.emailError ?? undefined,
|
email_error: sendPoFor.emailError ?? undefined,
|
||||||
|
email_payload: sendPoFor.emailPayload ?? undefined,
|
||||||
|
additional_attachments: sendPoFor.additionalAttachments ?? [],
|
||||||
expand: {
|
expand: {
|
||||||
supplier: {
|
supplier: {
|
||||||
email: sendPoFor.supplierEmail ?? "",
|
email: sendPoFor.supplierEmail ?? "",
|
||||||
|
|
|
||||||
|
|
@ -392,6 +392,15 @@ describe("listBuyerTasks", () => {
|
||||||
id: "po-1",
|
id: "po-1",
|
||||||
status: "draft",
|
status: "draft",
|
||||||
po_number: "PO-26072901",
|
po_number: "PO-26072901",
|
||||||
|
email_payload: {
|
||||||
|
to: "previous@example.com",
|
||||||
|
cc: "copy@example.com",
|
||||||
|
bcc: "blind@example.com",
|
||||||
|
subject: "Old subject",
|
||||||
|
body: "<p>Old body</p>",
|
||||||
|
bodyText: "Old body",
|
||||||
|
},
|
||||||
|
additional_attachments: ["drawing.pdf"],
|
||||||
expand: {
|
expand: {
|
||||||
supplier: {
|
supplier: {
|
||||||
id: "sup-1",
|
id: "sup-1",
|
||||||
|
|
@ -409,6 +418,15 @@ describe("listBuyerTasks", () => {
|
||||||
expect(groups).toHaveLength(1);
|
expect(groups).toHaveLength(1);
|
||||||
expect(groups[0].poNumber).toBe("PO-26072901");
|
expect(groups[0].poNumber).toBe("PO-26072901");
|
||||||
expect(groups[0].supplierEmail).toBe("orders@steelco.example");
|
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: "<p>Old body</p>",
|
||||||
|
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 () => {
|
it("populates emailError from the related PO when the PO has an email_error", async () => {
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import {
|
||||||
generatePurchaseOrderPdf,
|
generatePurchaseOrderPdf,
|
||||||
PurchaseOrderPdfValidationError,
|
PurchaseOrderPdfValidationError,
|
||||||
} from "@/lib/server/purchase-orders-service";
|
} from "@/lib/server/purchase-orders-service";
|
||||||
|
import type { PurchaseOrderEmailPayload } from "@/lib/types";
|
||||||
|
|
||||||
export type BuyerTaskTab = "needs_po" | "po_draft" | "po_sent";
|
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
|
* layout. The full reason remains visible in the order page's send-modal
|
||||||
* banner. */
|
* banner. */
|
||||||
emailError: string | null;
|
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;
|
step: BuyerTaskTab;
|
||||||
source: BuyerTaskGroupSource;
|
source: BuyerTaskGroupSource;
|
||||||
};
|
};
|
||||||
|
|
@ -111,7 +116,7 @@ export async function listBuyerTasks(tab: BuyerTaskTab): Promise<BuyerTaskGroup[
|
||||||
"booked",
|
"booked",
|
||||||
];
|
];
|
||||||
let stockPurchases: Array<Record<string, unknown>> = [];
|
let stockPurchases: Array<Record<string, unknown>> = [];
|
||||||
let stockItemMap = new Map<string, Array<{ id: string; description: string; quantity: number; cost_price: number }>>();
|
const stockItemMap = new Map<string, Array<{ id: string; description: string; quantity: number; cost_price: number }>>();
|
||||||
if (tab === "po_sent") {
|
if (tab === "po_sent") {
|
||||||
stockPurchases = await admin.collection("stock_purchases").getFullList({
|
stockPurchases = await admin.collection("stock_purchases").getFullList({
|
||||||
filter: `status = "sent" || status = "sending"`,
|
filter: `status = "sent" || status = "sending"`,
|
||||||
|
|
@ -153,7 +158,15 @@ export async function listBuyerTasks(tab: BuyerTaskTab): Promise<BuyerTaskGroup[
|
||||||
if (!orderId || !supplierId) continue;
|
if (!orderId || !supplierId) continue;
|
||||||
|
|
||||||
const po = item.expand?.purchase_order as
|
const po = item.expand?.purchase_order as
|
||||||
| { id: string; status: BuyerTaskGroup["poStatus"]; po_number?: string; expand?: { supplier?: { email?: string } } }
|
| {
|
||||||
|
id: string;
|
||||||
|
status: BuyerTaskGroup["poStatus"];
|
||||||
|
po_number?: string;
|
||||||
|
email_error?: string;
|
||||||
|
email_payload?: PurchaseOrderEmailPayload;
|
||||||
|
additional_attachments?: string[] | string;
|
||||||
|
expand?: { supplier?: { email?: string } };
|
||||||
|
}
|
||||||
| undefined;
|
| undefined;
|
||||||
|
|
||||||
// Post-filter by PO status (tab-specific). The PB filter can't
|
// Post-filter by PO status (tab-specific). The PB filter can't
|
||||||
|
|
@ -172,6 +185,12 @@ export async function listBuyerTasks(tab: BuyerTaskTab): Promise<BuyerTaskGroup[
|
||||||
?.expand?.customer;
|
?.expand?.customer;
|
||||||
const company = customer?.expand?.company
|
const company = customer?.expand?.company
|
||||||
?? (item.expand?.order as { expand?: { company?: { company?: string } } })?.expand?.company;
|
?? (item.expand?.order as { expand?: { company?: { company?: string } } })?.expand?.company;
|
||||||
|
const rawAdditionalAttachments = po?.additional_attachments;
|
||||||
|
const additionalAttachments = Array.isArray(rawAdditionalAttachments)
|
||||||
|
? rawAdditionalAttachments
|
||||||
|
: rawAdditionalAttachments
|
||||||
|
? [rawAdditionalAttachments]
|
||||||
|
: [];
|
||||||
|
|
||||||
group = {
|
group = {
|
||||||
groupId,
|
groupId,
|
||||||
|
|
@ -195,6 +214,8 @@ export async function listBuyerTasks(tab: BuyerTaskTab): Promise<BuyerTaskGroup[
|
||||||
(po as { email_error?: string } | undefined)?.email_error
|
(po as { email_error?: string } | undefined)?.email_error
|
||||||
? String((po as { email_error?: string }).email_error).slice(0, 200)
|
? String((po as { email_error?: string }).email_error).slice(0, 200)
|
||||||
: null,
|
: null,
|
||||||
|
emailPayload: po?.email_payload ?? null,
|
||||||
|
additionalAttachments,
|
||||||
step: tab,
|
step: tab,
|
||||||
source: "customer",
|
source: "customer",
|
||||||
};
|
};
|
||||||
|
|
@ -228,6 +249,8 @@ export async function listBuyerTasks(tab: BuyerTaskTab): Promise<BuyerTaskGroup[
|
||||||
supplier: string;
|
supplier: string;
|
||||||
status: "draft" | "sent" | "sending" | "received" | "mail_failed" | "cancelled";
|
status: "draft" | "sent" | "sending" | "received" | "mail_failed" | "cancelled";
|
||||||
email_error?: string;
|
email_error?: string;
|
||||||
|
email_payload?: PurchaseOrderEmailPayload;
|
||||||
|
additional_attachments?: string[] | string;
|
||||||
expand?: { supplier?: { suppliername?: string; email?: string } };
|
expand?: { supplier?: { suppliername?: string; email?: string } };
|
||||||
};
|
};
|
||||||
const supplierId = stock.supplier;
|
const supplierId = stock.supplier;
|
||||||
|
|
@ -245,6 +268,12 @@ export async function listBuyerTasks(tab: BuyerTaskTab): Promise<BuyerTaskGroup[
|
||||||
(sum, it) => sum + it.quantity * it.cost_price,
|
(sum, it) => sum + it.quantity * it.cost_price,
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
|
const rawAdditionalAttachments = stock.additional_attachments;
|
||||||
|
const additionalAttachments = Array.isArray(rawAdditionalAttachments)
|
||||||
|
? rawAdditionalAttachments
|
||||||
|
: rawAdditionalAttachments
|
||||||
|
? [rawAdditionalAttachments]
|
||||||
|
: [];
|
||||||
groups.set(groupId, {
|
groups.set(groupId, {
|
||||||
groupId,
|
groupId,
|
||||||
orderId: stock.id,
|
orderId: stock.id,
|
||||||
|
|
@ -267,6 +296,8 @@ export async function listBuyerTasks(tab: BuyerTaskTab): Promise<BuyerTaskGroup[
|
||||||
poNumber: stock.purchase_number ?? null,
|
poNumber: stock.purchase_number ?? null,
|
||||||
supplierEmail: supplier?.email ?? null,
|
supplierEmail: supplier?.email ?? null,
|
||||||
emailError: stock.email_error ? stock.email_error.slice(0, 200) : null,
|
emailError: stock.email_error ? stock.email_error.slice(0, 200) : null,
|
||||||
|
emailPayload: stock.email_payload ?? null,
|
||||||
|
additionalAttachments,
|
||||||
step: "po_sent",
|
step: "po_sent",
|
||||||
source: "stock",
|
source: "stock",
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue