2026-06-11 18:30:02 +00:00
|
|
|
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;
|
2026-06-29 11:06:01 +00:00
|
|
|
/**
|
|
|
|
|
* 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.
|
|
|
|
|
*/
|
2026-06-11 18:30:02 +00:00
|
|
|
company: string;
|
|
|
|
|
dateadded: string;
|
|
|
|
|
lastvisiteddate: string;
|
2026-06-29 11:06:01 +00:00
|
|
|
/**
|
|
|
|
|
* 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.
|
|
|
|
|
*/
|
2026-06-11 18:30:02 +00:00
|
|
|
rep: string;
|
2026-06-29 11:06:01 +00:00
|
|
|
/**
|
|
|
|
|
* 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 };
|
2026-06-11 18:30:02 +00:00
|
|
|
description: string;
|
2026-06-29 11:06:01 +00:00
|
|
|
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;
|
2026-06-11 18:30:02 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface Company extends BaseRecord {
|
|
|
|
|
company: string;
|
|
|
|
|
address: string;
|
|
|
|
|
vendor: string;
|
2026-06-29 11:06:01 +00:00
|
|
|
/**
|
|
|
|
|
* 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 };
|
2026-06-11 18:30:02 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface CompanyDetails extends BaseRecord {
|
|
|
|
|
company_name: string;
|
|
|
|
|
address: string;
|
|
|
|
|
number: string;
|
|
|
|
|
email: string;
|
|
|
|
|
vat_number: string;
|
|
|
|
|
registration_number: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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"
|
feat: add quote templates, improved email, new statuses
Add complete quote template functionality, enhanced outbound email sending, new quote statuses, and supporting infrastructure:
- Add new quote statuses (template, sending, mail_failed) with tailored UI styling for status pills
- Implement full template workflows: save real quotes as templates, create new quotes from existing templates
- Add support for custom quote dates and extended validity periods on generated PDF documents
- Build out improved email pipeline with persisted compose fields and retry functionality for failed sends
- Add attachment direction tracking to categorize inbound, outbound, and generated quote attachments
- Fix tooltip component to properly handle Radix `asChild` prop to avoid React DOM attribute errors
- Add comprehensive test coverage, schema migrations, and update sample test and production data
2026-06-24 20:57:01 +00:00
|
|
|
| "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";
|
2026-06-11 18:30:02 +00:00
|
|
|
|
|
|
|
|
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;
|
feat: add quote email tracking, user color coding, and improved attachments
Add comprehensive quote management and UI improvements:
- Add `email_received_at` field to quotes collection, TypeScript types, and setup script
- Update quote attachments schema to mark `kind` optional for legacy attachments, add `uploaded_at` field, and improve documentation comments
- Create `/api/v1/users/lookup` server endpoint to resolve user IDs to display names
- Add stable user ID → color mapping for visual grouping of claimed quotes
- Enhance quotes list page: reorder tab filters, show claimer names, color-code rows per claimant
- Update quote detail page to display claimer info with user icon
- Expand DataTable component to support per-row custom class and style props
- Overhaul QuoteEmailCard: add proper HTML/plain text body sanitization/linkification, inline PDF/image previews, improved metadata display, and fixed auth-aware attachment previews via blob URLs
2026-06-22 13:15:00 +00:00
|
|
|
/** When the original inbound email was received (Date header / IMAP envelope date). */
|
|
|
|
|
email_received_at: string;
|
2026-06-11 18:30:02 +00:00
|
|
|
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;
|
2026-06-16 18:46:28 +00:00
|
|
|
email_message_id: string;
|
feat: add quote templates, improved email, new statuses
Add complete quote template functionality, enhanced outbound email sending, new quote statuses, and supporting infrastructure:
- Add new quote statuses (template, sending, mail_failed) with tailored UI styling for status pills
- Implement full template workflows: save real quotes as templates, create new quotes from existing templates
- Add support for custom quote dates and extended validity periods on generated PDF documents
- Build out improved email pipeline with persisted compose fields and retry functionality for failed sends
- Add attachment direction tracking to categorize inbound, outbound, and generated quote attachments
- Fix tooltip component to properly handle Radix `asChild` prop to avoid React DOM attribute errors
- Add comprehensive test coverage, schema migrations, and update sample test and production data
2026-06-24 20:57:01 +00:00
|
|
|
/** 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;
|
2026-06-11 18:30:02 +00:00
|
|
|
expand?: {
|
|
|
|
|
customer?: Customer;
|
|
|
|
|
company?: Company;
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-16 18:46:28 +00:00
|
|
|
export type QuoteAttachmentKind = "pdf" | "image" | "doc" | "other";
|
|
|
|
|
|
feat: add quote templates, improved email, new statuses
Add complete quote template functionality, enhanced outbound email sending, new quote statuses, and supporting infrastructure:
- Add new quote statuses (template, sending, mail_failed) with tailored UI styling for status pills
- Implement full template workflows: save real quotes as templates, create new quotes from existing templates
- Add support for custom quote dates and extended validity periods on generated PDF documents
- Build out improved email pipeline with persisted compose fields and retry functionality for failed sends
- Add attachment direction tracking to categorize inbound, outbound, and generated quote attachments
- Fix tooltip component to properly handle Radix `asChild` prop to avoid React DOM attribute errors
- Add comprehensive test coverage, schema migrations, and update sample test and production data
2026-06-24 20:57:01 +00:00
|
|
|
/**
|
|
|
|
|
* 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";
|
|
|
|
|
|
2026-06-16 18:46:28 +00:00
|
|
|
export interface QuoteAttachment extends BaseRecord {
|
|
|
|
|
quote: string;
|
2026-06-23 06:30:38 +00:00
|
|
|
/**
|
|
|
|
|
* 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`.
|
|
|
|
|
*/
|
2026-06-16 18:46:28 +00:00
|
|
|
filename: string;
|
2026-06-23 06:30:38 +00:00
|
|
|
/**
|
|
|
|
|
* 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[];
|
2026-06-16 18:46:28 +00:00
|
|
|
content_type: string;
|
|
|
|
|
size_bytes: number;
|
|
|
|
|
email_message_id: string;
|
feat: add quote email tracking, user color coding, and improved attachments
Add comprehensive quote management and UI improvements:
- Add `email_received_at` field to quotes collection, TypeScript types, and setup script
- Update quote attachments schema to mark `kind` optional for legacy attachments, add `uploaded_at` field, and improve documentation comments
- Create `/api/v1/users/lookup` server endpoint to resolve user IDs to display names
- Add stable user ID → color mapping for visual grouping of claimed quotes
- Enhance quotes list page: reorder tab filters, show claimer names, color-code rows per claimant
- Update quote detail page to display claimer info with user icon
- Expand DataTable component to support per-row custom class and style props
- Overhaul QuoteEmailCard: add proper HTML/plain text body sanitization/linkification, inline PDF/image previews, improved metadata display, and fixed auth-aware attachment previews via blob URLs
2026-06-22 13:15:00 +00:00
|
|
|
/**
|
|
|
|
|
* 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;
|
feat: add quote templates, improved email, new statuses
Add complete quote template functionality, enhanced outbound email sending, new quote statuses, and supporting infrastructure:
- Add new quote statuses (template, sending, mail_failed) with tailored UI styling for status pills
- Implement full template workflows: save real quotes as templates, create new quotes from existing templates
- Add support for custom quote dates and extended validity periods on generated PDF documents
- Build out improved email pipeline with persisted compose fields and retry functionality for failed sends
- Add attachment direction tracking to categorize inbound, outbound, and generated quote attachments
- Fix tooltip component to properly handle Radix `asChild` prop to avoid React DOM attribute errors
- Add comprehensive test coverage, schema migrations, and update sample test and production data
2026-06-24 20:57:01 +00:00
|
|
|
/**
|
|
|
|
|
* See `QuoteAttachmentDirection`. Tolerate null/undefined for
|
|
|
|
|
* rows created before the 2026-06-23 direction-field migration.
|
|
|
|
|
*/
|
|
|
|
|
direction?: QuoteAttachmentDirection | null;
|
2026-06-16 18:46:28 +00:00
|
|
|
expand?: {
|
|
|
|
|
quote?: Quote;
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-11 18:30:02 +00:00
|
|
|
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;
|
2026-06-22 16:29:59 +00:00
|
|
|
/**
|
|
|
|
|
* 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;
|
2026-06-11 18:30:02 +00:00
|
|
|
expand?: {
|
|
|
|
|
supplier?: Supplier;
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-29 11:06:01 +00:00
|
|
|
/**
|
|
|
|
|
* 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
|
|
|
|
|
* driver_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)
|
|
|
|
|
*/
|
2026-06-11 18:30:02 +00:00
|
|
|
export type ItemStatus =
|
|
|
|
|
| "pending"
|
|
|
|
|
| "processed"
|
|
|
|
|
| "po_generated"
|
|
|
|
|
| "driver_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;
|
2026-06-29 11:06:01 +00:00
|
|
|
/**
|
|
|
|
|
* 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;
|
2026-06-11 18:30:02 +00:00
|
|
|
expand?: {
|
|
|
|
|
company?: Company;
|
|
|
|
|
customer?: Customer;
|
|
|
|
|
quote?: Quote;
|
2026-06-29 11:06:01 +00:00
|
|
|
potential_quote?: Quote;
|
2026-06-11 18:30:02 +00:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-16 18:46:28 +00:00
|
|
|
export interface OrderItem extends BaseRecord {
|
2026-06-11 18:30:02 +00:00
|
|
|
order: string;
|
|
|
|
|
description: string;
|
|
|
|
|
po_description: string;
|
|
|
|
|
quantity: number;
|
|
|
|
|
order_qty: number;
|
|
|
|
|
selling_price: number;
|
|
|
|
|
supplier: string;
|
|
|
|
|
cost_price: number;
|
|
|
|
|
markup: number;
|
2026-06-16 18:46:28 +00:00
|
|
|
purchase_order: string;
|
|
|
|
|
item_status: ItemStatus;
|
2026-06-29 11:06:01 +00:00
|
|
|
/**
|
|
|
|
|
* 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 `driver_pool` / `assigned` / `en_route`.
|
|
|
|
|
*
|
|
|
|
|
* Default false. Invoices and delivery notes ignore this flag — the
|
|
|
|
|
* item is still part of the order.
|
|
|
|
|
*/
|
|
|
|
|
in_stock: boolean;
|
2026-06-16 18:46:28 +00:00
|
|
|
drv_status?: "unassigned" | "assigned" | "en_route" | "collected" | "failed";
|
|
|
|
|
drv_id?: string;
|
|
|
|
|
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 PurchaseOrder extends BaseRecord {
|
|
|
|
|
po_number: string;
|
|
|
|
|
order: string;
|
|
|
|
|
supplier: string;
|
2026-06-11 18:30:02 +00:00
|
|
|
status: string;
|
|
|
|
|
total_amount: number;
|
|
|
|
|
expand?: {
|
|
|
|
|
order?: Order;
|
|
|
|
|
supplier?: Supplier;
|
2026-06-16 18:46:28 +00:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
feat(pricelist): Catalogue tab with search + category/brand filters
Adds a second tab on /pricelist that shows the structured
`price_list` collection (AI-extracted line items) instead of the
raw PDF uploads. Operators can now:
- Search across SKU, description, and brand
- Filter by category (twist_drill, hss_cutter, countersink, ...)
- Filter by brand (JEI, TurboSteel, TurboTough, ...)
- See cost vs selling price side-by-side in ZAR
- Click a row's 'Source' link to jump back to the raw PDF
The distinct filter values are pulled from a one-shot getList(1, 500)
on mount, with hardcoded fallbacks (FALLBACK_CATEGORIES /
FALLBACK_BRANDS) so the dropdowns are useful even before any
extractions have run.
Layout: Tabs (Raw Uploads | Catalogue) wrap both views; the
header + page chrome stay shared. The DeleteDialog stays outside
the Tabs since it's a modal.
Also ships an idempotent migration to add `source_file` (text)
to `price_list` — used by the n8n workflow to track which raw
PDF each extracted row came from, and by the verify-after-POST
step the workflow uses to decide whether to flip
`raw_price_list.status` to Completed vs Failed.
Price type is in PriceListRow (shared subset) and PriceList
(full record). Supplier names are resolved client-side via
getAllSuppliers() since the live schema has supplier as a text
field rather than a relation.
Tests: 229/230 (same pre-existing failure, unrelated). TypeScript
clean.
2026-06-30 09:49:48 +00:00
|
|
|
export type PriceListRow = {
|
|
|
|
|
sku: 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;
|
feat(pricelist): Catalogue search hits every field (incl. synonyms)
The Catalogue tab's search box used to OR across just sku,
description, and brand. Now it hits:
- sku, description, brand, supplier, category, subcategory
(PB's case-insensitive `~` operator, each indexed)
- search_text (a denormalised text blob)
The denormalised column is the key part: PB can't substring-
match a JSON field, so without this, synonyms from
`specifications.synonyms` ("slugger cutter", "turbo tough",
etc.) would never match in the search box. The n8n workflow's
Code node now flattens every searchable bit — including the
synonyms array — into search_text on every POST. The 46 pre-
existing rows were backfilled once with the same formula.
The `price_list.supplier` field also stored the PB id on the
46 pre-existing rows (n8n was setting the resolved name, but
the original LLM tests that seeded the table didn't). Fixed
that in the same backfill — supplier now reads 'JEISA -
WESTSCOTT GROUP' instead of 'q6hpwbxsicyoiuu', which makes
the supplier filter dropdown work AND makes
'supplier ~ "jeisa"' match.
Migration `2026-06-30-add-price-list-search-text`:
- Adds `search_text` (text) column
- Adds `idx_pl_search_text` non-unique index
- Idempotent — re-runs are safe and self-heal the index
Updates:
- `getPriceLists` helper ORs across all 6 columns + search_text
- `PriceList` TS type gains `search_text: string`
- `pricelist-extraction.md` Code node snippet shows how the
n8n workflow should populate `search_text` on every POST
- Search input placeholder reads 'Search anything — SKU,
description, brand, supplier, synonyms…'
Tests: 229/230 (same pre-existing failure, unrelated).
TypeScript clean.
2026-06-30 10:10:27 +00:00
|
|
|
/**
|
|
|
|
|
* 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;
|
feat(pricelist): Catalogue tab with search + category/brand filters
Adds a second tab on /pricelist that shows the structured
`price_list` collection (AI-extracted line items) instead of the
raw PDF uploads. Operators can now:
- Search across SKU, description, and brand
- Filter by category (twist_drill, hss_cutter, countersink, ...)
- Filter by brand (JEI, TurboSteel, TurboTough, ...)
- See cost vs selling price side-by-side in ZAR
- Click a row's 'Source' link to jump back to the raw PDF
The distinct filter values are pulled from a one-shot getList(1, 500)
on mount, with hardcoded fallbacks (FALLBACK_CATEGORIES /
FALLBACK_BRANDS) so the dropdowns are useful even before any
extractions have run.
Layout: Tabs (Raw Uploads | Catalogue) wrap both views; the
header + page chrome stay shared. The DeleteDialog stays outside
the Tabs since it's a modal.
Also ships an idempotent migration to add `source_file` (text)
to `price_list` — used by the n8n workflow to track which raw
PDF each extracted row came from, and by the verify-after-POST
step the workflow uses to decide whether to flip
`raw_price_list.status` to Completed vs Failed.
Price type is in PriceListRow (shared subset) and PriceList
(full record). Supplier names are resolved client-side via
getAllSuppliers() since the live schema has supplier as a text
field rather than a relation.
Tests: 229/230 (same pre-existing failure, unrelated). TypeScript
clean.
2026-06-30 09:49:48 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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 {}
|
|
|
|
|
|
2026-06-30 07:14:36 +00:00
|
|
|
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[];
|
2026-06-30 09:09:27 +00:00
|
|
|
/**
|
|
|
|
|
* 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;
|
2026-06-30 07:14:36 +00:00
|
|
|
/** PB relation → suppliers. Empty string when unset. */
|
|
|
|
|
supplier_name: string;
|
|
|
|
|
status: RawPriceListStatus;
|
|
|
|
|
/** Free-text log / summary — parser output, manual notes, etc. */
|
|
|
|
|
log_summary: string;
|
2026-06-30 09:09:27 +00:00
|
|
|
/**
|
|
|
|
|
* 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;
|
2026-06-30 07:14:36 +00:00
|
|
|
expand?: {
|
|
|
|
|
supplier_name?: Supplier;
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-16 18:46:28 +00:00
|
|
|
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;
|
2026-06-11 18:30:02 +00:00
|
|
|
ordersThisMonth: number;
|
|
|
|
|
totalRevenue: number;
|
|
|
|
|
}
|