- fix driver_locations schema mismatch: use `relation` instead of `driver` per live PB schema - remove invalid `ready_for_collection` filter that caused silent 400 errors - add order_item_attachments support for driver pickup photos with proper error handling: * replace legacy collection_photo field with new collection for multiple per-item files * upload photos after updating item status to avoid stranding assigned items * show warning toast if upload fails instead of success confirmation * update toast messages to reflect photo upload status - add pre-build schema check script to catch invalid PB field references pre-deploy - add corresponding npm scripts and prebuild hook for the schema check - add e2e test scripts for collection and upload flows - update type definitions and pocketbase SDK helpers for new schema and functionality
391 lines
No EOL
15 KiB
TypeScript
391 lines
No EOL
15 KiB
TypeScript
import PocketBase from 'pocketbase';
|
||
import {
|
||
AppSettings,
|
||
DEFAULT_SETTINGS,
|
||
OrderItem,
|
||
OrderItemAttachment,
|
||
AttachmentSource,
|
||
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-08):
|
||
// 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
|
||
// ready_for_collection = NOT IN PB YET (2026-07-08) — do NOT filter on it;
|
||
// referencing any field PB doesn't have throws 400.
|
||
// item_status alone ("assigned" / "collection_pool")
|
||
// is sufficient for the current driver/pool queries.
|
||
// 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"`,
|
||
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"`,
|
||
});
|
||
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"`,
|
||
expand: 'supplier,order,purchase_order',
|
||
sort: 'supplier.suppliername',
|
||
});
|
||
return items;
|
||
} catch {
|
||
const items = await pb.collection('order_items').getFullList<OrderItem>({
|
||
filter: `item_status = "collection_pool"`,
|
||
});
|
||
return hydrateSuppliers(items);
|
||
}
|
||
};
|
||
|
||
// ---------- 3. PROBLEMS — every flagged item in the system ----------
|
||
// Items with problem_flag=true OR item_status='problem'. No driver
|
||
// filter — problems are operational visibility, not personal lists.
|
||
export const getProblemItems = async (): Promise<OrderItem[]> => {
|
||
try {
|
||
const items = await pb.collection('order_items').getFullList<OrderItem>({
|
||
filter: `problem_flag = true || item_status = "problem"`,
|
||
expand: 'supplier,order,purchase_order',
|
||
sort: '-updated',
|
||
});
|
||
return items;
|
||
} catch {
|
||
const items = await pb.collection('order_items').getFullList<OrderItem>({
|
||
filter: `problem_flag = true || item_status = "problem"`,
|
||
});
|
||
return hydrateSuppliers(items);
|
||
}
|
||
};
|
||
|
||
// Update order item status when collected. This is the canonical
|
||
// driver-side collection writer — the dashboard's
|
||
// `/api/v1/order-items/[id]/status` route covers the same patch from
|
||
// the operator side, so keep the field set aligned.
|
||
//
|
||
// Status flow on collection:
|
||
// - Always sets:
|
||
// * `item_status = "collected"` (operator-side)
|
||
// * `drv_status = "collected"` (driver-side status machine)
|
||
// * `drv_collected_at = now` (timestamp of physical pickup)
|
||
// * `qty_collected = receivedQty` (audit: actual qty picked up)
|
||
// - Always sets `collection_photo` if a file was supplied.
|
||
// - Quantity-mismatch behaviour:
|
||
// * Full (received === expected) → no extra flags. Clean collection.
|
||
// * Partial (0 < received < expected) → `problem_flag = true`,
|
||
// `problem_type = "stock"`, and a stock-shortage note in
|
||
// `problem_notes`. The operator still sees the item as
|
||
// collected but the problem flag surfaces it in the Problems
|
||
// tab so a follow-up can be planned.
|
||
// * Over (received > expected) → `problem_flag = true` and a
|
||
// `problem_notes` entry explaining the overage. No
|
||
// `problem_type` because it's not a stock issue — the supplier
|
||
// gave more than ordered, which can mean a packing error or a
|
||
// promo. We leave the type empty so the operator picks.
|
||
//
|
||
// The helper intentionally doesn't compute `expected` itself — the
|
||
// caller passes `expectedQty` so the source of truth for "what was
|
||
// ordered" stays at the call site (where the same number was just
|
||
// shown to the driver in the modal).
|
||
export const updateCollectionStatus = async (
|
||
id: string,
|
||
status: string,
|
||
data: {
|
||
qty_collected?: number;
|
||
notes?: string;
|
||
collected_date?: string;
|
||
photo?: File | null;
|
||
expectedQty?: number;
|
||
problem_type?: string;
|
||
problem_flag?: boolean;
|
||
}
|
||
) => {
|
||
const patch: Record<string, unknown> = {
|
||
item_status: status,
|
||
drv_status: 'collected',
|
||
drv_collected_at: new Date().toISOString(),
|
||
qty_collected: data.qty_collected,
|
||
collected_date: data.collected_date ?? new Date().toISOString(),
|
||
};
|
||
|
||
// Only forward the optional photo if a file was actually attached.
|
||
// Passing `null`/undefined to a PB file field would clear an existing
|
||
// attachment on subsequent updates, which we don't want for a
|
||
// re-collect after the first one.
|
||
if (data.photo) patch.collection_photo = data.photo;
|
||
|
||
// Notes field keeps the driver's free-text commentary.
|
||
if (data.notes !== undefined) patch.notes = data.notes;
|
||
|
||
// Quantity-mismatch handling — see the doc-comment above.
|
||
const received = typeof data.qty_collected === 'number' ? data.qty_collected : 0;
|
||
const expected = typeof data.expectedQty === 'number' ? data.expectedQty : 0;
|
||
if (expected > 0 && received > 0 && received < expected) {
|
||
// Partial — short pickup. Flag so it surfaces in Problems.
|
||
patch.problem_flag = true;
|
||
patch.problem_type = 'stock';
|
||
const userNote = (data.notes ?? '').trim();
|
||
const autoNote = `Partial collection: ${received} of ${expected} units`;
|
||
patch.problem_notes = userNote ? `${autoNote} — ${userNote}` : autoNote;
|
||
} else if (expected > 0 && received > expected) {
|
||
// Over-collection — supplier gave more than ordered. Flag without
|
||
// a stock type so the operator picks the right follow-up.
|
||
patch.problem_flag = true;
|
||
patch.problem_type = '';
|
||
const userNote = (data.notes ?? '').trim();
|
||
const autoNote = `Over collection: ${received} units received against ${expected} ordered`;
|
||
patch.problem_notes = userNote ? `${autoNote} — ${userNote}` : autoNote;
|
||
}
|
||
|
||
return await pb.collection('order_items').update(id, patch);
|
||
};
|
||
|
||
// ---------- Attachments ----------
|
||
// Upload a file (typically a pickup photo) as an OrderItemAttachment
|
||
// row in PB's `order_item_attachments` collection. Mirrors the
|
||
// quote_item_attachments flow: parent via the `order_item` relation,
|
||
// file carried in the `file` field, plus content_type / size_bytes
|
||
// for the operator side to surface in their list view.
|
||
//
|
||
// Returns the new attachment record. Throws on PB errors so the
|
||
// caller can roll back the status change (otherwise the driver
|
||
// sees a "Collected" toast while the photo is lost).
|
||
export const uploadOrderItemAttachment = async (params: {
|
||
orderItemId: string;
|
||
file: File;
|
||
source?: AttachmentSource;
|
||
notes?: string;
|
||
}): Promise<OrderItemAttachment> => {
|
||
const { orderItemId, file, source = 'driver_proof', notes } = params;
|
||
|
||
// Build a FormData payload. PB's create() accepts a plain object
|
||
// with the file property, which it then serializes as multipart.
|
||
const formData = new FormData();
|
||
formData.append('order_item', orderItemId);
|
||
formData.append('filename', file.name);
|
||
formData.append('file', file);
|
||
formData.append('content_type', file.type || '');
|
||
if (typeof file.size === 'number') {
|
||
formData.append('size_bytes', String(file.size));
|
||
}
|
||
formData.append('source', source);
|
||
if (notes) formData.append('notes', notes);
|
||
|
||
const created = await pb.collection('order_item_attachments').create(formData);
|
||
return created as unknown as OrderItemAttachment;
|
||
};
|
||
|
||
// List attachments for a single order item. Newest first.
|
||
export const listOrderItemAttachments = async (
|
||
orderItemId: string
|
||
): Promise<OrderItemAttachment[]> => {
|
||
const records = await pb.collection('order_item_attachments').getFullList({
|
||
filter: `order_item = "${orderItemId}"`,
|
||
sort: '-uploaded_at',
|
||
});
|
||
return records as unknown as OrderItemAttachment[];
|
||
};
|
||
|
||
// ---------- Display helpers ----------
|
||
// Per the project brief:
|
||
// po_description is the supplier-facing line (preferred on the PO
|
||
// pickup screen); description is the customer-facing line. We always
|
||
// show the supplier-facing line when it's there, and fall back to the
|
||
// customer-facing one for legacy rows.
|
||
export const getItemDisplayDescription = (item: OrderItem): string => {
|
||
const po = (item.po_description || '').trim();
|
||
if (po) return po;
|
||
return (item.description || '').trim() || '—';
|
||
};
|
||
|
||
// order_qty is the supplier-facing quantity (preferred on the pickup
|
||
// screen); quantity is the customer-facing quantity. Per the brief:
|
||
// order_qty "falls back to quantity if blank". We honor that order.
|
||
export const getItemDisplayQty = (item: OrderItem): number => {
|
||
const oq = item.order_qty;
|
||
if (typeof oq === 'number' && !Number.isNaN(oq) && oq > 0) return oq;
|
||
const q = item.quantity;
|
||
if (typeof q === 'number' && !Number.isNaN(q) && q > 0) return q;
|
||
return 0;
|
||
};
|
||
|
||
// ---------- Driver-side claim action ----------
|
||
// When the driver sees an open item in Pool and wants to take it:
|
||
// set drv_id = me, item_status = "assigned", assigned_at = now.
|
||
export const claimItemForDriver = async (itemId: string) => {
|
||
const user = getCurrentUser();
|
||
if (!user) throw new Error('Not signed in');
|
||
return await pb.collection('order_items').update(itemId, {
|
||
drv_id: user.id,
|
||
item_status: 'assigned',
|
||
drv_status: 'assigned',
|
||
assigned_at: new Date().toISOString(),
|
||
});
|
||
};
|
||
|
||
// Report a problem on an order item
|
||
export const reportProblem = async (
|
||
orderItemId: string,
|
||
problemType: string,
|
||
description: string
|
||
) => {
|
||
return await pb.collection('order_items').update(orderItemId, {
|
||
item_status: 'problem',
|
||
problem_type: problemType,
|
||
problem_flag: true,
|
||
problem_notes: description,
|
||
});
|
||
};
|
||
|
||
// Location tracking - tries to save, fails silently if table doesn't exist.
|
||
// IMPORTANT: PB schema (verified live 2026-07-08) stores the driver
|
||
// relation under `relation`, not `driver`. Writing to `driver` 400s and
|
||
// the row is never created — that's why the map polyline has been empty.
|
||
// If we ever migrate PB to rename the field back to `driver` (it should
|
||
// be — `relation` is a confusing name), update this + getDriverTrail.
|
||
export const saveDriverLocation = async (location: Omit<DriverLocation, 'id'>) => {
|
||
try {
|
||
return await pb.collection('driver_locations').create(location);
|
||
} catch (e) {
|
||
// Log now (was silent) — silent catches are how the `driver`-field
|
||
// bug stayed hidden for months.
|
||
console.warn('[saveDriverLocation] failed:', e instanceof Error ? e.message : e);
|
||
}
|
||
};
|
||
|
||
// Get driver's location trail for today (for map polyline).
|
||
export const getDriverTrail = async (driverId: string) => {
|
||
try {
|
||
const today = new Date();
|
||
today.setHours(0, 0, 0, 0);
|
||
const records = await pb.collection('driver_locations').getFullList({
|
||
filter: `relation = "${driverId}" && timestamp >= "${today.toISOString()}"`,
|
||
sort: 'timestamp',
|
||
});
|
||
return records as unknown as DriverLocation[];
|
||
} catch (e) {
|
||
console.warn('[getDriverTrail] failed:', e instanceof Error ? e.message : e);
|
||
return [];
|
||
}
|
||
};
|
||
|
||
// Settings helpers
|
||
const SETTINGS_KEY = '1driver_settings';
|
||
|
||
export const getSettings = (): AppSettings => {
|
||
if (typeof window === 'undefined') return DEFAULT_SETTINGS;
|
||
const saved = localStorage.getItem(SETTINGS_KEY);
|
||
if (saved) {
|
||
try {
|
||
return { ...DEFAULT_SETTINGS, ...JSON.parse(saved) };
|
||
} catch {
|
||
return DEFAULT_SETTINGS;
|
||
}
|
||
}
|
||
return DEFAULT_SETTINGS;
|
||
};
|
||
|
||
export const saveSettings = (settings: Partial<AppSettings>) => {
|
||
if (typeof window === 'undefined') return;
|
||
const current = getSettings();
|
||
const updated = { ...current, ...settings };
|
||
localStorage.setItem(SETTINGS_KEY, JSON.stringify(updated));
|
||
return updated;
|
||
}; |