Merge branch 'feat/buyer-tasks-resend-task1'
Adds a Re-Send button next to Mark as ready on the PO Sent tab of the buyer-tasks page. The button opens the existing SendPoModal in resend mode, pre-filled from the PO's previous email_payload (to/cc/bcc/subject/body). Also migrates the hardcoded purple Mark-as-ready color to the themed primary token, and fixes the mail-failed 'Resend' label that previously opened the wrong dialog.
This commit is contained in:
commit
f3052bfd40
8 changed files with 348 additions and 76 deletions
|
|
@ -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",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
<BuyerTaskGroupCard
|
||||
group={{ ...baseGroup, step: "po_sent" }}
|
||||
onAction={vi.fn()}
|
||||
onResend={onResend}
|
||||
/>
|
||||
);
|
||||
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(
|
||||
<BuyerTaskGroupCard
|
||||
group={{ ...baseGroup, step: "po_sent" }}
|
||||
onAction={vi.fn()}
|
||||
onResend={vi.fn()}
|
||||
/>
|
||||
);
|
||||
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(
|
||||
<BuyerTaskGroupCard
|
||||
group={{ ...baseGroup, step: "po_sent", poStatus: "mail_failed" }}
|
||||
onAction={vi.fn()}
|
||||
onResend={vi.fn()}
|
||||
/>
|
||||
);
|
||||
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(
|
||||
<BuyerTaskGroupCard
|
||||
group={{ ...baseGroup, step: "po_sent" }}
|
||||
onAction={vi.fn()}
|
||||
onResend={vi.fn()}
|
||||
/>
|
||||
);
|
||||
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(
|
||||
<BuyerTaskGroupCard
|
||||
group={{ ...baseGroup, step: "po_sent" }}
|
||||
onAction={onAction}
|
||||
onResend={onResend}
|
||||
/>
|
||||
);
|
||||
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(
|
||||
<BuyerTaskGroupCard
|
||||
group={{ ...baseGroup, step: "po_draft" }}
|
||||
onAction={vi.fn()}
|
||||
/>
|
||||
);
|
||||
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(<BuyerTaskGroupCard group={baseGroup} onAction={onAction} />);
|
||||
|
|
|
|||
|
|
@ -18,16 +18,26 @@ const ACTION_LABEL: Record<BuyerTaskGroup["step"], string> = {
|
|||
const ACTION_CLASS: Record<BuyerTaskGroup["step"], string> = {
|
||||
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)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => onAction(group)}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
ACTION_CLASS[group.step],
|
||||
group.poStatus === "mail_failed" &&
|
||||
"bg-destructive hover:bg-destructive/90 text-destructive-foreground",
|
||||
)}
|
||||
data-testid={`buyer-task-action-${group.groupId}-${group.step}`}
|
||||
data-po-status={group.poStatus ?? "none"}
|
||||
>
|
||||
{group.poStatus === "mail_failed" ? "Resend" : ACTION_LABEL[group.step]}
|
||||
</Button>
|
||||
{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).
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => onResend?.(group)}
|
||||
disabled={disabled || !onResend}
|
||||
className={cn(
|
||||
RESEND_DEFAULT_CLASS,
|
||||
group.poStatus === "mail_failed" && RESEND_FAILED_CLASS,
|
||||
)}
|
||||
data-testid={`buyer-task-resend-${group.groupId}`}
|
||||
data-po-status={group.poStatus ?? "none"}
|
||||
>
|
||||
{group.poStatus === "mail_failed" ? "Resend" : "Re-Send"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => onAction(group)}
|
||||
disabled={disabled}
|
||||
className={ACTION_CLASS[group.step]}
|
||||
data-testid={`buyer-task-action-${group.groupId}-${group.step}`}
|
||||
>
|
||||
{ACTION_LABEL[group.step]}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
// needs_po / po_draft: single button (unchanged behaviour).
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => onAction(group)}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
ACTION_CLASS[group.step],
|
||||
group.poStatus === "mail_failed" &&
|
||||
"bg-destructive hover:bg-destructive/90 text-destructive-foreground",
|
||||
)}
|
||||
data-testid={`buyer-task-action-${group.groupId}-${group.step}`}
|
||||
data-po-status={group.poStatus ?? "none"}
|
||||
>
|
||||
{group.poStatus === "mail_failed" ? "Resend" : ACTION_LABEL[group.step]}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{group.poStatus === "mail_failed" && group.emailError && (
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ const sampleGroup = {
|
|||
poStatus: null,
|
||||
poNumber: null,
|
||||
supplierEmail: null,
|
||||
emailPayload: null,
|
||||
additionalAttachments: [],
|
||||
step: "needs_po" as const,
|
||||
};
|
||||
|
||||
|
|
@ -80,6 +82,79 @@ describe("BuyerTasksPage", () => {
|
|||
});
|
||||
});
|
||||
|
||||
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(<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();
|
||||
});
|
||||
|
||||
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 () => {
|
||||
vi.spyOn(global, "fetch")
|
||||
.mockResolvedValueOnce(
|
||||
|
|
|
|||
|
|
@ -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 ?? "",
|
||||
|
|
|
|||
|
|
@ -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. 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<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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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: "<p>Old body</p>",
|
||||
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: "<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 () => {
|
||||
|
|
|
|||
|
|
@ -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<BuyerTaskGroup[
|
|||
"booked",
|
||||
];
|
||||
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") {
|
||||
stockPurchases = await admin.collection("stock_purchases").getFullList({
|
||||
filter: `status = "sent" || status = "sending"`,
|
||||
|
|
@ -153,7 +158,15 @@ export async function listBuyerTasks(tab: BuyerTaskTab): Promise<BuyerTaskGroup[
|
|||
if (!orderId || !supplierId) continue;
|
||||
|
||||
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;
|
||||
|
||||
// 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;
|
||||
const company = customer?.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 = {
|
||||
groupId,
|
||||
|
|
@ -195,6 +214,8 @@ export async function listBuyerTasks(tab: BuyerTaskTab): Promise<BuyerTaskGroup[
|
|||
(po as { email_error?: string } | undefined)?.email_error
|
||||
? String((po as { email_error?: string }).email_error).slice(0, 200)
|
||||
: null,
|
||||
emailPayload: po?.email_payload ?? null,
|
||||
additionalAttachments,
|
||||
step: tab,
|
||||
source: "customer",
|
||||
};
|
||||
|
|
@ -228,6 +249,8 @@ export async function listBuyerTasks(tab: BuyerTaskTab): Promise<BuyerTaskGroup[
|
|||
supplier: string;
|
||||
status: "draft" | "sent" | "sending" | "received" | "mail_failed" | "cancelled";
|
||||
email_error?: string;
|
||||
email_payload?: PurchaseOrderEmailPayload;
|
||||
additional_attachments?: string[] | string;
|
||||
expand?: { supplier?: { suppliername?: string; email?: string } };
|
||||
};
|
||||
const supplierId = stock.supplier;
|
||||
|
|
@ -245,6 +268,12 @@ export async function listBuyerTasks(tab: BuyerTaskTab): Promise<BuyerTaskGroup[
|
|||
(sum, it) => 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<BuyerTaskGroup[
|
|||
poNumber: stock.purchase_number ?? null,
|
||||
supplierEmail: supplier?.email ?? null,
|
||||
emailError: stock.email_error ? stock.email_error.slice(0, 200) : null,
|
||||
emailPayload: stock.email_payload ?? null,
|
||||
additionalAttachments,
|
||||
step: "po_sent",
|
||||
source: "stock",
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue