export interface BaseRecord { id: string; created: string; updated: string; collectionId: string; collectionName: string; } export interface Customer extends BaseRecord { customer: string; email: string; alternative_email: string; buyer_email: string; number: string; /** * PB relation → `companies`. Stored as the related record's ID * string. UI fetches with `expand: "company"` to render the company * name. A customer works for a company but may deliver to a * different address — delivery coordinates live on the customer * (`coordinates` below), not the company. */ company: string; dateadded: string; lastvisiteddate: string; /** * PB relation → `users` (auth collection). Stored as user ID. UI * fetches with `expand: "rep"` to render the user name. Sales rep * handling this customer; the order form auto-fills order.rep from * here when a customer is selected. */ rep: string; /** * Geographic location for the customer's delivery point (shaft / * site address — may differ from the parent company's location). * PB geoPoint shape: `{ lon: number, lat: number }`. Zero default * `{ lon: 0, lat: 0 }` ("Null Island") must be treated as empty in * the UI — it's PB's auto-applied fallback for an unset geoPoint. */ coordinates?: { lon: number; lat: number }; description: string; expand?: { company?: Company; rep?: User; }; } /** * Minimal user shape used for expand joins on the customer (`rep`) * field and for the user-search combobox. PB's `users` collection is * the auth collection; `id` is the user ID, `name` is display name, * `email` is login. */ export interface User { id: string; name: string; email: string; } export interface Company extends BaseRecord { company: string; address: string; vendor: string; company_email: string; company_tel: string; /** * Geographic location for future routing / delivery features. * * PB geoPoint shape: `{ lon: number, lat: number }`. Zero default * `{ lon: 0, lat: 0 }` ("Null Island") must be treated as empty in * the UI — it's PB's auto-applied fallback for an unset geoPoint. * * Customers + suppliers use a combined `coordinates: string` field * (separate convention). Companies diverge to enable PB's `within` * radius filter without re-parsing. */ coordinates?: { lon: number; lat: number }; } export interface CompanyDetails extends BaseRecord { company_name: string; address: string; number: string; email: string; vat_number: string; registration_number: string; } /** * Admin-managed categories that bucket quote_items on the /sprint * page (JEI / TOOLS / FASTENERS / OIL / HUSQVARNA - GARDEN & FORESTRY * / UNKNOWN). Each category carries three match signals: * * match_brands → highest priority — checks price_list.brand * match_supplier_tags → medium — checks supplier.supply_tags * match_keywords → fallback — scans description + SKU * * Items that match no category land in the UNKNOWN bucket — the * "needs review" pile. */ export interface Category extends BaseRecord { name: string; slug: string; match_keywords: string; match_supplier_tags: string; match_brands: string; color: string; display_order: number; is_active: boolean; } export interface Supplier extends BaseRecord { suppliername: string; email: string; suppliernumber: string; supplieraddress: string; supply_tags: string; coordinates: string; closingtime: string; } export type QuoteStatus = | "new" | "claimed" | "processed" | "emailed" | "approval_pending" | "approved" | "rejected" | "problem" | "complete" | "cancelled" | "follow_up" | "email_failed" | "unmatched_bounce" | "template" /** * Transient: the operator clicked Approve & Send Email. Next.js * set this; n8n is about to (or currently) attempting the SMTP * send. Cleared on success (→ emailed) or failure (→ mail_failed). * Visible in the UI as a spinner pill. If the quote sits here for * more than ~2 min the UI flags it as stuck. */ | "sending" /** * Terminal: the outbound send was attempted and failed. The * reason is on `email_error`. Operator can retry from the modal, * which flips status back to `sending`. Distinct from * `email_failed` (which is the inbound bounce path). */ | "mail_failed"; export interface Quote extends BaseRecord { quote_number: string; quote_reference: string; external_number: string; rfq_number: string; markup: number; customer: string; company: string; claimed_by: string; description: string; status: QuoteStatus; approved_by: string; notes: string; email_sender: string; email_subject: string; email_body: string; has_attachments: boolean; company_letterhead: string; email_delivered: boolean; email_delivered_at: string; /** When the original inbound email was received (Date header / IMAP envelope date). */ email_received_at: string; email_opened: boolean; email_opened_at: string; email_tracking_id: string; has_new_reply: boolean; last_reply_at: string; email_failed_at: string; pdf_generated_at: string; email_message_id: string; /** Date printed on the PDF (the quote's "official" date). Blank → fall back to `created`. */ quote_date: string; /** Validity period in days (e.g. 30, 60). Blank/null → default to QUOTE_VALID_DAYS in the PDF. */ validity_days: number | null; /** True for templates — reusable scaffolding, not a real customer quote. */ is_template: boolean; /** If this template was cloned from a real quote, the source quote id. */ template_source_id: string; /** If this quote was generated from a template, the template quote id. */ generated_from_template_id: string; /** User-set follow-up reminder date. Dashboard widget flags overdue. */ follow_up_due: string; /** * Email-composition fields persisted by the Approve & Send modal * (added 2026-06-23). Each is the operator's last-used value for * this quote — so opening the modal a second time shows what they * sent before, instead of starting from scratch. * * `email_subject_outbound` overrides the default `Re: {email_subject}`; * `email_message` overrides the default "please find attached" body. * Both empty falls back to the defaults at send time (n8n side). * * `attach_auth_letters` defaults true — toggles the per-letterhead * supplier authorization letter (CNL or ISHERWOOD). Requires the * `letterhead_settings.auth_letter` field to be uploaded; otherwise * it's a no-op and the modal surfaces a note. */ email_cc: string; email_bcc: string; email_subject_outbound: string; email_message: string; attach_auth_letters: boolean; /** * RFC 5322 Message-ID stamped on the outbound email by the SMTP * send. Lets the inbound monitor match future bounce / DSN * messages back to this specific outbound attempt. Empty until * the first send completes. */ email_outbound_message_id: string; /** * JSON array of `quote_attachments` record IDs that were attached * to the outbound send. Captured at send time so retry sends the * same set (not whatever is attached at retry time). Empty until * the first send completes. */ email_outbound_attachment_ids: string; /** * Last failure reason from the outbound send. Empty on success. * Cleared on every retry attempt. Visible in the modal banner * and the quote card timeline. */ email_error: string; /** * Wall-clock when status flipped to `sending`. Lets the UI flag * "stuck for >2 min" sends and operators measure SMTP turnaround. */ sending_started_at: string; expand?: { customer?: Customer; company?: Company; }; } export type QuoteAttachmentKind = "pdf" | "image" | "doc" | "other"; /** * Where a `quote_attachments` row came from / where it's going. * - "inbound" — file arrived with the original RFQ email * - "outbound" — operator uploaded it on the quote detail page to * be sent with the quote email * - "generated" — PDF rendered by `/api/v1/quotes/[id]/pdf` * * `direction` is null/undefined for rows that pre-date the * 2026-06-23 migration; the UI must tolerate that and treat them * as "inbound" by default (the migration backfills new rows, but * very old rows may still be null in dev). */ export type QuoteAttachmentDirection = "inbound" | "outbound" | "generated"; export interface QuoteAttachment extends BaseRecord { quote: string; /** * User-facing filename, e.g. "Quote-CNL-Q009638.pdf" — what we set * when uploading. NOT what PB stores on disk; PB normalises to a * slugged version in `file`. */ filename: string; /** * PB's stored file tokens. For a single-file field PB returns an * array (it allows multiple per record); we treat it as an array * here. The first element is the token to use in `/api/files/...` URLs. */ file: string[]; content_type: string; size_bytes: number; email_message_id: string; /** * Classification used by the dashboard to pick the right icon / preview. * Older attachments (and a few mid-2026 imports) were uploaded without * `kind` being set — the UI must tolerate `null` / `undefined`. */ kind?: QuoteAttachmentKind | null; uploaded_at?: string; /** * See `QuoteAttachmentDirection`. Tolerate null/undefined for * rows created before the 2026-06-23 direction-field migration. */ direction?: QuoteAttachmentDirection | null; expand?: { quote?: Quote; }; } /** * Per-item pricing-proof attachment. Lives in the * `quote_item_attachments` collection; a quote_item can have many of * these (one for the AI agent's price-list proof, one for the * operator's manual screenshot, etc.). Different from * `QuoteAttachment` which is per-**quote** (the original RFQ email, * the generated PDF, etc.) — those are context for the whole * conversation; these are audit-trail for a specific price. * * `source` is the audit tag — added 2026-07-02 when the AI agent * started attaching its own price-list proof to each row it * priced. The operator's manual uploads use `operator_proof`. */ export type QuoteItemAttachmentSource = | "agent_proof" | "operator_proof" | "manual"; export interface QuoteItemAttachment extends BaseRecord { quote_item: string; filename: string; file: string[]; content_type: string; size_bytes: number; source: QuoteItemAttachmentSource; notes: string; uploaded_at: string; /** * Identity of whoever uploaded (operator email from the * dev-identity cookie/header, or "agent" for AI uploads). * Audit only — not used for filtering. */ uploaded_by?: string; } export interface QuoteReply extends BaseRecord { quote: string; from_email: string; from_name: string; subject: string; body_plain: string; body_html: string; received_at: string; email_message_id: string; in_reply_to: string; quoted_text: string; expand?: { quote?: Quote; }; } export interface QuoteItem extends BaseRecord { quote: string; description: string; quote_number: string; quote_reference: string; quantity: number; supplier: string; cost_price: number; selling_price: number; notes: string; is_processed: boolean; markup: number; quote_pdf_id: string; /** * Flipped to true the first time the user saves the item from the * process page or quote-form. Gates the AI rewrite feature — we * don't let the LLM rewrite an item the user hasn't put their own * touch on yet (the AI-extracted descriptions from the email * pipeline are treated as drafts until a human confirms them). */ user_edited: boolean; /** * SKU / part number the buyer asked for. Operators type this on * the process page (or the n8n email extractor populates it on * the way in). "Check pricing" uses it as the join key against * `price_list.sku_normalised`. */ sku: string; /** * Audit tag for how the `cost_price` / `selling_price` values on * this row were populated. Free-text (not a select) so the set of * values can grow without a schema change. Known values: * - "manual" — operator typed the numbers directly * - "catalogue" — auto-filled from the matched price_list row * on a Check-pricing run * - "ai" — populated by the AI agent (n8n Email Monitor * or future AI workflow) reading the price list * and matching items. Surfaces a "Priced by AI" * badge on the /sprint row so the operator * knows to review before saving. * - "" (empty) — not yet decided / no match found */ pricing_source: string; /** * The `price_list.id` we matched against on the last Check-pricing * run. Lets the UI resolve the supplier, spec, and pricing * provenance without re-running the matcher. Stored as a plain * text id (not a relation) on purpose so the helper can resolve * by id without a second round-trip to PB. */ matched_price_list_id: string; /** * Free-text audit trail for the last pricing decision. Examples: * "Auto-matched from JEISA - WESTSCOTT GROUP on 2026-06-30" * "No match in catalogue — operator to follow up" * "3 candidates; picked newest match" */ pricing_notes: string; /** * Sprint page category bucket. Relation to the `categories` * collection. NULL → lands in the UNKNOWN tab in the UI. * * Optional on the type — pre-sprint code paths (PDF render, * dashboard stats, etc.) never read this and shouldn't be forced * to populate it in test fixtures. */ category?: string; /** * Sprint claim lifecycle: * unclaimed → visible on /sprint, free to claim * claimed → someone has the 30-min lock * priced → priced + saved → leaves the sprint list * awaiting_supplier → email sent to supplier, waiting on reply * send_failed → outbound email send failed; needs retry * * `priced` is set in tandem with `is_processed = true`; the sprint * list filter is `is_processed = false` so priced items disappear * from view. Optional — defaults to "unclaimed" via migration * backfill. */ claim_status?: ClaimStatus; /** Email of the user who currently has the 30-min claim. */ claimed_by?: string; /** When the claim was made. */ claimed_at?: string; /** Soft TTL — claim auto-releases at this time. 30 min default. */ claim_expires_at?: string; /** * RFC 5322 Message-ID of the outbound RFQ email we sent to the * supplier. The Supplier RFQ Monitor (n8n) matches inbound replies * by `In-Reply-To: ` against this field. */ outbound_message_id?: string; /** When the RFQ email was sent. */ outbound_sent_at?: string; /** Set by the inbound monitor when a supplier reply lands. */ supplier_replied_at?: string; /** Incremented on each inbound reply. 0 = no replies yet. */ supplier_reply_count?: number; expand?: { supplier?: Supplier; category?: Category; }; } /** * Sprint claim lifecycle states. Stored as a `select` enum on * `quote_items.claim_status` — see the migration that adds it. */ export type ClaimStatus = | "unclaimed" | "claimed" | "priced" | "awaiting_supplier" | "send_failed"; /** * Per-line state for an `order_items` record. * * This is the only authoritative status in the orders model — orders * themselves carry no stored status field. The order header "Complete" * badge in the UI is derived from this (every item === `delivered`). * * Lifecycle: * pending → item created, not yet sent to a supplier * processed → operator has reviewed/edited the line * po_generated → PO created, in_stock check happens here * collection_pool → queued for collection (in_stock=false path) * assigned → a driver has picked it up * collected → back at the office (in_stock=true skips the pool) * stock_verified → verified against stock count * picking → being pulled for delivery * ready_delivery → packed, on the delivery vehicle * delivered → signed for at the customer (terminal for delivery) * cancelled → cancelled (terminal) */ export type ItemStatus = | "pending" | "processed" | "po_generated" | "collection_pool" | "assigned" | "collected" | "stock_verified" | "picking" | "ready_delivery" | "delivered" | "cancelled" | "ready_invoice" | "invoiced" | "stock_booked"; export interface Order extends BaseRecord { order_number: string; company: string; customer: string; quote: string; notes: string; quote_matching_attempted: boolean; quote_match_confidence: number; potential_quote: string; potential_quote_confidence: number; is_repair_order: boolean; /** * Sales rep handling this order. Free-text — auto-populated from the * customer's `rep` field when a customer is selected, but always * editable. Empty for company-only orders until the operator types one. */ rep: string; /** * True once an invoice has been generated for this order. Independent * of item lifecycle — an order can be marked invoiced before, during, * or after the items have all been delivered. Set by the invoicing * action; never derived. */ invoiced: boolean; /** * The reference the operator sees on the customer's PO — typically the * quote number we issued, but it can also be a customer's own ref, * an RFQ id, or an external PO reference. Drives the in-house matcher: * we try exact matches against `quote_number` / `quote_reference` / * `rfq_number` / `external_number` first, then fall back to a fuzzy * company + customer + description + price match. */ quote_reference_text: string; /** * Audit trail for the `potential_quote` suggestion. One of: * - "exact_quote_number" * - "exact_quote_reference" * - "exact_rfq_number" * - "exact_external_number" * - "company_customer_desc_price" * - "no_match" * Free-text on purpose — the set may grow (LLM-based later) and we * don't want a schema migration per addition. */ quote_match_method: string; expand?: { company?: Company; customer?: Customer; quote?: Quote; potential_quote?: Quote; }; } export interface OrderItem extends BaseRecord { order: string; description: string; po_description: string; /** * Operator-entered quantity. Nullable — an order row can be saved with * no quantity yet (operator intends to fill it in on the process page). * Defaults to `null` in the UI; the `canSubmit` gate on /orders/new * rejects save attempts where every line is missing a quantity, so a * blank-but-not-empty record can survive the round trip to PB without * the form silently writing `0`/`1` on the operator's behalf. * * Note: the type code path is `quantity: number | null` from here on; * older order rows stored as `0` still load as `0`. */ quantity: number | null; order_qty: number; /** * Customer-facing unit price. Nullable — many orders are priced after * the operator processes the line (cost copy from quote + margin * decision). `$0` is a valid intentional value (freebie / TBD), * which is why we keep `null` separate from `0`. */ selling_price: number | null; supplier: string; cost_price: number; markup: number; purchase_order: string; item_status: ItemStatus; /** * Operator flag set on the process page. When true, the line * bypasses the driver collection pool entirely after PO generation: * the item is treated as stock-on-hand and jumps straight to * `item_status = "collected"` (or `stock_verified`) on PO send, * skipping `collection_pool` / `assigned` / `en_route`. * * Default false. Invoices and delivery notes ignore this flag — the * item is still part of the order. */ in_stock: boolean; drv_status?: "unassigned" | "assigned" | "en_route" | "collected" | "failed"; drv_id?: string; /** * Server-side ISO timestamp of the most recent assignment. Set on * the order_item row by the assignment page when an operator picks * a driver (or the agent updates via the driver app — same row * write either way). Do not use this field to derive * "currently assigned" — check `item_status === "assigned"` (or * `drv_status`) for that. `assigned_at` is the when, not the who. */ assigned_at?: string; notes: string; /** * Pricing audit-trail fields added by the 2026-07-06 * order-items-pricing-fields migration. They round-trip from the * matched quote_item when the operator hits "use this cost" on the * process page, or stay blank when the operator types the price * manually. `sku` and `pricing_source` print on the PO so the * supplier knows which part number to fulfil against. * * `pricing_source` is one of "quote" | "catalogue" | "manual" by * convention (free text — PB doesn't enforce the union at write * time). `matched_price_list_id` is the catalogue row id when * sourced from `price_list`. */ sku?: string; pricing_source?: string; matched_price_list_id?: string; pricing_notes?: string; /** * Stock split fields added by the 2026-07-14-create-stock migration. * When the operator collects a line and the supplier shipped MORE * than the customer's quantity (typical: MOQ bump at PO time), * the surplus portion is booked into stock. `surplus_qty` is how * much went to stock; `stock_booked_from` is which stock_item row * it was booked into. The actual audit trail is the matching * `stock_ledger` row(s) — query by `reference_type="order_item" * && reference_id=`. */ surplus_qty?: number; stock_booked_from?: string; expand?: { supplier?: Supplier; }; } export interface CollectionPool extends BaseRecord { order: string; order_item: string; supplier?: string; drv_id?: string; pool_status: "queued" | "assigned" | "in_progress" | "completed" | "failed"; priority?: "low" | "normal" | "high" | "urgent"; pool_notes?: string; assigned_at?: string; completed_at?: string; expand?: { order?: Order; order_item?: OrderItem; supplier?: Supplier; }; } export interface PurchaseOrderEmailPayload { /** * Operator-supplied `To` override. Empty string means "fall back to * supplier.email at send time" (resolution happens in n8n, not in * the route). When non-empty AND different from the supplier's * stored email, the service helper persists it to supplier.email * so the next send to that supplier prefills correctly. */ to?: string; /** Subject line of the outbound email. */ subject: string; /** Body of the outbound email in HTML. n8n emailSend sends this * as the rich Content-Type: text/html part. */ body: string; /** Plain-text rendering of `body`. Used as the multipart/alternative * fallback (RFC 2046 mandated) and as the text spam-filters parse * first. Both forms are produced from the same source by * `buildDefaultPoEmailBody` so they can never drift. Optional * for backwards compatibility with rows written before the HTML * upgrade. */ bodyText?: string; /** Comma-separated CC addresses. */ cc?: string; /** Comma-separated BCC addresses. */ bcc?: string; /** RFC 5322 Message-ID we sent. Mirrored on `email_message_id` for searchability. */ sentMessageId?: string; /** ISO 8601 timestamp of successful SMTP send. */ sentAt?: string; /** RFC 5322 Message-ID of the supplier's most recent reply (if any). */ supplierReplyMessageId?: string; /** First ~280 chars of the supplier's reply body, for at-a-glance scanning. */ supplierReplyPreview?: string; } export interface PurchaseOrderDocumentEntry { /** Monotonic version number (1, 2, 3, ...). Newer is greater. */ version: number; /** PB-generated filename for the file in `purchase_orders.documents`. */ file: string; /** * PB-side file token (the slugged-on-disk name) for the file in * `purchase_orders.documents`. Distinct from `file` (the * human-readable name we asked PB to store). New entries always * populate this; legacy rows may be empty — the GET handler * falls back to `documents[idx]` for those. */ token: string; /** ISO 8601 timestamp the PDF was generated. */ generatedAt: string; /** User id of the operator who triggered the regen (empty string if automated). */ generatedBy: string; /** Free-text reason the operator gave for the regen (the modal's textarea). */ reason: string; /** PB status — `current` for the latest, `superseded` for older ones. */ status: "current" | "superseded"; } export interface PurchaseOrder extends BaseRecord { po_number: string; order: string; supplier: string; status: string; total_amount: number; /** * Back-link from PO to the order_items that this PO was created * from. Mirrors `order_items.purchase_order` — written on PO * create, kept in sync when items are added/removed/reassigned. * Renders in the PB admin UI on the PO row and is used by the * "open the source order" affordance. */ order_item?: string[]; /** * Outbound email composition + supplier reply metadata. Round-trips * from the operator's Send PO modal → API route → n8n → PB → display. * Mirrors the `quote_items.specifications` JSON-field convention. */ email_payload?: PurchaseOrderEmailPayload; /** RFC 5322 Message-ID of the last successful outbound send. */ email_message_id?: string; /** ISO 8601 timestamp of the most recent supplier reply (when reply tracking is built). */ last_reply_at?: string; /** Last SMTP failure reason — surfaced in the mail_failed badge tooltip. */ email_error?: string; /** * Embedded PO documents (PDFs, signed delivery notes, etc). * Newest is last. The Send PO flow attaches the last entry. * Replaces the legacy `purchase_order_pdfs` collection. */ documents?: string[]; /** * Files attached to the PO for the supplier (supplier quote, drawing, * NDA, etc.). Sent alongside the versioned PO PDF on email. Distinct * from `documents`, which is the versioned-PDF history; mixing the two * breaks the `documents ↔ documents_history` invariant the PDF download * path depends on. * * PB file field, multi, no PB-side cap. The upload route enforces * 25 MB per file to match the rest of the app. */ additional_attachments?: string[]; /** * Audit log of every document that has ever lived on this PO. * Mirrors the structure of the legacy `purchase_order_pdfs` row * (version, file, generatedAt, generatedBy, reason, status) but as * a JSON array on the PO itself — no separate collection needed. */ documents_history?: PurchaseOrderDocumentEntry[]; expand?: { order?: Order; supplier?: Supplier; order_item?: OrderItem[]; }; } export interface DeliveryNote extends BaseRecord { delivery_note_number: string; order: string; company?: string; customer?: string; status: "draft" | "ready" | "delivered" | "cancelled"; delivery_date?: string; notes?: string; expand?: { order?: Order; company?: Company; customer?: Customer; }; } export interface DeliveryNoteItem extends BaseRecord { delivery_note: string; order_item: string; description: string; quantity: number; expand?: { delivery_note?: DeliveryNote; order_item?: OrderItem; }; } export type PriceListRow = { sku: string; /** * The same SKU with non-alphanumeric characters stripped and the * remainder uppercased. `JEITD-06` → `JEITD06`. Index on this * column lets the Check-pricing matcher do substring + exact * matches regardless of how the buyer typed it. */ sku_normalised: string; description: string; category: string; subcategory: string; brand: string; /** * Free-text supplier name as stored on the price_list row. The * live schema has this as a text field rather than a relation * to `suppliers`, so it's the human-readable name (or PB id, for * pre-migration rows). The Catalogue tab does a client-side * lookup against `suppliers` to render the canonical name. */ supplier: string; cost_price: number; selling_price: number; /** Markup percentage as stored (e.g. 25 = 25%). 0 if unset. */ markup: number; unit: string; stock_qty: number; /** Rich JSON: per-category attributes + synonyms for fuzzy match. */ specifications: { diameter_mm?: number; shank_mm?: number; length_mm?: number; material?: string; synonyms?: string[]; [key: string]: unknown; }; /** `raw_price_list.id` this row was extracted from. Empty for manually-created rows. */ source_file: string; /** * Denormalised search blob. PB can't substring-match a JSON * field, so the n8n workflow and the backfill script flatten * every searchable bit of a row into this column: sku, * description, brand, supplier, category, subcategory, and * the `specifications.synonyms` array. The Catalogue tab's * search box hits this column directly so fuzzy matches via * synonyms work ("slugger cutter" finds the HSS cutters whose * synonyms list contains it). */ search_text: string; }; /** * One row in the `price_list` collection. The structured, * post-AI-extraction catalogue that the quote-filler agent searches * when filling prices. * * Schema (live): sku, description, category, subcategory, brand, * supplier (text), cost_price, selling_price, markup, unit, * stock_qty, specifications (json), source_file (text → raw_price_list.id). * Indexed on sku, category, subcategory, brand, supplier. */ export interface PriceList extends BaseRecord, PriceListRow {} export type RawPriceListStatus = "Pending" | "Processing" | "Completed" | "Failed"; /** * One row in the Pricelist page table. Holds a raw supplier price-list * file (PDF / CSV / text / image) before any parsing/AI runs over it. * * Backed by the `raw_price_list` PocketBase collection. See * `docs/pocketbase/raw_price_list.collection.json` and * `scripts/migrations/2026-06-30-add-raw-price-list.ts` for the schema * source of truth. * * The live PB collection's fields (discovered via the admin API): * - supplier_file: the uploaded file (PDF/CSV/text/image) * - supplier_name: relation → suppliers (free text → relation * migration was the v1 design decision, kept for back-compat) * - log_summary: free-text parser output / operator notes * - status: select (pending / processing / completed / failed) * * If the collection is missing in a fresh deploy, the migration script * creates it with this same shape. */ export interface RawPriceList extends BaseRecord { /** PB file tokens. Single file per row — first element is the URL token. */ supplier_file: string[]; /** * Original browser-side filename (e.g. "Acme-Bolts-2026-Q2.pdf"). * PB slugifies the upload into a token stored inside `supplier_file`, * so without this field the operator sees the slugged version. The * WFDASH upload form auto-populates it from `file.name`. */ file_name: string; /** PB relation → suppliers. Empty string when unset. */ supplier_name: string; status: RawPriceListStatus; /** Free-text log / summary — parser output, manual notes, etc. */ log_summary: string; /** * Free-text operator notes for the AI extraction step. Read by the * n8n workflow's LLM user message ("Brand is JEI. Apply 30% markup. * All prices in ZAR excl. VAT. POA for missing values.") and by * the post-extraction Code node (which regexes out a `markup: NN%` * override for the selling_price calculation). */ notes: string; expand?: { supplier_name?: Supplier; }; } export interface Repair extends BaseRecord { repair_number: string; customer?: string; company?: string; order?: string; status: "new" | "assessing" | "quoted" | "approved" | "in_progress" | "completed" | "cancelled"; item_description: string; fault_description: string; notes?: string; expand?: { customer?: Customer; company?: Company; order?: Order; }; } export interface DashboardStats { totalCustomers: number; activeQuotes: number; ordersThisMonth: number; totalRevenue: number; } // ───────────────────────────────────────────────────────────────── // Stock management — added 2026-07-14 // ───────────────────────────────────────────────────────────────── /** * Allowed values for `stock_ledger.reason`. The schema field is plain * `text` for now (operator hasn't added the enum in PB admin yet — * see Option 2 of the 2026-07-14 plan). The API layer validates * against this union regardless, so when the enum is later added to * PB, the API contract doesn't change. * * - `purchase_surplus` — operator split a collected line, the * excess over customer qty went to stock. * Reference: the order_item that was split. * - `stock_order` — qty booked in from a pure stock purchase * (v2 stock-orders flow). Reference: the * stock_purchase id (when that collection * lands). * - `customer_fulfillment` — qty booked out when a customer order is * fulfilled from existing stock (v3 stock- * out flow). Reference: the order_item * that was fulfilled. * - `adjustment` — operator manual correction (count, write- * off, etc). Reference: optional manual id. * - `return` — qty returned from a customer or driver. * Reference: the source order_item. */ export type StockReason = | "purchase_surplus" | "stock_order" | "customer_fulfillment" | "adjustment" | "return"; /** All allowed reasons, exported for validation + tests. */ export const STOCK_REASON_VALUES: readonly StockReason[] = [ "purchase_surplus", "stock_order", "customer_fulfillment", "adjustment", "return", ] as const; export interface StockItem extends BaseRecord { sku: string; name: string; description: string; /** Display unit (e.g. "ea", "box", "L", "m"). Free text. */ unit: string; /** Bin / shelf / location label. */ location: string; /** * Denormalized running total. Authoritative source is the * `stock_ledger` rows; the API layer maintains `current_qty` in * the same transaction as the ledger insert so the two stay in * sync. UI reads `current_qty` for fast display; the ledger * drill-down is for audit / drift repair. */ current_qty: number; /** Reorder threshold — UI flags this row when `current_qty <= min_qty`. */ min_qty: number; /** * The "I want this much on hand" target. The bulk-PO-from-low-stock * flow uses this to compute the order qty: `order_qty = max(0, target_qty - current_qty)`. * Items with `target_qty` unset or 0 are opted out of the bulk flow * (the operator hasn't decided to auto-reorder them yet). */ target_qty?: number; /** Default supplier for re-orders. */ supplier: string; /** Soft-hide flag — operator can deactivate without deleting history. */ is_active: boolean; notes: string; expand?: { supplier?: Supplier; }; } export interface StockLedger extends BaseRecord { stock_item: string; /** Signed: positive = in, negative = out. */ change_qty: number; reason: StockReason; /** * Free-text tag for what the row references, e.g. "order_item", * "stock_order", "manual". Pairs with `reference_id` to find the * originating record. */ reference_type: string; /** PB id of the originating record. */ reference_id: string; notes: string; created_by: string; expand?: { stock_item?: StockItem; /** * Best-effort expand of the `created_by` user relation. May be * missing (system writes, pre-migration rows). The detail page * falls back to "System" when this is absent so the render * never crashes on a missing expand. */ created_by?: { id: string; name?: string; email?: string }; }; } // ───────────────────────────────────────────────────────────────── // Stock purchases (v2 pure stock-purchase flow) — added 2026-07-14 // ───────────────────────────────────────────────────────────────── /** * Lifecycle: draft → sent → received (terminal) or draft → cancelled. * `sent` is set when the operator clicks "Send to supplier" (status * flip only — no SMTP wiring in v1). `received` triggers the ledger * + current_qty updates per line via the receive endpoint. */ export type StockPurchaseStatus = | "draft" | "sent" | "received" | "cancelled"; export const STOCK_PURCHASE_STATUS_VALUES: readonly StockPurchaseStatus[] = [ "draft", "sent", "received", "cancelled", ] as const; /** * Per-line state for stock-purchase lines. The state machine runs * `collection_pool` → `assigned_to_driver` → `collected` → `booked`, * mirroring the operator's workflow: * - `collection_pool`: line is awaiting a driver assignment. * - `assigned_to_driver`: line is on a driver's run; `assigned_at` * holds the timestamp. * - `collected`: driver has picked up the goods from the supplier. * - `booked`: qty has been booked into `stock_items` via the * collection-confirm "Book to stock" flow. * `null`/`undefined` = line hasn't been sent yet (still in draft). */ export type StockPurchaseLineStatus = | "collection_pool" | "assigned_to_driver" | "collected" | "booked"; export const STOCK_PURCHASE_LINE_STATUS_VALUES: readonly StockPurchaseLineStatus[] = [ "collection_pool", "assigned_to_driver", "collected", "booked", ] as const; export interface StockPurchase extends BaseRecord { /** * `PO{yy}{6d-seq}STK` — e.g. `PO26000001STK`. Per-year counter * so the sequence resets on Jan 1. See * `generateStockPurchaseNumber` in * `@/lib/server/stock-purchases-service`. */ purchase_number: string; supplier: string; status: StockPurchaseStatus; notes: string; /** Outbound email composition (subject/body/cc/bcc/sentMessageId), if any. */ email_payload?: Record; /** SMTP error string (empty when the last send succeeded). */ email_error: string; /** RFC 5322 Message-ID from the last send attempt. */ email_message_id: string; /** * Generated PO PDF + any other attachments the operator wants to * send to the supplier. v1 send flow uploads the PDF here on the * "Send to supplier" action; the stock-order list table renders * a per-row icon to view/print. */ documents?: string[]; expand?: { supplier?: Supplier; }; } export interface StockPurchaseItem extends BaseRecord { purchase: string; stock_item: string; description: string; quantity: number; /** * How much has actually been received against this line. Starts at * 0; the "Book to stock" flow on collection-confirm writes here * (capped at `quantity`). Repeated partial-bookings accumulate. */ quantity_received: number; cost_price: number; notes: string; /** * Per-line state — see `StockPurchaseLineStatus`. `null`/ * `undefined` means the line is in the parent PO's draft state * (not yet sent to the supplier). */ status?: StockPurchaseLineStatus; /** * Timestamp the line was bound to a driver (the * `assigned_to_driver` transition). Mirrors `order_items.assigned_at`. */ assigned_at?: string | null; /** * Driver the line is bound to (relation → users). Mirrors * `order_items.drv_id`. Empty / undefined for unassigned lines * (status = `collection_pool` or earlier). */ drv_id?: string; expand?: { purchase?: StockPurchase; stock_item?: StockItem; }; }