feat(buyer-tasks): require audit note when marking ready

The Mark ready action on the buyer-tasks page now mirrors the
collection-pool flow: the operator is asked for a free-text note
(name of who they spoke to, collection number, etc.) before the
items transition to ready_delivery. The note is required, is trimmed,
and is written to order_items.notes alongside the status change so
there's an audit trail.

- Service: markGroupReady now takes a required note param, trims it,
  rejects empty values, and persists the trimmed text to notes.
- Route: /api/v1/buyer-tasks/mark-ready returns 400 when note is
  missing or whitespace-only, and forwards the value to the service.
- Page: BuyerTasksPage swaps the no-input MarkReadyModal for the
  shared MarkReadyDialog, threads the note through the POST body,
  and surfaces server-side validation errors back into the dialog
  rather than only as a toast.
- The old MarkReadyModal is removed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
SJCoetz 2026-07-29 22:38:00 +02:00
parent 6259ed8407
commit 6fa658df62
7 changed files with 159 additions and 128 deletions

View file

@ -20,7 +20,7 @@ describe("POST /api/v1/buyer-tasks/mark-ready", () => {
vi.clearAllMocks();
});
it("returns result on success", async () => {
it("returns result on success and forwards the note", async () => {
vi.mocked(markGroupReady).mockResolvedValue({
updated: 2,
failed: [],
@ -31,31 +31,76 @@ describe("POST /api/v1/buyer-tasks/mark-ready", () => {
orderId: "order-1",
supplierId: "sup-1",
items: [{ id: "item-1" }, { id: "item-2" }],
note: "Spoke to David — pickup tomorrow 09:00",
}) as never
);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.updated).toBe(2);
expect(body.failed).toEqual([]);
expect(markGroupReady).toHaveBeenCalledWith(
"order-1",
"sup-1",
[{ id: "item-1" }, { id: "item-2" }],
"Spoke to David — pickup tomorrow 09:00"
);
});
it("returns 400 on missing orderId", async () => {
const res = await POST(
makeRequest({ supplierId: "sup-1", items: [{ id: "item-1" }] }) as never
makeRequest({
supplierId: "sup-1",
items: [{ id: "item-1" }],
note: "Note",
}) as never
);
expect(res.status).toBe(400);
});
it("returns 400 on missing supplierId", async () => {
const res = await POST(
makeRequest({ orderId: "order-1", items: [{ id: "item-1" }] }) as never
makeRequest({
orderId: "order-1",
items: [{ id: "item-1" }],
note: "Note",
}) as never
);
expect(res.status).toBe(400);
});
it("returns 400 on empty items", async () => {
const res = await POST(
makeRequest({ orderId: "order-1", supplierId: "sup-1", items: [] }) as never
makeRequest({
orderId: "order-1",
supplierId: "sup-1",
items: [],
note: "Note",
}) as never
);
expect(res.status).toBe(400);
});
it("returns 400 when note is missing", async () => {
const res = await POST(
makeRequest({
orderId: "order-1",
supplierId: "sup-1",
items: [{ id: "item-1" }],
}) as never
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/note/i);
});
it("returns 400 when note is whitespace-only", async () => {
const res = await POST(
makeRequest({
orderId: "order-1",
supplierId: "sup-1",
items: [{ id: "item-1" }],
note: " ",
}) as never
);
expect(res.status).toBe(400);
});
@ -67,8 +112,9 @@ describe("POST /api/v1/buyer-tasks/mark-ready", () => {
orderId: "order-1",
supplierId: "sup-1",
items: [{ id: "item-1" }],
note: "Note",
}) as never
);
expect(res.status).toBe(500);
});
});
});

View file

