diff --git a/src/app/api/v1/buyer-tasks/mark-ready/route.test.ts b/src/app/api/v1/buyer-tasks/mark-ready/route.test.ts index f9fd48f..48699af 100644 --- a/src/app/api/v1/buyer-tasks/mark-ready/route.test.ts +++ b/src/app/api/v1/buyer-tasks/mark-ready/route.test.ts @@ -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); }); -}); +}); \ No newline at end of file diff --git a/src/app/api/v1/buyer-tasks/mark-ready/route.ts b/src/app/api/v1/buyer-tasks/mark-ready/route.ts index 0deda14..64d6c86 100644 --- a/src/app/api/v1/buyer-tasks/mark-ready/route.ts +++ b/src/app/api/v1/buyer-tasks/mark-ready/route.ts @@ -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 } ); } -} +} \ No newline at end of file diff --git a/src/components/buyer-tasks/buyer-tasks-page.tsx b/src/components/buyer-tasks/buyer-tasks-page.tsx index a2fd67d..b7fbe25 100644 --- a/src/components/buyer-tasks/buyer-tasks-page.tsx +++ b/src/components/buyer-tasks/buyer-tasks-page.tsx @@ -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(null); const [markReadyFor, setMarkReadyFor] = useState(null); + const [readyBusy, setReadyBusy] = useState(false); + const [readyError, setReadyError] = useState(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(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 (
@@ -150,11 +172,19 @@ export function BuyerTasksPage() { ))}
)} - setMarkReadyFor(null)} - busy={false} + poLabel={poLabel} + itemLabel={itemLabel} + busy={readyBusy} + serverError={readyError} + onOpenChange={(o) => { + if (!o) { + setMarkReadyFor(null); + setReadyError(null); + } + }} + onSubmit={handleMarkReadyConfirm} /> {sendPoFor && ( { - it("renders the confirmation text", () => { - render( - - ); - 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(); - fireEvent.click(screen.getByRole("button", { name: /confirm/i })); - expect(onConfirm).toHaveBeenCalledOnce(); - }); - - it("calls onCancel when cancel is clicked", () => { - const onCancel = vi.fn(); - render(); - fireEvent.click(screen.getByRole("button", { name: /cancel/i })); - expect(onCancel).toHaveBeenCalledOnce(); - }); - - it("does not render when closed", () => { - render(); - expect(screen.queryByText(/i spoke to this person/i)).not.toBeInTheDocument(); - }); -}); diff --git a/src/components/buyer-tasks/mark-ready-modal.tsx b/src/components/buyer-tasks/mark-ready-modal.tsx deleted file mode 100644 index c03af25..0000000 --- a/src/components/buyer-tasks/mark-ready-modal.tsx +++ /dev/null @@ -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 ( - !o && onCancel()}> - - - Mark ready for collection? - - I spoke to this person and the stock is ready for collection. - - - - - } - > - Cancel - - - - - - ); -} diff --git a/src/lib/server/buyer-tasks-service.test.ts b/src/lib/server/buyer-tasks-service.test.ts index b16da9a..534f475 100644 --- a/src/lib/server/buyer-tasks-service.test.ts +++ b/src/lib/server/buyer-tasks-service.test.ts @@ -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" }]); diff --git a/src/lib/server/buyer-tasks-service.ts b/src/lib/server/buyer-tasks-service.ts index 7ecab6a..aab324d 100644 --- a/src/lib/server/buyer-tasks-service.ts +++ b/src/lib/server/buyer-tasks-service.ts @@ -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) {