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