fix(send-po-modal): preserve previous message body on resend (don't overwrite with default template)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
SJCoetz 2026-08-06 18:06:22 +02:00
parent f61c173a1a
commit abdc3a4d9f
2 changed files with 62 additions and 65 deletions

View file

@ -2,16 +2,6 @@ import { describe, it, expect, beforeEach, vi } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
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 = {
groupId: "order-1::sup-1",
orderId: "order-1",

View file

@ -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<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] : []);
}
})();
// Phase 2: async default-body seed. SKIPPED on the resend flow — the
// operator's previous bodyText is already prefilled in phase 1 above
// and we mustn't clobber it with the generic template once the fetch
// resolves. Phase 2 also gates messageLoading so the textarea doesn't
// grey out mid-edit on resend.
if (!isResendFlow) {
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);
}
})();
}
// 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;
};