feat(collection-pool): stock lines can go back to pending and be marked ready

Back to pending on a stock line used to clear only the driver, so it stayed
in the pool as ready. Stock lines now carry a pool_pending flag (the inverse
of ready, so every existing line stays ready) and behave like customer stops:
pending badge, Mark Ready (new stock-ready route), no driver assignment while
pending. The back-to-pending route refuses to claim success when PocketBase
has no pool_pending field yet.

Needs the manual live step npm run pb:migrate:pool-pending (already run).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-10-30 22:08:19 +02:00
parent 0e9d85d536
commit 91173edbcd
10 changed files with 242 additions and 47 deletions

View file

@ -22,6 +22,7 @@
"pb:migrate:rep-trip-legs": "npx tsx scripts/migrations/2026-09-28-create-rep-trip-legs.ts",
"pb:migrate:quote-assistant": "npx tsx scripts/migrations/2026-10-16-add-quote-assistant-fields.ts",
"pb:migrate:collection-ai": "npx tsx scripts/migrations/2026-10-17-add-collection-ai.ts",
"pb:migrate:pool-pending": "npx tsx scripts/migrations/2026-10-30-add-stock-pool-pending.ts",
"pb:migrate:collection-auto-push": "npx tsx scripts/migrations/2026-10-28-add-collection-auto-push.ts",
"pb:migrate:order-assistant": "npx tsx scripts/migrations/2026-10-24-add-order-assistant-fields.ts",
"pb:migrate:po-number-unique": "npx tsx scripts/migrations/2026-10-19-po-number-unique-index.ts",

View file

@ -0,0 +1,47 @@
/**
* One-shot migration: let a stock line wait in the collection pool as
* "pending", like a customer stop that is not ready yet.
*
* stock_purchase_items.pool_pending bool (false = ready, the old behaviour)
*
* Inverted on purpose: a new bool is false on every existing row, so every
* existing stock line stays ready and nothing changes until someone presses
* Back to pending.
*
* Run with: npm run pb:migrate:pool-pending
*
* Idempotent. Manual step on the live PB, before deploying.
*/
import "./_env-loader";
import PocketBase from "pocketbase";
const PB_URL = process.env.NEXT_PUBLIC_POCKETBASE_URL || process.env.PB_URL || "http://127.0.0.1:8090";
const ADMIN_EMAIL = process.env.PB_ADMIN_EMAIL;
const ADMIN_PASSWORD = process.env.PB_ADMIN_PASSWORD;
const TAG = "[pool-pending]";
async function main() {
if (!ADMIN_EMAIL || !ADMIN_PASSWORD) {
throw new Error("Set PB_ADMIN_EMAIL and PB_ADMIN_PASSWORD (e.g. from .env.local).");
}
const pb = new PocketBase(PB_URL);
await pb.collection("_superusers").authWithPassword(ADMIN_EMAIL, ADMIN_PASSWORD);
console.log(`${TAG} Authenticated against ${PB_URL}`);
const collection = await pb.collections.getOne("stock_purchase_items");
if (collection.fields.some((f: { name: string }) => f.name === "pool_pending")) {
console.log(`${TAG} stock_purchase_items.pool_pending already exists — skipping.`);
return;
}
await pb.collections.update(collection.id, {
fields: [
...collection.fields,
{ name: "pool_pending", type: "bool", required: false, presentable: false },
],
});
console.log(`${TAG} stock_purchase_items.pool_pending added.`);
}
main().catch((err) => {
console.error(`${TAG} Migration failed:`, err);
process.exit(1);
});

View file

