WFDASH-WEBAPP/src/lib/types.ts

913 lines
31 KiB
TypeScript

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: <message-id>` 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";
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;
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;
}