From c5e6ce63a2319aee993792a3d706aabb2cb19db1 Mon Sep 17 00:00:00 2001 From: Dave Date: Thu, 15 Oct 2026 08:25:19 +0200 Subject: [PATCH] feat(pool): show out-of-stock rows in red, and let the operator clear them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A driver reports an item as out of stock; the item goes back to the collection pool flagged. Then nothing could ever clear it — no route, no button, in either app. The line stayed red-flagged for good even after the supplier confirmed stock was back, and the pool had no way to say "this one's fine now". Adds a dedicated clear-problem endpoint rather than reusing /assign. /assign computes item_status + drv_status + drv_id and blanks assigned_at on every non-assign call, so routing "in stock again" through it would drag the row's assignment state along with it. This route writes the three problem fields and nothing else, so it cannot clobber a driver assignment made while the supplier was being chased. problem_notes is cleared too, deliberately. It's the driver's own words and the flag is what the UI keys off; leaving it would keep the detail popover reading "No Stock" after the operator confirmed otherwise. The Out of stock pill sits ABOVE the Ready/Pending badge, not below. A row can legitimately be both — this happens to be one right now: supplier confirmed ready, driver then found none on the shelf. "Ready · Out of stock" back to back reads as a contradiction, so out-of-stock takes the prominent slot. Problem fields now ride through on stock rows too, since the driver app reports stock-side problems on stock_purchase_items and the operator needs the same control there. --- src/app/(app)/collection-pool/components.tsx | 49 ++++++++++- src/app/(app)/collection-pool/page.tsx | 51 +++++++++++ .../items/[id]/clear-problem/route.ts | 84 +++++++++++++++++++ 3 files changed, 183 insertions(+), 1 deletion(-) create mode 100644 src/app/api/v1/collection-map/items/[id]/clear-problem/route.ts diff --git a/src/app/(app)/collection-pool/components.tsx b/src/app/(app)/collection-pool/components.tsx index ba50fa3..351b7df 100644 --- a/src/app/(app)/collection-pool/components.tsx +++ b/src/app/(app)/collection-pool/components.tsx @@ -1,7 +1,7 @@ "use client"; import Link from "next/link"; -import { AlertTriangle, CheckCircle2, ChevronDown, FileText, Loader2, Truck } from "lucide-react"; +import { AlertTriangle, CheckCircle2, ChevronDown, FileText, Loader2, PackageCheck, Truck } from "lucide-react"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; @@ -94,6 +94,7 @@ export function PoolRow({ onToggleSelected, onOpenAssign, onToggleReady, + onMarkBackInStock, onMarkStockCollected, }: { row: CollectionPoolVisibleRow; @@ -104,6 +105,7 @@ export function PoolRow({ onToggleSelected: (rowId: string) => void; onOpenAssign: (row: CollectionPoolVisibleRow) => void; onToggleReady: (row: CollectionPoolVisibleRow, ready: boolean) => void; + onMarkBackInStock: (row: CollectionPoolVisibleRow) => void; onMarkStockCollected: (row: CollectionPoolVisibleRow) => void; }) { const assignmentState = getAssignmentState(row, todayYmd); @@ -126,12 +128,24 @@ export function PoolRow({ // `collected` once the driver returns. const isStock = row.source === "stock"; const showMarkCollected = isStock && row.stock_line_status === "assigned_to_driver"; + // Driver said the supplier has none. `problem_type` is a select on + // `order_items` and free text on `stock_purchase_items`, so compare + // the string rather than importing a union. Narrow on purpose: + // `partial_stock` / `stock` etc. are NOT out-of-stock and don't + // block the operator clearing the row the same way. + const isOutOfStock = + Boolean(row.problem_flag) && (row.problem_type ?? "") === "out_of_stock"; return (
  • span:first-child]:bg-destructive", )} aria-busy={busy} > @@ -256,6 +270,19 @@ export function PoolRow({ {assignmentBadge.label} + {/* Out-of-stock badge. Deliberately above the Ready/Pending badge in + the column — "Ready" describes the supplier-confirmed + state, and reading "Ready · Out of stock" back-to-back is + confusing when the driver has already proved otherwise. */} + {isOutOfStock && ( + + + Out of stock + + )} {/* Ready/pending badge is customer-only — stock lines don't have a `ready_for_collection` concept. */} {!isStock && ( @@ -314,6 +341,23 @@ export function PoolRow({ {row.ready_for_collection ? "Mark Pending" : "Mark Ready"} )} + {/* Out-of-stock: the operator's way to put the line back in + circulation once the supplier confirms stock. Available + on stock rows too — the driver reports stock-side + problems on `stock_purchase_items`. */} + {isOutOfStock && ( + + )} {/* Stock-only: once the driver is assigned, the operator clicks "Mark Collected" when the driver returns with the goods. That flips the line from @@ -356,6 +400,7 @@ export function SupplierGroup({ onOpenAssign, onOpenBulkAssign, onToggleReady, + onMarkBackInStock, onMarkStockCollected, }: { // `group.rows` is typed as `CollectionPoolViewRow[]` in the helper, but @@ -380,6 +425,7 @@ export function SupplierGroup({ */ onOpenBulkAssign: (supplierId: string, supplierName: string, rows: CollectionPoolVisibleRow[]) => void; onToggleReady: (row: CollectionPoolVisibleRow, ready: boolean) => void; + onMarkBackInStock: (row: CollectionPoolVisibleRow) => void; onMarkStockCollected: (row: CollectionPoolVisibleRow) => void; }) { const rowIds = group.rows.map((r) => r.id); @@ -518,6 +564,7 @@ export function SupplierGroup({ onToggleSelected={onToggleRowSelected} onOpenAssign={onOpenAssign} onToggleReady={onToggleReady} + onMarkBackInStock={onMarkBackInStock} onMarkStockCollected={onMarkStockCollected} /> ))} diff --git a/src/app/(app)/collection-pool/page.tsx b/src/app/(app)/collection-pool/page.tsx index 7618cdd..bd497d5 100644 --- a/src/app/(app)/collection-pool/page.tsx +++ b/src/app/(app)/collection-pool/page.tsx @@ -73,6 +73,16 @@ type CollectionPoolRow = Partial> * "Mark Ready" button). Stock rows don't have this. */ ready_for_collection?: boolean | null; + /** + * Driver-reported problem. Present on both sources — `problem_type` + * is a PB select on `order_items` but a free-text field on + * `stock_purchase_items`, so it is typed as a plain string here. + * Drives the red out-of-stock treatment and the "Mark back in + * stock" button. + */ + problem_flag?: boolean | null; + problem_type?: string | null; + problem_notes?: string | null; expand?: { purchase_order?: { id: string; @@ -267,6 +277,9 @@ export default function CollectionPoolPage() { status?: string | null; assigned_at?: string | null; drv_id?: string; + problem_flag?: boolean | null; + problem_type?: string | null; + problem_notes?: string | null; expand?: CollectionPoolRow["expand"] & { purchase?: { id: string; @@ -298,6 +311,13 @@ export default function CollectionPoolPage() { // renders correctly when the driver has been assigned. assigned_at: s.assigned_at ?? null, drv_id: s.drv_id ?? "", + // 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 + // treatment + "Mark back in stock" control there. + problem_flag: s.problem_flag ?? false, + problem_type: s.problem_type ?? null, + problem_notes: s.problem_notes ?? null, // Stock rows don't have a customer-order relation. We // leave `expand.order` undefined so the display can omit // the customer/order cells. @@ -725,6 +745,36 @@ export default function CollectionPoolPage() { [load, readyDialogRow], ); + // Operator clears a driver-reported out-of-stock flag once the + // supplier confirms stock is back. Clears the three problem fields + // only — item_status / drv_id / assigned_at are untouched, so this + // can't clobber a driver assignment made while the supplier was + // chasing. The row simply goes from red back to normal and becomes + // assignable again. + const handleMarkBackInStock = useCallback( + async (row: CollectionPoolVisibleRow) => { + setBusyId(row.id); + try { + const res = await fetch(`/api/v1/collection-map/items/${row.id}/clear-problem`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ source: row.source === "stock" ? "stock" : "customer" }), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body?.error || `HTTP ${res.status}`); + } + toast.success("Back in stock — cleared the out-of-stock flag."); + await load(); + } catch (err) { + toast.error(err instanceof Error ? err.message : String(err)); + } finally { + setBusyId(null); + } + }, + [load], + ); + return (
    @@ -862,6 +912,7 @@ export default function CollectionPoolPage() { onOpenAssign={openAssignForRow} onOpenBulkAssign={openBulkAssign} onToggleReady={handleToggleReady} + onMarkBackInStock={handleMarkBackInStock} onMarkStockCollected={handleMarkStockCollected} /> )) diff --git a/src/app/api/v1/collection-map/items/[id]/clear-problem/route.ts b/src/app/api/v1/collection-map/items/[id]/clear-problem/route.ts new file mode 100644 index 0000000..6107119 --- /dev/null +++ b/src/app/api/v1/collection-map/items/[id]/clear-problem/route.ts @@ -0,0 +1,84 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getAdminPocketBase } from "@/lib/server/pocketbase-admin"; +import { getOrderItemsCollectionName } from "@/lib/server/orders-service"; + +/** + * Clear a driver-reported problem so the line is collectable again. + * + * The driver app reports a problem by stamping `problem_flag` + + * `problem_type` + `problem_notes` and releasing the row back to + * `collection_pool`. There was no inverse: once an item was reported + * out of stock it stayed flagged forever, because nothing in either + * app could clear it. That is what this route is for — the supplier + * says stock is back, the operator taps "Mark back in stock", and the + * red row goes away. + * + * Clears the three problem fields ONLY. It deliberately does not touch + * `item_status`, `drv_id`, `assigned_at` or `ready_for_collection`: + * the row is already unassigned in the pool (reporting released it), + * and re-sending `item_status` through the assign route would also + * blank `assigned_at` and could fight a driver assignment made in the + * meantime. Keeping the write this narrow means it cannot clobber + * anything a driver did in parallel. + * + * `problem_notes` is the driver's own words — the audit trail. It is + * dropped here on purpose: the flag is what the UI keys off, and + * leaving the note behind would keep the item's detail popover + * showing "No Stock" after the supplier confirmed otherwise. + * + * Body: `{ source?: "customer" | "stock" }` — defaults to customer. + * Customer rows write `order_items`, stock rows `stock_purchase_items`; + * the same three field names exist on both. + */ +type BodyInput = { + source?: "customer" | "stock"; +}; + +const PROBLEM_FIELDS = ["problem_flag", "problem_type", "problem_notes"] as const; + +export async function PATCH( + req: NextRequest, + context: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await context.params; + const body = (await req.json().catch(() => ({}))) as BodyInput; + const source = body.source === "stock" ? "stock" : "customer"; + + const pb = await getAdminPocketBase(); + const collectionName = + source === "stock" ? "stock_purchase_items" : getOrderItemsCollectionName(); + + const current = await pb.collection(collectionName).getOne(id).catch(() => null); + if (!current) { + return NextResponse.json( + { error: `${source === "stock" ? "Stock line" : "Order item"} ${id} not found` }, + { status: 404 }, + ); + } + + if (!current.problem_flag) { + // Already clear — return it unchanged rather than 409. The button + // is hidden when there's no flag, but a double-tap (or a stale + // tab) shouldn't surface an error to the operator. + return NextResponse.json({ ok: true, item: current, applied: null }); + } + + const patch: Record = { + problem_flag: false, + problem_type: "", + problem_notes: "", + }; + const updated = await pb.collection(collectionName).update(id, patch); + + return NextResponse.json({ ok: true, item: updated, applied: patch }); + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to clear the problem flag"; + const status = (error as Error & { status?: number }).status || 500; + return NextResponse.json({ error: message }, { status }); + } +} + +/** Fields this route is allowed to write. Exported for the unit test. */ +export const CLEARED_FIELDS = PROBLEM_FIELDS; \ No newline at end of file