WFDASHDRIVER-APP/lib/pocketbase.ts

927 lines
38 KiB
TypeScript
Raw Normal View History

import PocketBase from 'pocketbase';
import {
AppSettings,
DEFAULT_SETTINGS,
OrderItem,
OrderItemAttachment,
AttachmentSource,
DriverLocation,
StockPurchaseItem,
PurchaseOrder,
} from './types';
import { clearCache as clearItemsCache } from './itemsCache';
import { compressPhoto } from './image';
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<OrderItem[]> => {
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<string, any>();
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<OrderItem[]> => {
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<OrderItem>({
filter,
expand: 'supplier,order,purchase_order',
sort: 'supplier.suppliername',
});
return items;
} catch {
const items = await pb.collection('order_items').getFullList<OrderItem>({
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<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.
// No driver filter on purpose.
//
// Stock pickups live in their own collection (`stock_purchase_items`)
// with the pool rows surfaced as `status = "collection_pool"` there
// too. Without the second fetch, a stock pickup that's been reported
// as a problem (or any stock row that gets returned to the pool) sits
// in limbo — gone from Assigned, but invisible from Pool as well.
// Merge both collections into the same OrderItem shape so the same
// card / modal code renders them without branching on the source.
export const getPoolItems = async (): Promise<OrderItem[]> => {
let orderPool: OrderItem[] = [];
let stockPool: OrderItem[] = [];
try {
orderPool = await pb.collection('order_items').getFullList<OrderItem>({
filter: `item_status = "collection_pool"`,
expand: 'supplier,order,purchase_order',
sort: 'supplier.suppliername',
});
} catch {
orderPool = await hydrateSuppliers(
await pb.collection('order_items').getFullList<OrderItem>({
filter: `item_status = "collection_pool"`,
})
);
}
try {
const stockRows = await pb.collection('stock_purchase_items').getFullList<StockPurchaseItem>({
filter: `status = "collection_pool"`,
expand: 'purchase.supplier,stock_item',
sort: 'purchase.purchase_number',
});
stockPool = stockRows.map(toOrderItemFromStock);
} catch (e) {
console.warn('[getPoolItems] stock pool fetch failed:', e instanceof Error ? e.message : e);
}
return [...orderPool, ...stockPool];
};
// ---------- 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.
//
// Stock pickups (stock_purchase_items) get a parallel query: same
// problem_flag / status enum, mirrored onto OrderItem shape via
// toOrderItemFromStock so the Problems card UI renders them without
// a separate code path. Without this, flagged stock pickups only
// ever showed up in the Pool tab (and only after the getPoolItems
// change) — no driver ever saw a "Problems" card for a stock row.
export const getProblemItems = async (): Promise<OrderItem[]> => {
let orderProblems: OrderItem[] = [];
let stockProblems: OrderItem[] = [];
try {
orderProblems = await pb.collection('order_items').getFullList<OrderItem>({
filter: `problem_flag = true || item_status = "problem"`,
expand: 'supplier,order,purchase_order',
sort: '-updated',
});
} catch {
orderProblems = await hydrateSuppliers(
await pb.collection('order_items').getFullList<OrderItem>({
filter: `problem_flag = true || item_status = "problem"`,
})
);
}
try {
const stockRows = await pb.collection('stock_purchase_items').getFullList<StockPurchaseItem>({
filter: `problem_flag = true`,
expand: 'purchase.supplier,stock_item',
sort: '-updated',
});
stockProblems = stockRows.map(toOrderItemFromStock);
} catch (e) {
console.warn('[getProblemItems] stock fetch failed:', e instanceof Error ? e.message : e);
}
return [...orderProblems, ...stockProblems];
};
// 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<string, unknown> = {
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).
//
// Mirrors reportProblem — releases the row back into the open
// collection_pool (so it can be re-attempted) instead of parking it
// in a dead-end 'problem' status. drv_id is cleared so the row is
// unowned.
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: 'collection_pool',
drv_id: null,
assigned_at: null,
drv_status: 'unassigned',
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<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);
};
// Report a problem on a stock pickup (stock_purchase_items row).
//
// This used to set only the problem flags and leave the row in
// status='assigned_to_driver'. Combined with the Assigned-tab
// filter ignoring problem_flag, that meant a stock pickup with a
// reported problem kept reappearing in the driver's assigned list
// after every refresh — the visible "I tapped Problem and nothing
// happened" bug. Now we route it back to status='collection_pool'
// instead, mirroring the order_items flow above.
// * Drops the row off the driver's Assigned list immediately.
// * Puts it in the Pool tab (via getPoolItems, which now unions
// stock pickups too), so another driver can pick it up.
// * Keeps problem_flag=true so the operator audit trail and the
// Pool-card's red "Problem" badge both still surface the flag.
export const reportStockProblem = async (
stockItemId: string,
problemType: string,
description: string
) => {
return await pb.collection('stock_purchase_items').update(stockItemId, {
status: 'collection_pool',
drv_id: null,
assigned_at: null,
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 };
};
// Mirror of bulkProblem for the stock collection. Releases the row
// to status='collection_pool' (same convention as
// reportStockProblem) so flagged stock pickups show up in the Pool
// tab for re-attempt instead of vanishing from the driver app.
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, {
status: 'collection_pool',
drv_id: null,
assigned_at: null,
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 file is downscaled client-side first — see lib/image.ts. Uploading
// the raw camera file was what produced the run of `400 unexpected EOF`
// failures on 2026-10-12: the multi-megabyte body got truncated in
// transit on mobile data and PocketBase rejected the half-read
// multipart stream. Compressing (2000px/q80, see lib/image.ts) shrinks the photo to
// a fraction of the size and the upload completes.
//
// 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 report the failure.
export const uploadOrderItemAttachment = async (params: {
orderItemId: string;
file: File;
source?: AttachmentSource;
notes?: string;
}): Promise<OrderItemAttachment> => {
const { orderItemId, file } = params;
// Never throws — hands back the original if it can't do better, so
// a decode failure degrades to today's behaviour instead of blocking
// the collection.
const uploadFile = await compressPhoto(file);
const formData = new FormData();
// fieldName+ = append (never overwrite) existing files on the row
formData.append('order_item_attachements+', uploadFile);
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<OrderItemAttachment[]> => {
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
//
// Pools the row back into the open collection_pool instead of
// parking it in 'problem'. Rationale:
// * "Problem" was a dead-end status: the row disappeared from
// the driver's Assigned list but never appeared in a list
// where anyone could re-attempt it, so reported items just
// rotted on the operator-side Problems dashboard.
// * Setting item_status='collection_pool' puts the row straight
// into the Pool tab, open for any driver (or the same one)
// to claim via claimItemForDriver. `problem_flag` is kept true
// so the operator-side Problems view still sees it (the
// Problems filter is OR-based) and so the Pool tab renders
// the row with a red "Problem" badge — clearly flagged to
// whoever picks it up.
// * drv_id is cleared so the row no longer belongs to the
// reporter; without that, the next claim by another driver
// would silently overwrite drv_id but the row would still be
// associated with the old driver in any drv_id-only audits.
export const reportProblem = async (
orderItemId: string,
problemType: string,
description: string
) => {
return await pb.collection('order_items').update(orderItemId, {
item_status: 'collection_pool',
drv_id: null,
assigned_at: null,
drv_status: 'unassigned',
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<DriverLocation, 'id'>) => {
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<AppSettings>) => {
if (typeof window === 'undefined') return;
const current = getSettings();
const updated = { ...current, ...settings };
localStorage.setItem(SETTINGS_KEY, JSON.stringify(updated));
return updated;
};