@ -166,7 +166,7 @@ export function PoolRow({
// to clear. Hiding it when there is nothing to undo keeps the row's
// actions honest instead of offering a button that silently does
// nothing.
const showBackToPending = isStock ? isAssigned : Boolean(row.ready_for_collection) || isAssigned;
const showBackToPending = Boolean(row.ready_for_collection) || isAssigned;
// `collection_date` on a pending stop is a FORECAST from the supplier;
// on a ready stop it's the confirmed collection day. Either way the
// row goes blue so a dated stop stands out in the list, but the badge
@ -379,20 +379,17 @@ export function PoolRow({
<span className="font-normal opacity-90">· {collectionDate.relative}</span>
</span>
)}
{/* Ready/pending badge is customer-only — stock lines
don't have a `ready_for_collection` concept. */}
{!isStock && (
<span
className={cn(
"inline-flex rounded-full px-2 py-0.5 text-xs font-medium",
row.ready_for_collection
? "bg-emerald-100 text-emerald-800 ring-1 ring-emerald-200/60 dark:bg-emerald-500/15 dark:text-emerald-300 dark:ring-emerald-400/30"
: "bg-amber-100 text-amber-800 ring-1 ring-amber-200/60 dark:bg-amber-500/15 dark:text-amber-300 dark:ring-amber-400/30",
)}
>
{readyLabel}
</span>
)}
{/* Ready/pending badge: stock lines carry it too (`pool_pending`). */}
<span
className={cn(
"inline-flex rounded-full px-2 py-0.5 text-xs font-medium",
row.ready_for_collection
? "bg-emerald-100 text-emerald-800 ring-1 ring-emerald-200/60 dark:bg-emerald-500/15 dark:text-emerald-300 dark:ring-emerald-400/30"
: "bg-amber-100 text-amber-800 ring-1 ring-amber-200/60 dark:bg-amber-500/15 dark:text-amber-300 dark:ring-amber-400/30",
)}
>
{readyLabel}
</span>
{!isStock && noteLines.length > 0 && (
<span className="group/notes relative inline-flex items-center rounded-md border bg-muted/30 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
Notes
@ -422,9 +419,9 @@ export function PoolRow({
size="sm"
variant={isAssigned ? "outline" : "default"}
onClick={() => onOpenAssign(row)}
disabled={busy || (!isStock && !row.ready_for_collection)}
disabled={busy || !row.ready_for_collection}
title={
!isStock && !row.ready_for_collection
!row.ready_for_collection
? "Mark this stop as ready before assigning"
: undefined
}
@ -435,7 +432,7 @@ export function PoolRow({
is still pending. The reverse direction ("back to pending")
is its own action below, because it also clears a driver
assignment and always demands a reason. */}
{!isStock && !row.ready_for_collection && (
{!row.ready_for_collection && (
<Button
size="sm"
variant="default"
@ -615,9 +612,7 @@ export function SupplierGroup({
const targetRows = allSelected || selectedCount === 0
? group.rows
: group.rows.filter((r) => selectedIds.has(r.id));
const unreadyTargetCount = targetRows.filter(
(r) => r.source !== "stock" && !r.ready_for_collection,
).length;
const unreadyTargetCount = targetRows.filter((r) => !r.ready_for_collection).length;
const allTargetsReady = unreadyTargetCount === 0;
const eyebrowText = (() => {

View file

@ -303,6 +303,8 @@ export default function CollectionPoolPage() {
status?: string | null;
assigned_at?: string | null;
drv_id?: string;
/** True after "Back to pending": the line waits in the pool, not ready. */
pool_pending?: boolean | null;
problem_flag?: boolean | null;
problem_type?: string | null;
problem_notes?: string | null;
@ -337,6 +339,9 @@ export default function CollectionPoolPage() {
// renders correctly when the driver has been assigned.
assigned_at: s.assigned_at ?? null,
drv_id: s.drv_id ?? "",
// Same flag customer lines use. A line without `pool_pending` (every
// existing one, or before the migration) is ready, as it always was.
ready_for_collection: !s.pool_pending,
// Problem flags ride through on stock rows too — the driver
// app reports stock-side problems on `stock_purchase_items`
// (`reportStockProblem`), and the operator needs the same red
@ -817,6 +822,24 @@ export default function CollectionPoolPage() {
setBusyId(row.id);
setReadyDialogError(null);
try {
if (row.source === "stock") {
// Stock lines live in another table: their own route.
const stockRes = await fetch(`/api/v1/collection-map/items/${row.id}/stock-ready`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ note }),
});
if (!stockRes.ok) {
const body = await stockRes.json().catch(() => ({}));
const message = body?.error || `HTTP ${stockRes.status}`;
setReadyDialogError(message);
throw new Error(message);
}
toast.success("Marked ready — note saved.");
setReadyDialogRow(null);
await load();
return;
}
const res = await fetch(`/api/v1/collection-map/items/${row.id}/assign`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },

View file

@ -220,30 +220,21 @@ describe("buildVisibleRows", () => {
expect(rows.map((row) => row.id)).toEqual(["assigned-ready"]);
});
it("keeps stock rows in the ready filter because entering the pool marks them ready", () => {
const stock = makeRow({
id: "stock-assigned",
it("treats a stock line like a customer stop: ready unless it was sent back to pending", () => {
// The page maps `pool_pending` onto ready_for_collection (a line with no flag is ready).
const ready = makeRow({
id: "stock-ready",
source: "stock",
item_status: "assigned_to_driver",
drv_id: "drv-1",
assigned_at: "2026-07-07T00:00:00.000Z",
ready_for_collection: null,
ready_for_collection: true,
});
const pending = makeRow({ id: "stock-pending", source: "stock", ready_for_collection: false });
const opts = { todayYmd: "2026-07-07", searchText: "" };
expect(
buildVisibleRows([stock], {
todayYmd: "2026-07-07",
searchText: "",
readyFilter: "ready",
}).map((row) => row.id),
).toEqual(["stock-assigned"]);
expect(
buildVisibleRows([stock], {
todayYmd: "2026-07-07",
searchText: "",
readyFilter: "unready",
}),
).toEqual([]);
expect(buildVisibleRows([ready, pending], { ...opts, readyFilter: "ready" }).map((r) => r.id)).toEqual(["stock-ready"]);
expect(buildVisibleRows([ready, pending], { ...opts, readyFilter: "unready" }).map((r) => r.id)).toEqual(["stock-pending"]);
});
it("matches order numbers in the text search", () => {

View file

@ -71,7 +71,8 @@ const ASSIGNMENT_SORT_ORDER: Record<AssignmentState, number> = {
const MARKER_TOKENS = ["marker-blue", "marker-emerald", "marker-violet", "marker-amber"] as const;
function isReadyForCollection(row: CollectionPoolViewRow): boolean {
return row.source === "stock" || row.ready_for_collection === true;
// Stock lines carry the flag too (the page maps `pool_pending` onto it).
return row.ready_for_collection === true;
}
export function getAssignmentState(

View file

@ -105,9 +105,9 @@ describe("PATCH /api/v1/collection-map/items/[id]/back-to-pending", () => {
expect(patch.assigned_at).toBe("");
});
it("writes the stock source to stock_purchase_items without a ready field", async () => {
it("writes the stock source to stock_purchase_items and marks the line pending", async () => {
getOneMock.mockResolvedValueOnce({ id: "item-1", drv_id: "user-7", notes: "" });
updateMock.mockResolvedValueOnce({ id: "item-1" });
updateMock.mockResolvedValueOnce({ id: "item-1", pool_pending: true });
const res = await PATCH(makeReq({ source: "stock", note: "wrong day" }), params);
@ -117,10 +117,21 @@ describe("PATCH /api/v1/collection-map/items/[id]/back-to-pending", () => {
expect(patch.status).toBe("collection_pool");
expect(patch.drv_id).toBe("");
expect(patch.assigned_at).toBe("");
// Stock lines have no ready_for_collection concept.
// Stock has no ready_for_collection; pool_pending is its inverse.
expect(patch.pool_pending).toBe(true);
expect(patch).not.toHaveProperty("ready_for_collection");
});
it("409s for stock when PocketBase has no pool_pending field yet (it would silently drop the write)", async () => {
getOneMock.mockResolvedValueOnce({ id: "item-1", drv_id: "user-7", notes: "" });
updateMock.mockResolvedValueOnce({ id: "item-1" });
const res = await PATCH(makeReq({ source: "stock", note: "wrong day" }), params);
expect(res.status).toBe(409);
expect((await res.json()).error).toMatch(/pb:migrate:pool-pending/);
});
it("appends to the existing audit feed instead of replacing it", async () => {
getOneMock.mockResolvedValueOnce({
id: "item-1",

View file

@ -25,11 +25,15 @@ import { buildBackToPendingNote, type NoteActor } from "@/lib/note-feed";
* status → "collection_pool"
* drv_id → ""
* assigned_at → ""
* pool_pending → true (the stock twin of "not ready")
* notes → appended audit entry
*
* Stock rows have no `ready_for_collection` concept (the pool treats
* "in the pool" as the ready state), so that field is simply not part
* of their patch.
* Stock rows have no `ready_for_collection` field. `pool_pending` is its
* inverse (true = waiting, not ready), so every existing stock line,
* which has it false, stays ready. The field comes from
* `npm run pb:migrate:pool-pending`; PocketBase silently drops writes to
* a field it doesn't have, so the route checks the field came back and
* says so instead of reporting a move that didn't happen.
*
* WHY A NOTE IS REQUIRED: the previous behaviour — the pool's
* "Mark Pending" button — sent `{ ready_for_collection: false }` with
@ -110,7 +114,7 @@ export async function PATCH(
const patch: Record<string, unknown> =
source === "stock"
? { status: "collection_pool", drv_id: "", assigned_at: "", notes }
? { status: "collection_pool", drv_id: "", assigned_at: "", pool_pending: true, notes }
: {
...normalizeOrderItemStatusInput({
item_status: "collection_pool",
@ -129,6 +133,18 @@ export async function PATCH(
const updated = await pb.collection(collectionName).update(id, patch);
// Without the field PB drops `pool_pending` and the line would stay
// "ready": say so rather than pretend (the driver is already cleared).
if (source === "stock" && !("pool_pending" in updated)) {
return NextResponse.json(
{
error:
"The driver was cleared, but stock lines can't be set to pending yet: run npm run pb:migrate:pool-pending against PocketBase.",
},
{ status: 409 },
);
}
return NextResponse.json({
ok: true,
source,

View file

@ -0,0 +1,53 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
const getOneMock = vi.fn();
const updateMock = vi.fn();
const collectionMock = vi.fn(() => ({ getOne: getOneMock, update: updateMock }));
vi.mock("@/lib/server/pocketbase-admin", () => ({
getAdminPocketBase: async () => ({ collection: collectionMock }),
}));
import { PATCH } from "./route";
import { parseStoredNotes } from "@/lib/note-feed";
const req = (body: unknown) =>
new NextRequest("http://localhost/api/v1/collection-map/items/s1/stock-ready", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const params = { params: Promise.resolve({ id: "s1" }) };
describe("PATCH /api/v1/collection-map/items/[id]/stock-ready", () => {
beforeEach(() => {
getOneMock.mockReset();
updateMock.mockReset();
collectionMock.mockClear();
});
it("400s without a note", async () => {
const res = await PATCH(req({ note: " " }), params);
expect(res.status).toBe(400);
expect(updateMock).not.toHaveBeenCalled();
});
it("404s for an unknown line", async () => {
getOneMock.mockRejectedValueOnce(new Error("nope"));
expect((await PATCH(req({ note: "ready" }), params)).status).toBe(404);
});
it("clears pool_pending on the stock line and appends a note", async () => {
getOneMock.mockResolvedValueOnce({ id: "s1", notes: "" });
updateMock.mockResolvedValueOnce({ id: "s1", pool_pending: false });
const res = await PATCH(req({ note: "supplier confirmed" }), params);
expect(res.status).toBe(200);
expect(collectionMock).toHaveBeenCalledWith("stock_purchase_items");
const patch = updateMock.mock.calls[0][1] as { pool_pending: boolean; notes: string };
expect(patch.pool_pending).toBe(false);
expect(parseStoredNotes(patch.notes).at(-1)?.body).toBe("Marked ready for collection: supplier confirmed");
});
});

View file

@ -0,0 +1,57 @@
import { NextRequest, NextResponse } from "next/server";
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
import { appendNote, type NoteActor } from "@/lib/note-feed";
/**
* Mark a pending stock line ready for collection again.
*
* The stock twin of "Mark Ready" on a customer stop: clears
* `stock_purchase_items.pool_pending` (set by Back to pending) so the line
* can be assigned to a driver. A note is required and is appended to the
* line's audit feed.
*
* Body: `{ note: string, actor?: { id, name } }`
*/
const FALLBACK_ACTOR: NoteActor = { id: "operator", name: "Operator" };
export async function PATCH(
req: NextRequest,
context: { params: Promise<{ id: string }> },
) {
try {
const { id } = await context.params;
const body = (await req.json().catch(() => ({}))) as {
note?: string;
actor?: { id?: string; name?: string };
};
const note = typeof body.note === "string" ? body.note.trim() : "";
if (note.length === 0) {
return NextResponse.json({ error: "A note is required when marking ready." }, { status: 400 });
}
if (note.length > 500) {
return NextResponse.json({ error: "Note is too long (max 500 characters)." }, { status: 400 });
}
const actor: NoteActor =
typeof body.actor?.name === "string" && body.actor.name.trim().length > 0
? { id: body.actor.id?.trim() || "operator", name: body.actor.name.trim() }
: FALLBACK_ACTOR;
const pb = await getAdminPocketBase();
const current = await pb.collection("stock_purchase_items").getOne(id).catch(() => null);
if (!current) {
return NextResponse.json({ error: `Item ${id} not found` }, { status: 404 });
}
const notes = appendNote(current.notes, {
ts: new Date().toISOString(),
actor,
category: "status",
body: `Marked ready for collection: ${note}`,
});
const updated = await pb.collection("stock_purchase_items").update(id, { pool_pending: false, notes });
return NextResponse.json({ ok: true, item: updated });
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to mark item ready";
const status = (error as Error & { status?: number }).status || 500;
return NextResponse.json({ error: message }, { status });
}
}