@ -5,6 +5,7 @@ type Body = {
orderId?: string;
supplierId?: string;
items?: Array<{ id: string }>;
note?: string;
};
export async function POST(req: NextRequest) {
@ -24,9 +25,15 @@ export async function POST(req: NextRequest) {
if (!body.items || body.items.length === 0) {
return NextResponse.json({ error: "items must be non-empty" }, { status: 400 });
}
if (typeof body.note !== "string" || body.note.trim() === "") {
return NextResponse.json(
{ error: "A note is required when marking ready for collection" },
{ status: 400 }
);
}
try {
const result = await markGroupReady(body.orderId, body.supplierId, body.items);
const result = await markGroupReady(body.orderId, body.supplierId, body.items, body.note);
return NextResponse.json(result);
} catch (err) {
return NextResponse.json(
@ -34,4 +41,4 @@ export async function POST(req: NextRequest) {
{ status: 500 }
);
}
}
}

View file

@ -5,7 +5,7 @@ import { Loader2 } from "lucide-react";
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 { MarkReadyDialog } from "@/components/ui/mark-ready-dialog";
import { SendPoModal } from "@/components/purchase-orders/send-po-modal";
import type { BuyerTaskGroup } from "@/lib/server/buyer-tasks-service";
@ -21,6 +21,8 @@ export function BuyerTasksPage() {
const [loading, setLoading] = useState(false);
const [busyGroupId, setBusyGroupId] = useState<string | null>(null);
const [markReadyFor, setMarkReadyFor] = useState<BuyerTaskGroup | null>(null);
const [readyBusy, setReadyBusy] = useState(false);
const [readyError, setReadyError] = useState<string | 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);
@ -98,15 +100,16 @@ export function BuyerTasksPage() {
// lazily inside the modal).
setSendPoFor(group);
} else if (group.step === "po_sent") {
setReadyError(null);
setMarkReadyFor(group);
}
}
async function handleMarkReadyConfirm() {
async function handleMarkReadyConfirm(note: string) {
if (!markReadyFor) return;
const group = markReadyFor;
setMarkReadyFor(null);
setBusyGroupId(group.groupId);
setReadyBusy(true);
setReadyError(null);
try {
const res = await fetch("/api/v1/buyer-tasks/mark-ready", {
method: "POST",
@ -115,18 +118,37 @@ export function BuyerTasksPage() {
orderId: group.orderId,
supplierId: group.supplierId,
items: group.items.map((i) => ({ id: i.id })),
note,
}),
});
if (!res.ok) throw new Error(`Failed to mark ready (${res.status})`);
const body = await res.json().catch(() => ({}));
if (!res.ok) {
const message = typeof body?.error === "string" ? body.error : `Failed to mark ready (${res.status})`;
setReadyError(message);
return;
}
toast.success("Marked ready for collection");
setMarkReadyFor(null);
await fetchTab(tab);
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to mark ready");
const message = err instanceof Error ? err.message : "Failed to mark ready";
setReadyError(message);
} finally {
setBusyGroupId(null);
setReadyBusy(false);
}
}
// Build the title labels for the dialog from the group's PO + first
// item, mirroring the collection-pool usage pattern.
const poLabel = markReadyFor
? `${markReadyFor.orderNumber} · ${markReadyFor.supplierName}`
: "";
const itemLabel = markReadyFor?.items[0]?.description
? markReadyFor.items[0].description
: markReadyFor
? `${markReadyFor.itemCount} item${markReadyFor.itemCount === 1 ? "" : "s"}`
: "";
return (
<div className="space-y-4">
<BuyerTaskTabs active={tab} counts={counts} onChange={setTab} />
@ -150,11 +172,19 @@ export function BuyerTasksPage() {
))}
</div>
)}
<MarkReadyModal
<MarkReadyDialog
open={markReadyFor !== null}
onConfirm={handleMarkReadyConfirm}
onCancel={() => setMarkReadyFor(null)}
busy={false}
poLabel={poLabel}
itemLabel={itemLabel}
busy={readyBusy}
serverError={readyError}
onOpenChange={(o) => {
if (!o) {
setMarkReadyFor(null);
setReadyError(null);
}
}}
onSubmit={handleMarkReadyConfirm}
/>
{sendPoFor && (
<SendPoModal

View file

@ -1,37 +0,0 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { MarkReadyModal } from "./mark-ready-modal";
describe("MarkReadyModal", () => {
it("renders the confirmation text", () => {
render(
<MarkReadyModal
open
onConfirm={vi.fn()}
onCancel={vi.fn()}
/>
);
expect(
screen.getByText(/i spoke to this person and the stock is ready for collection/i)
).toBeInTheDocument();
});
it("calls onConfirm when confirm is clicked", () => {
const onConfirm = vi.fn();
render(<MarkReadyModal open onConfirm={onConfirm} onCancel={vi.fn()} />);
fireEvent.click(screen.getByRole("button", { name: /confirm/i }));
expect(onConfirm).toHaveBeenCalledOnce();
});
it("calls onCancel when cancel is clicked", () => {
const onCancel = vi.fn();
render(<MarkReadyModal open onConfirm={vi.fn()} onCancel={onCancel} />);
fireEvent.click(screen.getByRole("button", { name: /cancel/i }));
expect(onCancel).toHaveBeenCalledOnce();
});
it("does not render when closed", () => {
render(<MarkReadyModal open={false} onConfirm={vi.fn()} onCancel={vi.fn()} />);
expect(screen.queryByText(/i spoke to this person/i)).not.toBeInTheDocument();
});
});

View file

@ -1,60 +0,0 @@
"use client";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogClose,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
export function MarkReadyModal({
open,
onConfirm,
onCancel,
busy,
}: {
open: boolean;
onConfirm: () => void;
onCancel: () => void;
busy?: boolean;
}) {
return (
<Dialog open={open} onOpenChange={(o) => !o && onCancel()}>
<DialogContent data-testid="mark-ready-modal">
<DialogHeader>
<DialogTitle>Mark ready for collection?</DialogTitle>
<DialogDescription>
I spoke to this person and the stock is ready for collection.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose
render={
<Button
type="button"
variant="outline"
disabled={busy}
data-testid="mark-ready-cancel"
/>
}
>
Cancel
</DialogClose>
<Button
type="button"
onClick={onConfirm}
disabled={busy}
className="bg-purple-600 hover:bg-purple-700 text-white"
data-testid="mark-ready-confirm"
>
Confirm
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -363,7 +363,7 @@ describe("generatePoForGroup", () => {
});
describe("markGroupReady", () => {
it("updates all items to ready_delivery", async () => {
it("updates all items to ready_delivery with the note written to notes", async () => {
const getOne = vi.fn().mockResolvedValue({ order: "order-1" });
const updateItem = vi.fn().mockResolvedValue({});
const admin = {
@ -372,15 +372,50 @@ describe("markGroupReady", () => {
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
const { markGroupReady } = await import("./buyer-tasks-service");
const result = await markGroupReady("order-1", "sup-1", [
{ id: "item-1" },
{ id: "item-2" },
]);
const result = await markGroupReady(
"order-1",
"sup-1",
[{ id: "item-1" }, { id: "item-2" }],
"Spoke to David — pickup tomorrow 09:00"
);
expect(result.updated).toBe(2);
expect(result.failed).toEqual([]);
expect(updateItem).toHaveBeenCalledWith("item-1", { item_status: "ready_delivery" });
expect(updateItem).toHaveBeenCalledWith("item-2", { item_status: "ready_delivery" });
expect(updateItem).toHaveBeenCalledWith("item-1", {
item_status: "ready_delivery",
notes: "Spoke to David — pickup tomorrow 09:00",
});
expect(updateItem).toHaveBeenCalledWith("item-2", {
item_status: "ready_delivery",
notes: "Spoke to David — pickup tomorrow 09:00",
});
});
it("trims the note before persisting", async () => {
const getOne = vi.fn().mockResolvedValue({ order: "order-1" });
const updateItem = vi.fn().mockResolvedValue({});
const admin = {
collection: vi.fn().mockReturnValue({ getOne, update: updateItem }),
};
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
const { markGroupReady } = await import("./buyer-tasks-service");
await markGroupReady("order-1", "sup-1", [{ id: "item-1" }], " trimmed ");
expect(updateItem).toHaveBeenCalledWith("item-1", {
item_status: "ready_delivery",
notes: "trimmed",
});
});
it("throws when the note is empty after trim", async () => {
const admin = { collection: vi.fn() };
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
const { markGroupReady } = await import("./buyer-tasks-service");
await expect(
markGroupReady("order-1", "sup-1", [{ id: "item-1" }], " ")
).rejects.toThrow(/note is required/i);
});
it("returns 0 when no items provided", async () => {
@ -388,7 +423,7 @@ describe("markGroupReady", () => {
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
const { markGroupReady } = await import("./buyer-tasks-service");
const result = await markGroupReady("order-1", "sup-1", []);
const result = await markGroupReady("order-1", "sup-1", [], "note");
expect(result.updated).toBe(0);
expect(result.failed).toEqual([]);
@ -406,10 +441,12 @@ describe("markGroupReady", () => {
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
const { markGroupReady } = await import("./buyer-tasks-service");
const result = await markGroupReady("order-1", "sup-1", [
{ id: "item-1" },
{ id: "item-2" },
]);
const result = await markGroupReady(
"order-1",
"sup-1",
[{ id: "item-1" }, { id: "item-2" }],
"Note"
);
expect(result.updated).toBe(1);
expect(result.failed).toEqual([{ id: "item-2", error: "PB error" }]);

View file

@ -154,11 +154,16 @@ export async function generatePoForGroup(
export async function markGroupReady(
orderId: string,
supplierId: string,
items: Array<{ id: string }>
items: Array<{ id: string }>,
note: string
): Promise<{ updated: number; failed: Array<{ id: string; error: string }> }> {
if (!orderId || !supplierId) {
throw new Error("orderId and supplierId are required");
}
const trimmedNote = note.trim();
if (!trimmedNote) {
throw new Error("A note is required when marking ready for collection");
}
if (items.length === 0) return { updated: 0, failed: [] };
const admin = await getAdminPocketBase();
@ -179,6 +184,9 @@ export async function markGroupReady(
}
await admin.collection("order_items").update(item.id, {
item_status: "ready_delivery",
// Audit log of who the buyer spoke to / reference number
// etc. Mirrors the collection-pool MarkReadyDialog flow.
notes: trimmedNote,
});
updated += 1;
} catch (err) {