feat(pool): show out-of-stock rows in red, and let the operator clear them

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.
This commit is contained in:
Dave 2026-10-15 08:25:19 +02:00
parent cf8f766d80
commit c5e6ce63a2
3 changed files with 183 additions and 1 deletions

View file

@ -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 (
<li
className={cn(
"relative z-10 isolate grid grid-cols-[6px_minmax(0,1fr)]",
selected && "bg-primary/5",
// Out-of-stock rows get a red left rail + wash. The rail is
// swapped to solid red so the row reads as blocked at a glance
// in a long list; the "Out of stock" pill below carries the
// meaning so colour is never the only signal.
isOutOfStock && "bg-destructive/[0.06] [&>span:first-child]:bg-destructive",
)}
aria-busy={busy}
>
@ -256,6 +270,19 @@ export function PoolRow({
<span className={cn("inline-flex rounded-full px-2 py-0.5 text-xs font-medium", assignmentBadge.className)}>
{assignmentBadge.label}
</span>
{/* 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 && (
<span
data-od-id="row-out-of-stock-badge"
className="inline-flex items-center gap-1 rounded-full bg-destructive px-2 py-0.5 text-xs font-semibold text-destructive-foreground"
>
<AlertTriangle className="size-3" aria-hidden />
Out of stock
</span>
)}
{/* 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"}
</Button>
)}
{/* 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 && (
<Button
size="sm"
variant="outline"
onClick={() => onMarkBackInStock(row)}
disabled={busy}
data-od-id="row-mark-back-in-stock"
className="border-destructive/50 text-destructive hover:bg-destructive hover:text-destructive-foreground"
>
<PackageCheck className="mr-1.5 size-3.5" aria-hidden />
Mark back in stock
</Button>
)}
{/* 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}
/>
))}

View file

@ -73,6 +73,16 @@ type CollectionPoolRow = Partial<Omit<OrderItem, "item_status" | "assigned_at">>
* "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 (
<div className="space-y-6">
<header className="flex items-start justify-between gap-4">
@ -862,6 +912,7 @@ export default function CollectionPoolPage() {
onOpenAssign={openAssignForRow}
onOpenBulkAssign={openBulkAssign}
onToggleReady={handleToggleReady}
onMarkBackInStock={handleMarkBackInStock}
onMarkStockCollected={handleMarkStockCollected}
/>
))

View file

@ -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<string, unknown> = {
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;