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:
parent
0e9d85d536
commit
91173edbcd
10 changed files with 242 additions and 47 deletions
|
|
@ -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",
|
||||
|
|
|
|||
47
scripts/migrations/2026-10-30-add-stock-pool-pending.ts
Normal file
47
scripts/migrations/2026-10-30-add-stock-pool-pending.ts
Normal 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);
|
||||
});
|
||||
|
|
@ -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 = (() => {
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue