The match never ran on drivers' phones: it was off by default and the build had no WFDASH address, so photos were stored but nothing collected. Now it is on for every driver (switched on once for phones saved while it was off; a later "off" sticks) and falls back to wfdash.apphub.co.za. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
941 lines
No EOL
38 KiB
TypeScript
941 lines
No EOL
38 KiB
TypeScript
import PocketBase from 'pocketbase';
|
||
import {
|
||
AI_CHECK_DEFAULT_VERSION,
|
||
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,
|
||
additional_attachments: (() => {
|
||
const raw = (purchase as { additional_attachments?: unknown }).additional_attachments;
|
||
return Array.isArray(raw) ? raw : typeof raw === 'string' && raw ? [raw] : [];
|
||
})(),
|
||
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 {
|
||
const stored = JSON.parse(saved) as Partial<AppSettings>;
|
||
const settings: AppSettings = { ...DEFAULT_SETTINGS, ...stored };
|
||
// Phones saved while the invoice match was off by default: switch it
|
||
// on once. Saved back so a driver's later "off" is kept.
|
||
if ((stored.aiCheckDefaultVersion ?? 0) < AI_CHECK_DEFAULT_VERSION) {
|
||
settings.aiCheckEnabled = true;
|
||
settings.aiCheckDefaultVersion = AI_CHECK_DEFAULT_VERSION;
|
||
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
|
||
}
|
||
return settings;
|
||
} 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;
|
||
}; |