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:
parent
6259ed8407
commit
6fa658df62
7 changed files with 159 additions and 128 deletions
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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" }]);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue