WFDASHDRIVER-APP/lib/pocketbase.ts
SJCoetz aadc599a9a feat: add pickup proof photos, improve performance and UX
- add optional collection_photo field to OrderItem type and backend support for pickup photo uploads
- implement in-memory + localStorage cache for order items to eliminate tab-switch loading skeletons
- update service worker to use stale-while-revalidate for navigation requests to reduce perceived lag
- fix LocationTracker to only activate when logged in and hoist to root layout for persistent tracking
- adjust InstallPrompt positioning to avoid overlapping bottom navigation
- throttle map cluster recomputation to reduce unnecessary work during pan/zoom
- clear cached items on logout to prevent cross-driver data leakage
2026-07-07 20:43:17 +02:00

326 lines
No EOL
13 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, DriverLocation } 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-07-07):
// drv_id = relation -> users (canonical "assigned driver" field)
// driver = legacy/parallel driver field (only set on 1 row)
// supplier = relation -> suppliers (singular)
// item_status real values: assigned, collection_pool, pending,
// collected, problem, ...
// 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
// in_stock = NOT IN PB YET — add before relying on it
// assigned_at = ISO timestamp, populated server-side on assignment
// problem_notes = driver-written audit trail of failed collections
// Unknown status values are NOT rejected by PB — they silently match
// nothing. Always use the exact string 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 ----------
// Filter uses `drv_id` (the canonical assigned-driver relation), not
// `driver` (which is a legacy/parallel field only set on a handful of
// rows). drv_id is the field per the project brief (2026-07-07).
export const getMyAssignedItems = async (): Promise<OrderItem[]> => {
const user = getCurrentUser();
if (!user) return [];
try {
const items = await pb.collection('order_items').getFullList<OrderItem>({
filter: `drv_id = "${user.id}" && item_status = "assigned" && ready_for_collection = true`,
expand: 'supplier,order,purchase_order',
sort: 'supplier.suppliername',
});
return items;
} catch {
const items = await pb.collection('order_items').getFullList<OrderItem>({
filter: `drv_id = "${user.id}" && item_status = "assigned" && ready_for_collection = true`,
});
return hydrateSuppliers(items);
}
};
// Backwards-compat alias — keep old call sites working during the refactor.
export const getAssignedCollections = getMyAssignedItems;
// ---------- 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" && ready_for_collection = true`,
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" && ready_for_collection = true`,
});
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);
};
// ---------- 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
export const saveDriverLocation = async (location: Omit<DriverLocation, 'id'>) => {
try {
return await pb.collection('driver_locations').create(location);
} catch {
// Silently fail - table may not exist yet
}
};
// 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: `driver = "${driverId}" && timestamp >= "${today.toISOString()}"`,
sort: 'timestamp',
});
return records as unknown as DriverLocation[];
} catch {
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;
};