feat(pocketbase): merge stock_purchase_items into Assigned list

Stock pickups live in stock_purchase_items (status="assigned_to_driver"),
not order_items. Previously getMyAssignedItems() only queried order_items,
so a driver with a stock pickup assigned to them saw nothing in the
Assigned tab or Map.

- getMyAssignedStockItems() fetches stock_purchase_items with the
  matching drv_id/status filter and expands purchase.supplier +
  stock_item. A toOrderItemFromStock() reshaper puts each row onto
  the OrderItem shape so the existing card/modal/map components
  render stock pickups unchanged; a kind:'pickup' discriminator
  tags them for the write side.
- handleCollect / handleProblem / handleBulkCollect / handleBulkProblem
  branch on item.kind to PATCH stock_purchase_items instead of
  order_items (status="collected" + quantity_received on collect,
  problem_flag + problem_type + problem_notes on problem; mirrors
  the order_items quantity-mismatch auto-notes).
- Same merge on app/map/page.tsx so the stock stop lands on the map.
- scripts/check-pb-schema.mjs now covers the stock filter so a future
  PB rename is caught at prebuild.
This commit is contained in:
Dave 2026-08-17 22:33:10 +02:00
parent 8a51f4851b
commit 22bf5dab81
5 changed files with 443 additions and 14 deletions

View file

