WFDASHDRIVER-APP/lib/types.ts
Dave 286b86162b feat(problems): say what kind of problem, and flag out-of-stock rows
Every flagged item rendered the same red triangle and the same
"Problem" pill, whichever problem it was. An out-of-stock line and a
quality complaint look identical on the list, and they need different
follow-ups — one is a phone call to the supplier, the other is a
replacement.

problem_type is a PB *select* on order_items (stock|supplier|driver|
delivery|pricing|other, plus the four values added 2026-10-14) but a
free *text* field on stock_purchase_items, so the label map is
deliberately wider than PROBLEM_TYPES: updateStockCollectionStatus
writes `stock` on its own, and stock rows can carry anything.

Out-of-stock rows also get a red left rail and a light red wash. The
pill stays the signal — colour is never the only thing carrying the
meaning, since a red wash alone is invisible to anyone who can't
distinguish it from the hover state.

Colour is deliberately NOT used to disable Collect. Stock can arrive
without anyone clearing the flag, and a driver standing in front of
the shelf is the best possible source of truth.
2026-10-15 08:24:58 +02:00

328 lines
12 KiB
TypeScript

export interface Supplier {
id: string;
suppliername: string;
email: string;
suppliernumber: string;
supplieraddress: string;
supply_tags: string;
coordinates: string; // Raw coordinates text (e.g. "-33.9249, 18.4241")
closingtime: string;
}
export interface Order {
id: string;
order_number: string;
status: string;
notes: string;
}
export interface PurchaseOrder {
id: string;
po_number: string;
status: string;
// Attached PO PDF documents (file field on the purchase_orders PB collection)
documents?: string[];
// Pre-composed email payload (subject, body, to) — driver can resend
// this to the supplier via the "Email PO" button when the supplier
// hasn't received it.
email_payload?: {
to: string;
subject: string;
body: string;
bodyText: string;
cc?: string;
bcc?: string;
};
// Linked supplier (expanded)
expand?: {
supplier?: Supplier;
};
supplier?: string;
total_amount?: number;
order?: string;
order_item?: string;
updated?: string;
}
// Discriminator that tags a row as either an `order_items` row
// (the original Assigned-tab scope) or a `stock_purchase_items`
// row merged into the same list with the same stop shape.
// Default for legacy rows is 'order' — components can treat any
// row without an explicit kind as an order row.
export type StopKind = 'order' | 'pickup';
// Raw PocketBase row shape for `stock_purchase_items`. Mirrors the
// schema verified live 2026-08-17:
// drv_id, status (booked | collection_pool | assigned_to_driver |
// collected | ''), purchase (relation -> purchases),
// stock_item (relation -> stock_items), quantity (expected),
// quantity_received (audit), description (line), sku,
// supplier_sku, unit, cost_price, problem_flag/problem_type/
// problem_notes, assigned_at, notes.
//
// The driver app never displays this raw shape — see StockPickupItem
// below for the merged view that matches OrderItem's shape so the
// existing card/modal/map components render stock pickups unchanged.
export interface StockPurchaseItem {
id: string;
purchase: string;
stock_item: string;
drv_id?: string;
// Status field names line up with the operator-side flow. The
// driver-app driver-side analog is `item_status` — we map
// status="assigned_to_driver" -> item_status="assigned" so the
// existing chip counts and filters keep working.
status: 'booked' | 'collection_pool' | 'assigned_to_driver' | 'collected' | '' | string;
description: string;
sku?: string;
supplier_sku?: string;
unit?: string;
cost_price?: number;
quantity: number;
quantity_received?: number;
notes?: string;
problem_flag?: boolean;
problem_type?: string;
problem_notes?: string;
assigned_at?: string;
expand?: {
purchase?: PurchaseOrder;
stock_item?: StockItem;
};
}
// `stock_items` row shape — needed only to type the expand side of
// `stock_purchase_items.expand.stock_item`. The driver app reads
// `stock_item.name` (display name) and `stock_item.sku` from here.
export interface StockItem {
id: string;
name: string;
description?: string;
sku?: string;
unit?: string;
selling_price?: number;
cost_price?: number;
}
export interface OrderItem {
// 'order' for the original order_items rows, 'pickup' for stock
// pickups merged from stock_purchase_items. Components that don't
// write (card/map/modal reads) can ignore this entirely.
kind?: StopKind;
id: string;
order: string;
// Customer-facing line. Use this for label / list / customer view.
description: string;
// Supplier-facing line. Use this on the PO pickup screen — supplier
// paperwork references this exact wording. Falls back to description.
po_description?: string;
// Customer-side quantity. Nullable on legacy rows.
quantity: number;
// Supplier-side quantity. Per the brief, falls back to quantity when
// blank (zero is treated as blank in getItemDisplayQty).
order_qty?: number;
// Pricing — operator-side (cost), customer-side (selling), and audit
// source ('quote' | 'catalogue' | 'manual').
cost_price?: number;
selling_price?: number | null;
pricing_source?: 'quote' | 'catalogue' | 'manual' | string;
matched_quote_item?: string;
// Supplier part number. Use this on PO paperwork over description.
sku?: string;
// Workflow states. item_status is the operator-side state, drv_status
// is the driver-side state. They're independent enums — an item can
// be item_status='collected' (operator marked it received) while
// drv_status is still 'en_route' (driver hasn't checked in), etc.
item_status:
| 'pending'
| 'processed'
| 'po_generated'
| 'collection_pool'
| 'assigned'
| 'collected'
| 'stock_verified'
| 'picking'
| 'ready_delivery'
| 'delivered'
| 'cancelled'
| 'invoiced'
| 'problem'
| string;
drv_status?: 'unassigned' | 'assigned' | 'en_route' | 'collected' | 'failed' | string;
driver_status?: string;
delivery_status?: 'pending' | 'ready' | 'in_transit' | 'delivered' | 'failed';
invoice_status?: 'pending' | 'invoiced' | 'paid' | 'cancelled';
// Driver assignment. The canonical driver field per the brief is
// `drv_id` (relation -> users); the older `driver` field is a legacy
// shadow that only a handful of rows set. Use drv_id in every filter.
drv_id?: string;
driver?: string;
// Server-side ISO timestamp of when drv_id was set. Surface in detail.
assigned_at?: string;
// Audit trail of failed-collection attempts. The driver writes here.
// Distinct from notes (operator-side).
notes?: string;
problem_notes?: string;
problem_flag?: boolean;
problem_type?: 'stock' | 'supplier' | 'driver' | 'delivery' | 'pricing' | 'other' | string;
// Expanded relations populated by the PB `expand` query parameter
// (supplier, order, purchase_order). PocketBase attaches this object
// at fetch time; the driver app reads it as `item.expand?.supplier`
// etc. Not a stored column — synthesized client-side.
expand?: {
supplier?: Supplier;
order?: Order;
purchase_order?: PurchaseOrder;
};
// Pickup proof files. Two storage paths exist in PB; the runtime
// currently writes to the legacy file field on the order_items row
// (see `uploadOrderItemAttachment` in pocketbase.ts, which appends
// to `order_item_attachements+` with the `+` so each upload is
// preserved alongside previous ones rather than overwriting). The
// separate `order_item_attachments` collection (one row per file,
// schema documented near the bottom of this file) exists in PB and
// is exercised by the e2e probes, but the driver app does not write
// to it yet — migration is in flight, not complete. The misspelling
// `order_item_attachements` is the literal PB field name; do not
// "fix" it without a coordinated PB migration.
// (The legacy single-photo `collection_photo` slot is also still
// declared on the row but no longer written by the runtime — see
// handleCollect passing `photo: null` for that reason.)
order_item_attachements?: string[];
// Per-row quantities used for proving/dashboard math.
qty_processed?: number;
qty_assigned?: number;
qty_collected?: number;
qty_delivered?: number;
drv_assigned_at?: string;
drv_collected_at?: string;
delivered_at?: string;
markup?: number;
markup_percent?: number;
legacy_order_item_id?: string;
// Backwards-compat shim the app uses in some spots
is_stock_item?: boolean;
collected_date?: string;
collection_date?: string;
}
export interface DriverLocation {
id: string;
// PB schema note (verified live 2026-07-08): the relation field on
// `driver_locations` is named `relation`, NOT `driver`. Earlier docs
// and the type had this as `driver` — every save silently 400'd inside
// a try{}catch{} and the map polyline was always empty.
relation: string;
latitude: number;
longitude: number;
accuracy?: number;
altitude?: number;
speed?: number;
heading?: number;
timestamp: string;
}
// Attached files for a single order_item — photos taken at pickup,
// delivery note scans, supplier-side signed slips, etc. Created
// alongside the order_item row when a driver collects the item and
// optionally attaches a photo. Mirrors the `quote_item_attachments`
// schema (one row per file, parent via relation).
//
// PB collection verified 2026-07-07: order_item_attachments
// - order_item : relation -> order_items (cascadeDelete, required)
// - filename : text (required, max 500)
// - file : file (image/* + application/pdf, 20MB max)
// - content_type : text (optional, e.g. "image/jpeg")
// - size_bytes : number (optional)
// - source : select (driver_proof | operator_proof | manual)
// - notes : text (optional)
// - uploaded_at : autodate (server-side)
export type AttachmentSource = 'driver_proof' | 'operator_proof' | 'manual';
export interface OrderItemAttachment {
id: string;
order_item: string;
filename: string;
file: string;
content_type?: string;
size_bytes?: number;
source: AttachmentSource;
notes?: string;
uploaded_at: string;
expand?: {
order_item?: OrderItem;
};
}
export interface AppSettings {
locationInterval: number;
minimumAccuracy: number;
enableHighAccuracy: boolean;
darkMode: boolean;
fontSize: 'small' | 'normal' | 'large';
offlineMode: boolean;
}
export const DEFAULT_SETTINGS: AppSettings = {
locationInterval: 60,
minimumAccuracy: 50,
enableHighAccuracy: true,
darkMode: false,
fontSize: 'normal',
offlineMode: false,
};
export const PROBLEM_TYPES = [
{ value: 'out_of_stock', label: 'Out of Stock' },
{ value: 'partial_stock', label: 'Partial Stock' },
{ value: 'quality_issue', label: 'Quality Issue' },
{ value: 'wrong_item', label: 'Wrong Item' },
{ value: 'other', label: 'Other' },
] as const;
/**
* Stored `problem_type` -> the word we show the driver.
*
* The union is wider than PROBLEM_TYPES on purpose. `problem_type` is a
* PB **select** on `order_items` whose allowed values are
* stock | supplier | driver | delivery | pricing | other
* plus the four PROBLEM_TYPES entries we added on 2026-10-14. On
* `stock_purchase_items` the same field is a plain **text** field, so
* anything can turn up there too. `collectionStatus`/`updateStockCollectionStatus`
* write `stock` on their own. A generic "Problem" pill told the driver
* nothing about which of these they were looking at — an out-of-stock
* item and a quality complaint need different follow-ups.
*/
export const PROBLEM_TYPE_LABELS: Record<string, string> = {
out_of_stock: 'Out of stock',
partial_stock: 'Partial stock',
quality_issue: 'Quality issue',
wrong_item: 'Wrong item',
stock: 'Short / partial',
supplier: 'Supplier issue',
driver: 'Driver issue',
delivery: 'Delivery issue',
pricing: 'Pricing issue',
other: 'Other',
};
/** Badge text for a flagged item; falls back to the old generic label. */
export const problemBadgeLabel = (item: {
problem_flag?: boolean | null;
problem_type?: string | null;
}): string => {
if (!item.problem_flag) return '';
return PROBLEM_TYPE_LABELS[item.problem_type ?? ''] ?? 'Problem';
};
/**
* True only for a flagged row the driver reported as out of stock.
*
* Deliberately narrow: `stock` (partial collection) and the other
* operator-side types do NOT qualify, because the driver can't clear
* those and they don't block a re-collection the same way.
*/
export const isOutOfStock = (item: {
problem_flag?: boolean | null;
problem_type?: string | null;
}): boolean => Boolean(item.problem_flag) && item.problem_type === 'out_of_stock';