Every pickup-photo upload on 2026-10-12 failed: 86 of 86 attempts came back `400 unexpected EOF`, median 7.6s, max 15.6s. The old comment here blamed a PocketBase `image/*` mime-detection bug, which was wrong on both counts — `order_item_attachements` has mimeTypes=null, so nothing was checking the format. Go's multipart parser was hitting end-of-body before the declared Content-Length arrived: a 3-8MB Android camera file truncated in transit on mobile data. The office side never hit this because WFDASH re-encodes to 1200px/q70 in attachment-compressor.ts before storing. This adds the same treatment on the driver side so both produce comparable images (~150-300KB, sub-second transfer). compressPhoto() never throws and never returns null — on any decode failure it hands back the original file. Compression here is an optimisation, not a correctness requirement, so it must never turn a photo that might have uploaded fine into a hard error.
927 lines
No EOL
38 KiB
TypeScript
927 lines
No EOL
38 KiB
TypeScript
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 to 1200px/q70 shrinks the same 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;
|
||
}; |