@ -6,7 +6,7 @@ import dynamic from 'next/dynamic';
import { OrderItem } from '@/lib/types';
import { useAuth } from '@/components/AuthProvider';
import BottomNav from '@/components/BottomNav';
import { getMyAssignedItems, getPoolItems, getProblemItems, isAuthenticated } from '@/lib/pocketbase';
import { getMyAssignedItems, getMyAssignedStockItems, getPoolItems, getProblemItems, isAuthenticated } from '@/lib/pocketbase';
import { getCached, setCached } from '@/lib/itemsCache';
import { cn } from '@/lib/utils';
import { RefreshCw, MapPin, AlertCircle, Package, Inbox, AlertTriangle } from 'lucide-react';
@ -93,9 +93,19 @@ export default function MapPage() {
setError('');
try {
let data: OrderItem[] = [];
if (tab === 'assigned') data = await getMyAssignedItems();
else if (tab === 'collection_pool') data = await getPoolItems();
else data = await getProblemItems();
if (tab === 'assigned') {
// Merge order_items + stock_purchase_items (the "My stops"
// tab is the same scope as the Assigned list on page.tsx).
const [orders, stock] = await Promise.all([
getMyAssignedItems(),
getMyAssignedStockItems(),
]);
data = [...orders, ...stock];
} else if (tab === 'collection_pool') {
data = await getPoolItems();
} else {
data = await getProblemItems();
}
setItems(data);
setCached(tab, data);
} catch (err) {

View file

@ -13,16 +13,21 @@ import ProblemModal from '@/components/ProblemModal';
import ItemDetailModal from '@/components/ItemDetailModal';
import {
getMyAssignedItems,
getMyAssignedStockItems,
getPoolItems,
getProblemItems,
updateCollectionStatus,
updateStockCollectionStatus,
uploadOrderItemAttachment,
reportProblem,
reportStockProblem,
isAuthenticated,
logout,
getItemDisplayQty,
bulkCollect,
bulkProblem,
bulkCollectStock,
bulkProblemStock,
} from '@/lib/pocketbase';
import { getCached, setCached } from '@/lib/itemsCache';
import { cn, clusterByProximity } from '@/lib/utils';
@ -118,7 +123,21 @@ const loadItems = useCallback(async (showLoading = true) => {
try {
let data: OrderItem[] = [];
if (statusFilter === 'assigned') {
data = await getMyAssignedItems();
// Merge order_items (the original Assigned scope) with
// stock_purchase_items (status="assigned_to_driver"). Both
// get reshaped onto the same OrderItem shape with a `kind`
// discriminator so the existing card / modal / map code
// renders them identically — only the WRITE side branches
// on kind to hit the right PB collection.
//
// Pool + Problems stay unchanged: pool is system-wide open
// claims, problems are operator-side audit flags. Stock
// pickups don't have an open-claim state.
const [orders, stock] = await Promise.all([
getMyAssignedItems(),
getMyAssignedStockItems(),
]);
data = [...orders, ...stock];
} else if (statusFilter === 'collection_pool') {
data = await getPoolItems();
} else if (statusFilter === 'problem') {
@ -225,6 +244,22 @@ const loadItems = useCallback(async (showLoading = true) => {
// modal uses, so the comparison happens against the same number
// the driver just saw on screen.
const expectedQty = getItemDisplayQty(item);
if (item.kind === 'pickup') {
// Stock-pickup stop — PATCH stock_purchase_items. No photo
// upload on this side (stock_purchase_items has no
// attachments collection wired in PB).
await updateStockCollectionStatus(item.id, {
qty_collected: receivedQty,
notes,
expectedQty,
});
setItems((prev) => prev.filter((i) => i.id !== item.id));
setShowCollectModal(false);
showToast('Stock pickup collected successfully');
return;
}
const updated = await updateCollectionStatus(item.id, 'collected', {
qty_collected: receivedQty,
notes,
@ -280,7 +315,11 @@ const loadItems = useCallback(async (showLoading = true) => {
const handleProblem = async (item: OrderItem, problemType: string, description: string) => {
try {
await reportProblem(item.id, problemType, description);
if (item.kind === 'pickup') {
await reportStockProblem(item.id, problemType, description);
} else {
await reportProblem(item.id, problemType, description);
}
setItems((prev) => prev.filter((i) => i.id !== item.id));
setShowProblemModal(false);
showToast('Problem reported successfully', 'warning');
@ -300,14 +339,21 @@ const loadItems = useCallback(async (showLoading = true) => {
const handleBulkCollect = async (poLabel: string, itemsToCollect: OrderItem[]) => {
if (itemsToCollect.length === 0) return;
try {
const result = await bulkCollect(itemsToCollect);
// Split the input by kind so order rows and stock rows go to
// their respective PB collections in one round-trip each.
const [orderResult, stockResult] = await Promise.all([
bulkCollect(itemsToCollect.filter((i) => i.kind !== 'pickup')),
bulkCollectStock(itemsToCollect),
]);
const ok = orderResult.ok + stockResult.ok;
const failed = orderResult.failed + stockResult.failed;
const ids = new Set(itemsToCollect.map((i) => i.id));
setItems((prev) => prev.filter((i) => !ids.has(i.id)));
if (result.failed === 0) {
showToast(`Collected ${result.ok} item${result.ok === 1 ? '' : 's'} · ${poLabel}`, 'success');
if (failed === 0) {
showToast(`Collected ${ok} item${ok === 1 ? '' : 's'} · ${poLabel}`, 'success');
} else {
showToast(
`Collected ${result.ok}, ${result.failed} failed · ${poLabel}`,
`Collected ${ok}, ${failed} failed · ${poLabel}`,
'warning'
);
}
@ -320,14 +366,19 @@ const loadItems = useCallback(async (showLoading = true) => {
if (itemsToFlag.length === 0) return;
const description = `Bulk-flagged from ${poLabel}`;
try {
const result = await bulkProblem(itemsToFlag, description);
const [orderResult, stockResult] = await Promise.all([
bulkProblem(itemsToFlag.filter((i) => i.kind !== 'pickup'), description),
bulkProblemStock(itemsToFlag, description),
]);
const ok = orderResult.ok + stockResult.ok;
const failed = orderResult.failed + stockResult.failed;
const ids = new Set(itemsToFlag.map((i) => i.id));
setItems((prev) => prev.filter((i) => !ids.has(i.id)));
if (result.failed === 0) {
showToast(`Flagged ${result.ok} item${result.ok === 1 ? '' : 's'} · ${poLabel}`, 'warning');
if (failed === 0) {
showToast(`Flagged ${ok} item${ok === 1 ? '' : 's'} · ${poLabel}`, 'warning');
} else {
showToast(
`Flagged ${result.ok}, ${result.failed} failed · ${poLabel}`,
`Flagged ${ok}, ${failed} failed · ${poLabel}`,
'error'
);
}

View file

@ -6,6 +6,8 @@ import {
OrderItemAttachment,
AttachmentSource,
DriverLocation,
StockPurchaseItem,
PurchaseOrder,
} from './types';
import { clearCache as clearItemsCache } from './itemsCache';
@ -136,6 +138,198 @@ export const getMyAssignedItems = async (): Promise<OrderItem[]> => {
// Backwards-compat alias — keep old call sites working during the refactor.
export const getAssignedCollections = getMyAssignedItems;
// ---------- 1b. STOCK PICKUPS — assigned stock_purchase_items ----------
// Stock pickups live in their own PB collection (`stock_purchase_items`,
// not `order_items`) with their own status enum. The driver app
// previously only queried `order_items`, so a driver with a stock
// pickup assigned to them (status="assigned_to_driver") saw nothing —
// the row never made it into the Assigned list.
//
// Live PB probe (2026-08-17):
// - drv_id on stock_purchase_items is a relation -> users (works)
// - status values present: 'booked' | 'collection_pool' |
// 'assigned_to_driver' | 'collected' | '' (no 'assigned' alias
// on this collection — the operator-side 'assigned' is on
// order_items only).
// - purchase -> purchases; purchases -> suppliers via the standard
// supplier relation, so `expand: 'purchase.supplier,stock_item'`
// brings in both supplier coords and the stock_item name/sku.
//
// The merge step below tags every returned row with kind:"pickup"
// and re-shapes it onto the OrderItem shape so the existing card /
// modal / map components render stock pickups without modification.
// Only the WRITE side (handleCollect / handleProblem in app/page.tsx)
// branches on kind — it has to PATCH `stock_purchase_items` instead
// of `order_items`.
export const getMyAssignedStockItems = async (): Promise<OrderItem[]> => {
const user = getCurrentUser();
if (!user) return [];
let rows: StockPurchaseItem[] = [];
try {
rows = await pb.collection('stock_purchase_items').getFullList<StockPurchaseItem>({
filter: `drv_id = "${user.id}" && status = "assigned_to_driver"`,
expand: 'purchase.supplier,stock_item',
sort: 'purchase.purchase_number',
});
} catch (e) {
// Fallback without expand — re-fetch each purchase + stock_item
// individually. Slower, but at least it doesn't 400 the whole tab.
console.warn('[getMyAssignedStockItems] expand failed, falling back:', e instanceof Error ? e.message : e);
try {
rows = await pb.collection('stock_purchase_items').getFullList<StockPurchaseItem>({
filter: `drv_id = "${user.id}" && status = "assigned_to_driver"`,
});
await hydrateStockExpansions(rows);
} catch {
return [];
}
}
return rows.map(toOrderItemFromStock);
};
// Re-fetch the purchase + stock_item rows for any stock_purchase_items
// row where the expand side was dropped (degraded mode).
const hydrateStockExpansions = async (rows: StockPurchaseItem[]): Promise<void> => {
const purchaseIds = new Set<string>();
const stockItemIds = new Set<string>();
for (const r of rows) {
if (r.purchase) purchaseIds.add(r.purchase);
if (r.stock_item) stockItemIds.add(r.stock_item);
}
if (purchaseIds.size === 0 && stockItemIds.size === 0) return;
let purchaseMap = new Map<string, any>();
let stockItemMap = new Map<string, any>();
let supplierMap = new Map<string, any>();
try {
if (purchaseIds.size > 0) {
const purchases = await pb.collection('purchases').getFullList({
filter: Array.from(purchaseIds).map((id) => `id = "${id}"`).join(' || '),
expand: 'supplier',
});
purchaseMap = new Map(purchases.map((p: any) => [p.id, p]));
for (const p of purchases) {
if ((p as any).expand?.supplier) {
supplierMap.set((p as any).expand.supplier.id, (p as any).expand.supplier);
} else if (p.supplier) {
try {
const sup = await pb.collection('suppliers').getOne(p.supplier);
supplierMap.set(sup.id, sup);
} catch { /* supplier missing — leave it empty */ }
}
}
}
} catch (e) {
console.warn('[hydrateStockExpansions] purchases fetch failed:', e instanceof Error ? e.message : e);
}
try {
if (stockItemIds.size > 0) {
const stockItems = await pb.collection('stock_items').getFullList({
filter: Array.from(stockItemIds).map((id) => `id = "${id}"`).join(' || '),
});
stockItemMap = new Map(stockItems.map((s: any) => [s.id, s]));
}
} catch (e) {
console.warn('[hydrateStockExpansions] stock_items fetch failed:', e instanceof Error ? e.message : e);
}
for (const r of rows) {
if (r.expand) continue;
const purchase = purchaseMap.get(r.purchase);
const supplier = purchase ? supplierMap.get(purchase.supplier) : undefined;
const stockItem = stockItemMap.get(r.stock_item);
r.expand = {
purchase: purchase || undefined,
stock_item: stockItem || undefined,
};
if (purchase && supplier) {
(purchase as any).expand = { ...((purchase as any).expand || {}), supplier };
}
}
};
// Reshape a stock_purchase_items row onto the OrderItem shape so
// the existing card / modal / map components can render stock pickups
// without modification. The `kind: 'pickup'` tag is the only signal
// downstream code (write handlers in app/page.tsx) uses to decide
// which PB collection to PATCH.
//
// Field mapping (verified against the live schema 2026-08-17):
// description <- stock.description (falls back to stock_item.name)
// po_description <- stock.description (same — stock pickups don't
// have a separate customer-facing line)
// quantity <- stock.quantity (expected)
// order_qty <- stock.quantity (mirrors quantity so existing
// getItemDisplayQty helpers work)
// item_status <- 'assigned' (we map stock status onto the
// operator-side enum so chip counts and
// problem filters keep working)
// drv_id <- stock.drv_id
// assigned_at <- stock.assigned_at
// notes <- stock.notes
// problem_* <- pass-through
// is_stock_item <- true (renders the existing "Stock" badge)
// expand.supplier <- purchase.expand.supplier (so the supplier
// grouping on the list page works)
// expand.purchase_order <- a synthesized { po_number, status,
// expand:{supplier} } from purchase so the PO
// tooltip / ItemDetailModal / PO sub-group
// bucket code keeps working
// expand.stock_item <- pass-through (lets the detail modal show the
// canonical stock-item name + sku if needed)
// qty_collected <- stock.quantity_received (audit; the modal
// pre-fills this when re-opening)
const toOrderItemFromStock = (stock: StockPurchaseItem): OrderItem => {
const purchase = stock.expand?.purchase as (PurchaseOrder & { expand?: { supplier?: any }; supplier?: string; purchase_number?: string }) | undefined;
const supplierFromPurchase = purchase?.expand?.supplier;
const stockItem = stock.expand?.stock_item;
const description = (stock.description || stockItem?.description || stockItem?.name || '').trim();
// Synthesize a purchase_order-shaped object from the purchase row.
// We do this rather than aliasing so existing components that read
// `expand.purchase_order.po_number` keep working unchanged.
const purchase_order: PurchaseOrder | undefined = purchase
? ({
id: purchase.id,
po_number: purchase.purchase_number || '',
status: purchase.status,
expand: { supplier: supplierFromPurchase },
supplier: purchase.supplier,
} as unknown as PurchaseOrder)
: undefined;
return {
kind: 'pickup',
id: stock.id,
// `order` is a required field on OrderItem but stock pickups have
// no order — empty string keeps the type satisfied without
// misleading the UI (the render path reads expand.purchase_order
// for the PO column, never raw `order`).
order: '',
description,
po_description: description,
quantity: stock.quantity || 0,
order_qty: stock.quantity || 0,
item_status: 'assigned',
drv_id: stock.drv_id,
assigned_at: stock.assigned_at,
notes: stock.notes,
problem_flag: stock.problem_flag,
problem_type: stock.problem_type as OrderItem['problem_type'],
problem_notes: stock.problem_notes,
is_stock_item: true,
sku: stock.sku || stockItem?.sku,
qty_collected: stock.quantity_received,
expand: {
supplier: supplierFromPurchase,
purchase_order,
},
} as OrderItem;
};
// ---------- 2. POOL — every collection_pool item in the system ----------
// Pool means "open for any driver to claim". The driver app shows ALL
// pool rows here so the driver can see what's available before claiming.
@ -318,6 +512,112 @@ export const bulkProblem = async (
return { ok, failed };
};
// ---------- Stock-pickup writers (mirror of the order_items ones) ----------
// The driver-side collect/problem handlers in app/page.tsx branch on
// `item.kind === 'pickup'` and call these instead of
// `updateCollectionStatus` / `reportProblem`. Mirrors the same shape:
// - collected -> status="collected", quantity_received=received
// - partial -> problem_flag + problem_type="stock" + auto note
// - over -> problem_flag + problem_type="" + auto note
// - problem -> problem_flag + problem_type + problem_notes
//
// No photo upload on the stock side — stock_purchase_items has no
// attachments file field wired in PB. Photos stay an order_items-only
// feature; the collect modal on a stock stop accepts a photo input
// but it's silently dropped here.
export const updateStockCollectionStatus = async (
id: string,
data: {
qty_collected?: number;
notes?: string;
expectedQty?: number;
}
) => {
const patch: Record<string, unknown> = {
status: 'collected',
quantity_received: data.qty_collected ?? 0,
};
if (data.notes !== undefined) patch.notes = data.notes;
const received = typeof data.qty_collected === 'number' ? data.qty_collected : 0;
const expected = typeof data.expectedQty === 'number' ? data.expectedQty : 0;
if (expected > 0 && received > 0 && received < expected) {
patch.problem_flag = true;
patch.problem_type = 'stock';
const userNote = (data.notes ?? '').trim();
const autoNote = `Partial collection: ${received} of ${expected} units`;
patch.problem_notes = userNote ? `${autoNote} — ${userNote}` : autoNote;
} else if (expected > 0 && received > expected) {
patch.problem_flag = true;
patch.problem_type = '';
const userNote = (data.notes ?? '').trim();
const autoNote = `Over collection: ${received} units received against ${expected} ordered`;
patch.problem_notes = userNote ? `${autoNote} — ${userNote}` : autoNote;
}
return await pb.collection('stock_purchase_items').update(id, patch);
};
export const reportStockProblem = async (
stockItemId: string,
problemType: string,
description: string
) => {
return await pb.collection('stock_purchase_items').update(stockItemId, {
problem_flag: true,
problem_type: problemType,
problem_notes: description,
});
};
// Bulk versions — same pattern as the order_items bulk helpers, but
// filter the input list to only stock-pickup rows so a single bulk
// action across a mixed PO (orders + stock) doesn't cross-write.
export const bulkCollectStock = async (items: OrderItem[]): Promise<{ ok: number; failed: number }> => {
const stockItems = items.filter((i) => i.kind === 'pickup');
if (stockItems.length === 0) return { ok: 0, failed: 0 };
let ok = 0;
let failed = 0;
for (const item of stockItems) {
try {
const expectedQty = getItemDisplayQty(item);
await pb.collection('stock_purchase_items').update(item.id, {
status: 'collected',
quantity_received: expectedQty,
});
ok += 1;
} catch (e) {
failed += 1;
console.warn('[bulkCollectStock] failed for', item.id, e);
}
}
return { ok, failed };
};
export const bulkProblemStock = async (
items: OrderItem[],
description: string
): Promise<{ ok: number; failed: number }> => {
const stockItems = items.filter((i) => i.kind === 'pickup');
if (stockItems.length === 0) return { ok: 0, failed: 0 };
let ok = 0;
let failed = 0;
for (const item of stockItems) {
try {
await pb.collection('stock_purchase_items').update(item.id, {
problem_flag: true,
problem_type: 'other',
problem_notes: description,
});
ok += 1;
} catch (e) {
failed += 1;
console.warn('[bulkProblemStock] failed for', item.id, e);
}
}
return { ok, failed };
};
// ---------- Attachments ----------
// Upload a pickup photo by appending it to the order_item's
// `order_item_attachements` file field (PB file field, max multi).

View file

@ -44,7 +44,71 @@ export interface PurchaseOrder {
updated?: string;
}
// Discriminator that tags a row as either an `order_items` row
// (the original Assigned-tab scope) or a `stock_purchase_items`
// row merged into the same list with the same stop shape.
// Default for legacy rows is 'order' — components can treat any
// row without an explicit kind as an order row.
export type StopKind = 'order' | 'pickup';
// Raw PocketBase row shape for `stock_purchase_items`. Mirrors the
// schema verified live 2026-08-17:
// drv_id, status (booked | collection_pool | assigned_to_driver |
// collected | ''), purchase (relation -> purchases),
// stock_item (relation -> stock_items), quantity (expected),
// quantity_received (audit), description (line), sku,
// supplier_sku, unit, cost_price, problem_flag/problem_type/
// problem_notes, assigned_at, notes.
//
// The driver app never displays this raw shape — see StockPickupItem
// below for the merged view that matches OrderItem's shape so the
// existing card/modal/map components render stock pickups unchanged.
export interface StockPurchaseItem {
id: string;
purchase: string;
stock_item: string;
drv_id?: string;
// Status field names line up with the operator-side flow. The
// driver-app driver-side analog is `item_status` — we map
// status="assigned_to_driver" -> item_status="assigned" so the
// existing chip counts and filters keep working.
status: 'booked' | 'collection_pool' | 'assigned_to_driver' | 'collected' | '' | string;
description: string;
sku?: string;
supplier_sku?: string;
unit?: string;
cost_price?: number;
quantity: number;
quantity_received?: number;
notes?: string;
problem_flag?: boolean;
problem_type?: string;
problem_notes?: string;
assigned_at?: string;
expand?: {
purchase?: PurchaseOrder;
stock_item?: StockItem;
};
}
// `stock_items` row shape — needed only to type the expand side of
// `stock_purchase_items.expand.stock_item`. The driver app reads
// `stock_item.name` (display name) and `stock_item.sku` from here.
export interface StockItem {
id: string;
name: string;
description?: string;
sku?: string;
unit?: string;
selling_price?: number;
cost_price?: number;
}
export interface OrderItem {
// 'order' for the original order_items rows, 'pickup' for stock
// pickups merged from stock_purchase_items. Components that don't
// write (card/map/modal reads) can ignore this entirely.
kind?: StopKind;
id: string;
order: string;
// Customer-facing line. Use this for label / list / customer view.

View file

@ -35,6 +35,10 @@ const FILTERS = [
{ collection: 'order_items', filter: 'drv_id = "placeholder" && item_status = "assigned"' },
{ collection: 'order_items', filter: 'item_status = "collection_pool"' },
{ collection: 'order_items', filter: 'problem_flag = true || item_status = "problem"' },
// Stock pickups live in their own collection — see
// getMyAssignedStockItems in lib/pocketbase.ts. The driver app
// was silently dropping these rows before this filter was added.
{ collection: 'stock_purchase_items', filter: 'drv_id = "placeholder" && status = "assigned_to_driver"' },
{ collection: 'driver_locations', filter: 'relation = "placeholder" && timestamp >= "2026-01-01T00:00:00Z"' },
];