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