WFDASHDRIVER-APP/lib/pocketbase.ts
Dave 4efce9290c feat(assigned): window by yesterday+today (driver-local timezone)
Assigned tab was previously unfiltered by date — every assigned row
in the system was shown, including items scheduled for the future
(e.g. operator pre-assigns for tomorrow, driver sees them today).

Live PB probe (2026-08-11) shows `assigned_at` is set to the
SCHEDULED pickup date, not the moment of assignment: 15 currently
assigned rows split evenly across Aug 18/19/20.

New filter: `assigned_at` must fall within a rolling 2-day window
in driver-local time (yesterday 00:00 → today 23:59:59.999). This
covers today's work plus any carryover from yesterday that didn't
get completed. Items scheduled for tomorrow or later stay hidden
until their day rolls around.

Window boundaries are computed in the driver's local timezone (this
is a browser app, so `new Date()` is local) then converted to UTC
ISO for the PB filter — works as long as driver + operator share a
timezone, which is the case for the current single-region
deployment. Empty-state copy updated so drivers understand why
nothing might show even when they know there are upcoming pickups.

check-pb-schema probe updated to match the new clauses so future
regressions fail the build instead of silently showing everything.
2026-08-18 15:46:37 +02:00

820 lines
No EOL
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<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.
export const getPoolItems = async (): Promise<OrderItem[]> => {
try {
const items = await pb.collection('order_items').getFullList<OrderItem>({
filter: `item_status = "collection_pool"`,
expand: 'supplier,order,purchase_order',
sort: 'supplier.suppliername',
});
return items;
} catch {
const items = await pb.collection('order_items').getFullList<OrderItem>({
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<OrderItem[]> => {
try {
const items = await pb.collection('order_items').getFullList<OrderItem>({
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<OrderItem>({
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<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).
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<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).
// 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<OrderItemAttachment> => {
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<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
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<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;
};