import PocketBase from 'pocketbase'; import { AppSettings, DEFAULT_SETTINGS, OrderItem, OrderItemAttachment, AttachmentSource, DriverLocation, StockPurchaseItem, PurchaseOrder, } from './types'; import { clearCache as clearItemsCache } from './itemsCache'; const POCKETBASE_URL = process.env.NEXT_PUBLIC_POCKETBASE_URL || 'https://pocketbase-gnqc88suy8fqk6wyryjjdpvl.169.239.180.130.sslip.io'; export const pb = new PocketBase(POCKETBASE_URL); // Disable auto-cancellation so React re-renders don't kill in-flight requests pb.autoCancellation(false); // Auth helpers export const isAuthenticated = () => pb.authStore.isValid; export const getCurrentUser = () => pb.authStore.model; export const logout = () => { pb.authStore.clear(); // Drop cached items so the next driver who logs in on this // device doesn't briefly see the previous driver's list. clearItemsCache(); if (typeof window !== 'undefined') { window.location.href = '/login/'; } }; export const login = async (email: string, password: string) => { return await pb.collection('users').authWithPassword(email, password); }; // PB `order_items` schema (verified live 2026-08-11): // drv_id = relation -> users (canonical "assigned driver" field) // driver = NOT IN PB — confirmed via schema probe. The // operator (WFDash) app filters on this and gets // 400s silently. Must be updated to use drv_id. // supplier = relation -> suppliers (singular) // item_status real values: assigned, collection_pool, pending, // collected, problem, invoiced, ... (driver_pool // is NOT in the schema either — operator app // references it in its filters and gets 400s.) // drv_status real values: assigned, en_route, collected, failed, '' (empty), // unassigned // po_description = supplier-facing line ("M20×40 nut sku 11243…") // description = customer-facing line // order_qty = supplier-facing qty (falls back to quantity) // quantity = customer-facing qty // assigned_at = ISO timestamp, populated server-side on assignment // problem_notes = driver-written audit trail of failed collections // // Assigned-tab query: // The driver app's getMyAssignedItems filters on the canonical // drv_id + item_status="assigned" pair. Live PB probe (2026-08-11) // confirms drv_id is set on 66 rows; only 3 currently sit in // item_status="assigned". The remaining 63 have moved on in workflow // (e.g. invoiced). Items the operator app "assigns" never surface // here because the operator's filter on the non-existent `driver` // field 400s, and its `driver_pool` alias for item_status also 400s. // Operator-side fix required: write `drv_id` and // `item_status = "assigned"`. // // Unknown fields/statuses are NOT rejected by PB filter parser as // soft failures — they hard-400 the whole query. Always use the exact // field + status strings below. // ---------- Hydration helper ---------- // PB query builder can't always do the supplier expand (some deployments // strip expand support). This helper takes raw records and, if supplier // isn't already expanded, batch-fetches suppliers by id and stitches // them back into item.expand.supplier. Shared by every fetch below. const hydrateSuppliers = async (items: OrderItem[]): Promise => { const missing = Array.from(new Set( items .filter((it) => !it.expand?.supplier && (it as any).supplier) .map((it) => (it as any).supplier as string) )); if (missing.length === 0) return items; let supplierMap = new Map(); try { const filter = missing.map((id) => `id = "${id}"`).join(' || '); const suppliers = await pb.collection('suppliers').getFullList({ filter }); supplierMap = new Map(suppliers.map((s: any) => [s.id, s])); } catch { return items; } return items.map((it) => { if (it.expand?.supplier) return it; const supplierId = (it as any).supplier; const supplier = supplierId ? supplierMap.get(supplierId) : null; if (!supplier) return it; return { ...it, expand: { ...(it.expand || {}), supplier }, } as OrderItem; }); }; // ---------- 1. ASSIGNED — only items with drv_id = current user ---------- // Live PB schema probe (2026-08-11) confirmed: // - drv_id is the canonical driver-assignment relation (66 rows set). // - `driver` and `item_status = "driver_pool"` are NOT in the schema. // Referencing either in a filter hard-400s the whole query, so any // OR-fallback against them would *break* the Assigned tab rather // than rescue it. // - item_status = "assigned" is the live, working status. // - `assigned_at` is set by the operator app to the SCHEDULED pickup // date (not the moment of assignment). Items assigned "today for // tomorrow" carry assigned_at = tomorrow's date — the driver app // surfaces them tomorrow, not today. Live probe shows current // assigned rows split evenly across Aug 18/19/20 as expected. // // Assigned-day window: // The driver app shows items whose `assigned_at` falls within a // rolling 2-day window: yesterday + today (driver-local time). This // covers: // - items scheduled for today (the day's work), and // - items scheduled for yesterday (carryover that wasn't completed // — still actionable today). // Items scheduled for tomorrow or later stay hidden until their day // arrives, so the driver isn't tempted to "get ahead" of the schedule // and pickup a PO whose supplier hasn't been told it'll be picked up. // // Timezone: the window boundaries are computed in the driver's local // timezone (this is a browser app, so `new Date()` is local) and then // converted to UTC ISO strings for the PB filter. This works as long // as the driver and operator share a timezone — if they diverge, the // window rolls over at midnight in the driver's locale, not the // operator's. Acceptable for the current single-region deployment. const getAssignedDayWindow = (): { fromIso: string; toIso: string } => { const now = new Date(); // Today at 00:00 in the driver's local timezone. const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()); // Last millisecond of today (23:59:59.999) — inclusive upper bound. const todayEnd = new Date(todayStart.getTime() + 24 * 60 * 60 * 1000 - 1); // Yesterday at 00:00 — inclusive lower bound. const yesterdayStart = new Date(todayStart.getTime() - 24 * 60 * 60 * 1000); return { fromIso: yesterdayStart.toISOString(), toIso: todayEnd.toISOString(), }; }; export const getMyAssignedItems = async (): Promise => { const user = getCurrentUser(); if (!user) return []; const { fromIso, toIso } = getAssignedDayWindow(); const filter = `drv_id = "${user.id}" && item_status = "assigned" && ` + `assigned_at >= "${fromIso}" && assigned_at <= "${toIso}"`; try { const items = await pb.collection('order_items').getFullList({ filter, expand: 'supplier,order,purchase_order', sort: 'supplier.suppliername', }); return items; } catch { const items = await pb.collection('order_items').getFullList({ filter, }); return hydrateSuppliers(items); } }; // 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. // No driver filter on purpose. export const getPoolItems = async (): Promise => { try { const items = await pb.collection('order_items').getFullList({ filter: `item_status = "collection_pool"`, expand: 'supplier,order,purchase_order', sort: 'supplier.suppliername', }); return items; } catch { const items = await pb.collection('order_items').getFullList({ filter: `item_status = "collection_pool"`, }); return hydrateSuppliers(items); } }; // ---------- 3. PROBLEMS — every flagged item in the system ---------- // Items with problem_flag=true OR item_status='problem'. No driver // filter — problems are operational visibility, not personal lists. export const getProblemItems = async (): Promise => { try { const items = await pb.collection('order_items').getFullList({ filter: `problem_flag = true || item_status = "problem"`, expand: 'supplier,order,purchase_order', sort: '-updated', }); return items; } catch { const items = await pb.collection('order_items').getFullList({ filter: `problem_flag = true || item_status = "problem"`, }); return hydrateSuppliers(items); } }; // Update order item status when collected. This is the canonical // driver-side collection writer — the dashboard's // `/api/v1/order-items/[id]/status` route covers the same patch from // the operator side, so keep the field set aligned. // // Status flow on collection: // - Always sets: // * `item_status = "collected"` (operator-side) // * `drv_status = "collected"` (driver-side status machine) // * `drv_collected_at = now` (timestamp of physical pickup) // * `qty_collected = receivedQty` (audit: actual qty picked up) // - Always sets `collection_photo` if a file was supplied. // - Quantity-mismatch behaviour: // * Full (received === expected) → no extra flags. Clean collection. // * Partial (0 < received < expected) → `problem_flag = true`, // `problem_type = "stock"`, and a stock-shortage note in // `problem_notes`. The operator still sees the item as // collected but the problem flag surfaces it in the Problems // tab so a follow-up can be planned. // * Over (received > expected) → `problem_flag = true` and a // `problem_notes` entry explaining the overage. No // `problem_type` because it's not a stock issue — the supplier // gave more than ordered, which can mean a packing error or a // promo. We leave the type empty so the operator picks. // // The helper intentionally doesn't compute `expected` itself — the // caller passes `expectedQty` so the source of truth for "what was // ordered" stays at the call site (where the same number was just // shown to the driver in the modal). export const updateCollectionStatus = async ( id: string, status: string, data: { qty_collected?: number; notes?: string; collected_date?: string; photo?: File | null; expectedQty?: number; problem_type?: string; problem_flag?: boolean; } ) => { const patch: Record = { item_status: status, drv_status: 'collected', drv_collected_at: new Date().toISOString(), qty_collected: data.qty_collected, collected_date: data.collected_date ?? new Date().toISOString(), }; // Only forward the optional photo if a file was actually attached. // Passing `null`/undefined to a PB file field would clear an existing // attachment on subsequent updates, which we don't want for a // re-collect after the first one. if (data.photo) patch.collection_photo = data.photo; // Notes field keeps the driver's free-text commentary. if (data.notes !== undefined) patch.notes = data.notes; // Quantity-mismatch handling — see the doc-comment above. 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) { // Partial — short pickup. Flag so it surfaces in Problems. 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) { // Over-collection — supplier gave more than ordered. Flag without // a stock type so the operator picks the right follow-up. 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('order_items').update(id, patch); }; // ---------- Bulk helpers (no modal, used for "mark all collected" on // a PO sub-group) ---------- // // These skip the per-item modal flow (no photo, no per-item notes // prompt) and stamp each row straight to its terminal status. The // driver is presumed to have visually verified the items before // tapping "Mark all collected on PO #1234" — the PO sub-header lists // every item under it. // // Returns the count of items that were successfully transitioned. // Items that throw are logged and excluded from the count so a single // bad row doesn't poison the whole batch — the caller can decide // whether to surface a follow-up toast for the failures. export const bulkCollect = async (items: OrderItem[]): Promise<{ ok: number; failed: number }> => { let ok = 0; let failed = 0; for (const item of items) { try { const expectedQty = getItemDisplayQty(item); await pb.collection('order_items').update(item.id, { item_status: 'collected', drv_status: 'collected', drv_collected_at: new Date().toISOString(), qty_collected: expectedQty, collected_date: new Date().toISOString(), }); ok += 1; } catch (e) { failed += 1; console.warn('[bulkCollect] failed for', item.id, e); } } return { ok, failed }; }; // Flag every row as a generic problem — used by the "Flag all" action // on a PO sub-group. Each row gets the supplied note so the operator // can see what was reported in bulk (no per-item modal flow). export const bulkProblem = async ( items: OrderItem[], description: string ): Promise<{ ok: number; failed: number }> => { let ok = 0; let failed = 0; for (const item of items) { try { await pb.collection('order_items').update(item.id, { item_status: 'problem', problem_type: 'other', problem_flag: true, problem_notes: description, }); ok += 1; } catch (e) { failed += 1; console.warn('[bulkProblem] failed for', item.id, e); } } 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). // Uses the `fieldName+` append syntax so each upload is preserved // alongside any previous files rather than replacing them. // // The source/notes params are accepted for interface compatibility // but cannot be stored per-file without a separate attachment collection. // Both are safe to drop here since the driver always uses // source=driver_proof and notes go into order_items.notes separately. // // Throws on PB errors so the caller can roll back the status change. export const uploadOrderItemAttachment = async (params: { orderItemId: string; file: File; source?: AttachmentSource; notes?: string; }): Promise => { const { orderItemId, file } = params; const formData = new FormData(); // fieldName+ = append (never overwrite) existing files on the row formData.append('order_item_attachements+', file); const updated = await pb.collection('order_items').update(orderItemId, formData); // PB returns the updated order_item; extract the last filename as the // "id" for the returned OrderItemAttachment shape. const filenames: string[] = (updated as unknown as { order_item_attachements?: string[] }).order_item_attachements ?? []; const filename = filenames[filenames.length - 1] ?? file.name; return { id: filename, // no separate attachment id; filename is the key order_item: orderItemId, filename, file: filename, source: 'driver_proof', uploaded_at: new Date().toISOString(), } as unknown as OrderItemAttachment; }; // List attachment filenames for an order item by fetching the file list // from the order_item_attachements file field on the order_items row. export const listOrderItemAttachments = async ( orderItemId: string ): Promise => { const item = await pb.collection('order_items').getOne(orderItemId); const filenames: string[] = (item as unknown as { order_item_attachements?: string[] }).order_item_attachements ?? []; return filenames.map((filename) => ({ id: filename, order_item: orderItemId, filename, file: filename, source: 'driver_proof' as const, uploaded_at: '', })) as unknown as OrderItemAttachment[]; }; // ---------- Display helpers ---------- // Per the project brief: // po_description is the supplier-facing line (preferred on the PO // pickup screen); description is the customer-facing line. We always // show the supplier-facing line when it's there, and fall back to the // customer-facing one for legacy rows. export const getItemDisplayDescription = (item: OrderItem): string => { const po = (item.po_description || '').trim(); if (po) return po; return (item.description || '').trim() || '—'; }; // order_qty is the supplier-facing quantity (preferred on the pickup // screen); quantity is the customer-facing quantity. Per the brief: // order_qty "falls back to quantity if blank". We honor that order. export const getItemDisplayQty = (item: OrderItem): number => { const oq = item.order_qty; if (typeof oq === 'number' && !Number.isNaN(oq) && oq > 0) return oq; const q = item.quantity; if (typeof q === 'number' && !Number.isNaN(q) && q > 0) return q; return 0; }; // ---------- Driver-side claim action ---------- // When the driver sees an open item in Pool and wants to take it: // set drv_id = me, item_status = "assigned", assigned_at = now. export const claimItemForDriver = async (itemId: string) => { const user = getCurrentUser(); if (!user) throw new Error('Not signed in'); return await pb.collection('order_items').update(itemId, { drv_id: user.id, item_status: 'assigned', drv_status: 'assigned', assigned_at: new Date().toISOString(), }); }; // Report a problem on an order item export const reportProblem = async ( orderItemId: string, problemType: string, description: string ) => { return await pb.collection('order_items').update(orderItemId, { item_status: 'problem', problem_type: problemType, problem_flag: true, problem_notes: description, }); }; // Location tracking - tries to save, fails silently if table doesn't exist. // IMPORTANT: PB schema (verified live 2026-07-08) stores the driver // relation under `relation`, not `driver`. Writing to `driver` 400s and // the row is never created — that's why the map polyline has been empty. // If we ever migrate PB to rename the field back to `driver` (it should // be — `relation` is a confusing name), update this + getDriverTrail. export const saveDriverLocation = async (location: Omit) => { try { return await pb.collection('driver_locations').create(location); } catch (e) { // Log now (was silent) — silent catches are how the `driver`-field // bug stayed hidden for months. console.warn('[saveDriverLocation] failed:', e instanceof Error ? e.message : e); } }; // Get driver's location trail for today (for map polyline). export const getDriverTrail = async (driverId: string) => { try { const today = new Date(); today.setHours(0, 0, 0, 0); const records = await pb.collection('driver_locations').getFullList({ filter: `relation = "${driverId}" && timestamp >= "${today.toISOString()}"`, sort: 'timestamp', }); return records as unknown as DriverLocation[]; } catch (e) { console.warn('[getDriverTrail] failed:', e instanceof Error ? e.message : e); return []; } }; // Settings helpers const SETTINGS_KEY = '1driver_settings'; export const getSettings = (): AppSettings => { if (typeof window === 'undefined') return DEFAULT_SETTINGS; const saved = localStorage.getItem(SETTINGS_KEY); if (saved) { try { return { ...DEFAULT_SETTINGS, ...JSON.parse(saved) }; } catch { return DEFAULT_SETTINGS; } } return DEFAULT_SETTINGS; }; export const saveSettings = (settings: Partial) => { if (typeof window === 'undefined') return; const current = getSettings(); const updated = { ...current, ...settings }; localStorage.setItem(SETTINGS_KEY, JSON.stringify(updated)); return updated; };