From 22bf5dab810c2ec10b1d7bf77c586c5e29b93851 Mon Sep 17 00:00:00 2001 From: Dave Date: Mon, 17 Aug 2026 22:33:10 +0200 Subject: [PATCH] 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. --- app/map/page.tsx | 18 ++- app/page.tsx | 71 +++++++-- lib/pocketbase.ts | 300 ++++++++++++++++++++++++++++++++++++ lib/types.ts | 64 ++++++++ scripts/check-pb-schema.mjs | 4 + 5 files changed, 443 insertions(+), 14 deletions(-) diff --git a/app/map/page.tsx b/app/map/page.tsx index 1a1aa0d..b5c94ed 100644 --- a/app/map/page.tsx +++ b/app/map/page.tsx @@ -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) { diff --git a/app/page.tsx b/app/page.tsx index 130c4c4..b3c556c 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -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' ); } diff --git a/lib/pocketbase.ts b/lib/pocketbase.ts index 1bfe1d1..242fb01 100644 --- a/lib/pocketbase.ts +++ b/lib/pocketbase.ts @@ -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 => { // 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 => { + const user = getCurrentUser(); + if (!user) return []; + let rows: StockPurchaseItem[] = []; + try { + rows = await pb.collection('stock_purchase_items').getFullList({ + 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({ + 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 => { + const purchaseIds = new Set(); + const stockItemIds = new Set(); + 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(); + let stockItemMap = new Map(); + let supplierMap = new Map(); + + 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 = { + 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). diff --git a/lib/types.ts b/lib/types.ts index cdc1986..19cc0f3 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -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. diff --git a/scripts/check-pb-schema.mjs b/scripts/check-pb-schema.mjs index fd6cd8c..212be5c 100644 --- a/scripts/check-pb-schema.mjs +++ b/scripts/check-pb-schema.mjs @@ -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"' }, ];