WFDASH-WEBAPP/src/lib/pb-helpers.ts
SJCoetz 3d3a012174 fix(api,layout): sprint 500 chunk + add 9 missing top-level agent routes + metadataBase; drop unused collection_pool feature; quote counter fix migration
src/app/api/v1/sprint/attachment-counts/route.ts
  - Chunk itemIds into batches of 80 before building the PocketBase filter;
    the live cap was hit at ~115 IDs × 15 chars and surfaced as a
    generic Next 500 ("Something went wrong while processing your
    request.") that hid the real cause. 7 chunks for the sprint
    page's max of 500 items.

src/app/layout.tsx
  - Add metadataBase derived from NEXT_PUBLIC_SITE_URL /
    NEXT_PUBLIC_POCKETBASE_URL. Silences the per-page
    "metadataBase not set" warning during dev renders and gives
    OG / Twitter cards a stable canonical base.

src/app/api/v1/{quotes,order-items,purchase-orders,stock,
stock-purchases,users,sprint,driver,ai-insights}/route.ts (NEW)
  - Add the missing top-level collection routes the agent API
    needs. seven are straight passthroughs to their PB collection;
    /sprint proxies to quote_items with optional claimStatus and
    category query params; /driver lists PB users (no role field
    in PB so callers narrow with ?filter=); /ai-insights returns
    a roll-up count + most-recent regenerate timestamp across the
    four collections that carry ai_* JSON fields.

src/app/api/v1/collection-pool/{route.ts,route.test.ts},
src/app/(app)/collection-pool/{page.tsx,page.test.tsx,
view-model.ts,view-model.test.ts,components.tsx} (DELETED)
  - Drop the collection_pool feature entirely: PB has no
    collection_pool collection on the live instance
    (pb:migrate:orders-v2 was never run), the codebase comment
    in purchase-orders-service.ts says "now-removed", and the
    sidebar entrypoint has been migrated to /sprint + /deliveries.
    The replacement operational status is the `item_status =
    "collection_pool"` enum value on order_items, which stays.

src/lib/server/orders-service.ts (+ test), src/lib/pb-helpers.ts,
src/components/sidebar.tsx, src/components/ui/assign-driver-dialog.tsx,
src/app/(app)/collection-map/sidebar.tsx,
src/app/api/mcp/route.ts (+ test) (MODIFIED)
  - Remove listCollectionPool + getCollectionPool +
    updateCollectionPoolStatus, the /collection-pool sidebar entry,
    the deep-link in assign-driver-dialog and collection-map
    sidebar, and the collection_pool.list MCP tool (the tool,
    handler, and test mock).

scripts/migrations/2026-07-26-fix-quote-counter-and-reassign-numbers.ts (NEW)
  - One-shot migration script that:
      1. Reads every quote, sorts by created ASC.
      2. Reassigns clean Q202607001 … Q202607{N} over the whole
         table (including the e+22 garbage and the legacy
         Qnnnnnn rows).
      3. Sets quote_counters.(year=2026, month=7).last_seq = N
         so the next /api/v1/quotes/next-number returns a clean,
         atomic, unique number.
    Default mode = dry-run; pass --apply to write.
    Already executed against the live cloud PB during this session
    (verified: 200/200 rows updated, counter at 200, next-number
    returns Q202607201, Q202607202, Q202607203 on consecutive calls).

AGENTS.md (MODIFIED)
  - Add data-layer-discipline block matching the style of the
    three existing blocks. Documents which layer is legacy (pb-helpers)
    vs current (lib/server/*-service.ts via /api/v1/*), gives the
    migration order (customers → orders → quotes → suppliers →
    companies → rest), and includes the grep one-liner future AI
    agents should run before extending pb-helpers.

Not in this commit (your WIP, untouched):
  src/app/(app)/{dashboard,delivery-notes,layout,orders/[id],
  quotes/[id],quotes/approvals,quotes,sprint}/page.tsx,
  src/app/(auth)/login/page.tsx, src/components/charts/*,
  src/components/{company-form,customer-form,quote-email-card,
  sprint-item-pricing-modal,supplier-form,theme-bootstrap,
  theme-picker}*.{tsx,test.tsx}, src/app/api/invoicing/delivery-note/*
  src/app/api/v1/quotes/[id]/pdf/route.ts, src/app/globals.css,
  src/lib/{item-status-ui,pdf/quote-document,quote-status*,themes,
  types}.ts, src/lib/server/{order-item-status*,quotes-service}.ts,
  src/styles/themes.css, APP_CAPABILITIES.md, README.md,
  docs/SHIPPED.md, scripts/smoke/screenshot-pages.mjs,
  and the new untracked files under docs/, scripts/migrations/
  2026-07-19…25, src/app/{(app)/deliveries,(app)/delivery-notes,
  api/v1/ai-insights/rerun,api/v1/deliveries,api/v1/delivery-notes,
  api/v1/quotes/[id]/partial-approve,api/v1/quotes/[id]/split},
  src/components/{ai-insights-panel,ai-insights-renderer,
  ai-item-history-trigger,brand-stripe,delivery-notes,ui/stock-item-search,
  ui/duplicate-warning,ui/duplicate-warning.test}.tsx, supplier-form.test.tsx,
  src/lib/{pb-helpers-duplicate.test,server/deliveries-service*,
  server/delivery-notes-service*}, public/wfdash-logo*.png,
  opencode.jsonc.
2026-07-26 22:18:49 +02:00

2007 lines
71 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import getPocketBase from "./pocketbase";
import type { Customer, Company, Supplier, Quote, QuoteItem, QuoteAttachment, Order, OrderItem, PurchaseOrder, CollectionPool, DeliveryNote, Repair, RawPriceList, PriceList, StockItem, BaseRecord } from "./types";
import { TAB_GROUP_ITEM_STATUSES, itemStatusToTabGroup, type TabGroup } from "./item-status-ui";
import { buildPriceListSearchFilter } from "./price-list-search";
import { scoreOrderVsQuote, MATCH_CONFIDENCE_THRESHOLD, FUZZY_CANDIDATE_CAP } from "./server/order-quote-match";
export async function getCustomers(
page = 1,
perPage = 50,
search = "",
sort = "-created",
expand?: string
) {
const pb = getPocketBase();
// Note: `company` was converted from text to a relation; the old
// `company ~ "${search}"` filter clause no longer makes sense (it's
// matching against the relation id, not a name). Search now hits
// name / email / phone only. To find a customer by company name,
// start from the Companies page.
const filter = search
? `customer ~ "${search}" || email ~ "${search}" || number ~ "${search}"`
: "";
return pb.collection("customers").getList<Customer>(page, perPage, {
filter,
sort,
expand,
});
}
export async function getCustomer(id: string, expand?: string) {
const pb = getPocketBase();
return pb.collection("customers").getOne<Customer>(id, { expand });
}
export async function getAllCustomers(expand?: string) {
const pb = getPocketBase();
return pb.collection("customers").getFullList<Customer>({
sort: "-created",
expand,
});
}
export async function getCompanies(page = 1, perPage = 50, search = "", sort = "-created") {
const pb = getPocketBase();
const filter = search
? `company ~ "${search}" || vendor ~ "${search}" || company_email ~ "${search}" || company_tel ~ "${search}"`
: "";
return pb.collection("companies").getList<Company>(page, perPage, { filter, sort });
}
export async function getCompany(id: string) {
const pb = getPocketBase();
return pb.collection("companies").getOne<Company>(id);
}
export async function getAllCompanies() {
const pb = getPocketBase();
return pb.collection("companies").getFullList<Company>({ sort: "-created" });
}
export async function getSuppliers(page = 1, perPage = 50, search = "", sort = "-created") {
const pb = getPocketBase();
const filter = search
? `suppliername ~ "${search}" || email ~ "${search}" || suppliernumber ~ "${search}" || supply_tags ~ "${search}"`
: "";
return pb.collection("suppliers").getList<Supplier>(page, perPage, { filter, sort });
}
export async function getSupplier(id: string) {
const pb = getPocketBase();
return pb.collection("suppliers").getOne<Supplier>(id);
}
export async function getAllSuppliers() {
const pb = getPocketBase();
return pb.collection("suppliers").getFullList<Supplier>({ sort: "-created" });
}
/**
* Fields the supplier-map actually renders. Kept narrow so a 5k-row
* supplier table doesn't ship `closingtime` and the rest of the blob
* over the wire just to show pins on a Leaflet map.
*/
const SUPPLIER_MAP_FIELDS =
"id,suppliername,supplieraddress,suppliernumber,email,supply_tags,coordinates";
/**
* Suppliers that can actually be placed on the map — only rows with
* non-empty `coordinates`, and only the fields the map renders.
*
* Distinct from `getAllSuppliers` (which the quote/order forms still
* want full records from). Introduced when the supplier-map was
* choking on zoom-in with a few thousand suppliers: shipping the
* whole table was the biggest single contributor.
*/
export async function getMappableSuppliers() {
const pb = getPocketBase();
return pb.collection("suppliers").getFullList<Supplier>({
sort: "-created",
filter: 'coordinates != ""',
fields: SUPPLIER_MAP_FIELDS,
});
}
/* ============================================================================
* Stock items — added 2026-07-14 (Stream E: v3 stock-out flow).
*
* `getActiveStockItems` is what the order-entry pages call to feed
* the "Fulfill from stock" picker. Filters by `is_active=true` so
* deactivated items don't pollute the dropdown — same convention as
* the supplier map's `coordinates != ""` filter.
*
* `getStockItem` is the single-record variant used by the
* /api/v1/stock/fulfill route when it needs to re-read current_qty
* just before debiting (defensive — catches the race where the
* picker was filled with an earlier `current_qty` than the current
* row holds).
* ========================================================================== */
export async function getActiveStockItems() {
const pb = getPocketBase();
return pb.collection("stock_items").getFullList<StockItem>({
sort: "name",
filter: "is_active = true",
expand: "supplier",
});
}
export async function getStockItem(id: string) {
const pb = getPocketBase();
return pb.collection("stock_items").getOne<StockItem>(id, {
expand: "supplier",
});
}
/**
* Quote queries reference `is_template`, `quote_date`, `validity_days`,
* `follow_up_due`, `template_source_id`, `generated_from_template_id` —
* fields that are added in v5 by importing `pb_schema (1).json`.
*
* Until the schema is imported, PB rejects the filter with HTTP 400,
* which spams the browser console. We try the new filter first; on
* failure we fall back to the legacy unfiltered query and log a
* one-time reminder. After schema import everything works as designed.
*/
function is400(err: unknown): boolean {
if (!err || typeof err !== "object") return false;
const status = (err as { status?: number }).status;
return status === 400;
}
let warnedSchemaMissing = false;
function warnSchemaMissing(what: string) {
if (warnedSchemaMissing) return;
warnedSchemaMissing = true;
console.warn(
`[pb-helpers] Quote query falling back to legacy filter (${what}). ` +
"Import the new fields from pb_schema (1).json into PocketBase to enable the new filter."
);
}
/**
* Fetch a page of quotes, optionally filtered by one or more statuses.
*
* @param statuses One or more `quotes.status` values to OR-chain into a
* `status = "x" || status = "y" || …` filter. PB 0.26
* doesn't accept the `IN` operator, so we build the
* OR-chain ourselves (see MEMORY.md / PocketBase 0.26:
* no `IN` operator in raw filter strings). Pass `[]`
* (default) to skip the status filter.
*
* The Templates tab historically passed a single `status = "template"`
* string; that flow is now handled by passing `["template"]` instead.
*/
export async function getQuotes(
page = 1,
perPage = 50,
search = "",
sort = "-created",
statuses: string[] = [],
options: { deliveryNoteOnly?: boolean } = {}
) {
const pb = getPocketBase();
// Hide templates from every non-template tab so they don't pollute the
// workflow view. The Templates tab passes ["template"] explicitly —
// `statuses.includes("template")` lets it through.
const filters: string[] = [];
if (!statuses.includes("template")) filters.push("is_template != true");
if (search) {
filters.push(`quote_number ~ "${search}" || quote_reference ~ "${search}" || rfq_number ~ "${search}"`);
}
if (statuses.length > 0) {
const statusClause = statuses.map((s) => `status = "${s}"`).join(" || ");
filters.push(`(${statusClause})`);
}
// The "Delivery Note" tab on the Quotes page lists every quote
// that backs a delivery note, regardless of where it sits in
// the lifecycle (claimed, processed, emailed, etc). This is a
// relation filter, not a status filter — both can be combined
// if we ever need a "DN quotes in sent status" view.
if (options.deliveryNoteOnly) {
filters.push("delivery_note != ''");
}
const newFilter = filters.join(" && ");
try {
return await pb.collection("quotes").getList<Quote>(page, perPage, {
filter: newFilter,
sort,
expand: "customer,customer.company,customer.rep,company",
});
} catch (err) {
if (!is400(err)) throw err;
warnSchemaMissing("is_template");
// Drop just the is_template clause; keep the rest.
const legacyFilters = filters.filter((f) => f !== "is_template != true");
return pb.collection("quotes").getList<Quote>(page, perPage, {
filter: legacyFilters.join(" && "),
sort,
expand: "customer,customer.company,customer.rep,company",
});
}
}
/**
* Fetch only template quotes — for the templates page / dashboard widget.
* Sorted with the most-recently-created first.
*
* Returns an empty list (with a console warning) if the `is_template`
* field doesn't exist in PB yet, since this is brand-new functionality
* that fundamentally needs the field.
*/
export async function getTemplates(page = 1, perPage = 50) {
const pb = getPocketBase();
try {
return await pb.collection("quotes").getList<Quote>(page, perPage, {
filter: "is_template = true",
sort: "-created",
expand: "customer",
});
} catch (err) {
if (!is400(err)) throw err;
warnSchemaMissing("is_template (templates)");
// Minimal shape — only callers destructure .items, the rest is
// cosmetic for the widgets.
return {
items: [] as Quote[],
page: 1,
perPage,
totalItems: 0,
totalPages: 1,
};
}
}
/**
* Quotes whose user-set `follow_up_due` is today or in the past and
* which are not yet in a terminal status. Used by the dashboard widget
* to flag "needs review" work.
*
* Returns an empty list (with a console warning) until `follow_up_due`
* and `is_template` exist on the live PB collection.
*/
export async function getFollowUpDueQuotes() {
const pb = getPocketBase();
const today = new Date().toISOString();
try {
return await pb.collection("quotes").getFullList<Quote>({
filter: `follow_up_due != "" && follow_up_due <= "${today}" && status != "complete" && status != "cancelled" && status != "rejected" && is_template != true`,
sort: "follow_up_due",
expand: "customer",
});
} catch (err) {
if (!is400(err)) throw err;
warnSchemaMissing("follow_up_due / is_template (follow-ups)");
return [] as Quote[];
}
}
export async function getQuote(id: string) {
const pb = getPocketBase();
// Expand customer AND customer's nested relations (company + rep)
// so the detail page can render real names instead of opaque ids.
// PB expand syntax: comma-separated paths, nested via dot.
return pb.collection("quotes").getOne<Quote>(id, {
expand: "customer,customer.company,customer.rep,company",
});
}
/**
* Per-tab quote counts for the list-page tabs. Returns a `Record<tabKey, number>`
* where each key matches a tab key on the quotes list page:
*
* new ← status = new || status = follow_up (follow-up rows live in New)
* claimed ← status = claimed
* pending_approval ← status = processed || status = approval_pending || status = sending
* completed ← status = approved || status = complete || status = emailed
* || status = cancelled || status = rejected
* email_failed ← status = email_failed || status = mail_failed || status = unmatched_bounce
* problem ← status = problem
* templates ← is_template = true
* all ← unfiltered (excludes templates, like every other tab)
*
* One PB call per underlying status (with `perPage: 1` so we only transfer a
* count, not records). We don't make a separate "all" call — we sum the
* per-tab counts and trust PB's atomicity within a single request batch
* (the alternative — an unfiltered call — can disagree with the per-tab
* sum if statuses flip mid-query).
*/
export async function getQuoteStatusCounts() {
const pb = getPocketBase();
// Underlying statuses → tab key. A tab key can map to multiple statuses.
const statusToTab: Record<string, string> = {
new: "new",
follow_up: "new",
claimed: "claimed",
processed: "pending_approval",
approval_pending: "pending_approval",
// Partial-approval variants (added 2026-07-24) live in the same
// "needs eyes" tab as approval_pending — the rep reviews them on
// /quotes/approvals, but the list-page tab is the same.
partial_approval_pending: "pending_approval",
sending: "pending_approval",
approved: "completed",
complete: "completed",
// partial_approved is terminal for THIS batch (the parent quote),
// but a continuation quote (status=new) is the live one. Treat it
// as completed for tab purposes — the operator views the
// continuation in the "new" tab. The /quotes/approvals page still
// sees the parent via its own filter if needed.
partial_approved: "completed",
emailed: "completed",
cancelled: "completed",
rejected: "completed",
email_failed: "email_failed",
mail_failed: "email_failed",
unmatched_bounce: "email_failed",
problem: "problem",
};
const tabKeys = [
"new",
"claimed",
"pending_approval",
"completed",
"email_failed",
"problem",
];
// Try with the v5 template-exclusion filter; fall back if PB doesn't
// know `is_template` yet (schema not imported).
const notTemplate = `is_template != true`;
const buildRequests = (withTemplateGate: boolean) => {
const gate = withTemplateGate ? ` && ${notTemplate}` : "";
const requests = Object.keys(statusToTab).map((status) =>
pb.collection("quotes").getList<Quote>(1, 1, {
filter: `status = "${status}"${gate}`,
})
);
// Templates tab uses `is_template = true` (the status filter would
// exclude templates from the workflow track).
const templateFilter = withTemplateGate
? `is_template = true`
: `is_template = true`;
requests.push(pb.collection("quotes").getList<Quote>(1, 1, { filter: templateFilter }));
// The Delivery Note tab counts every quote linked to a
// delivery note (status-agnostic — covers in-flight + sent +
// invoiced). Skipped in the legacy fallback because
// `delivery_note` is a newer field and the count is just
// visual; the tab still works with count 0 if the field
// doesn't exist.
requests.push(
pb.collection("quotes").getList<Quote>(1, 1, {
filter: `delivery_note != ""${gate}`,
}),
);
return requests;
};
try {
const perStatus = await Promise.all(buildRequests(true));
const counts: Record<string, number> = {};
for (const key of tabKeys) counts[key] = 0;
counts.templates = 0;
counts.delivery_note = 0;
let allCount = 0;
Object.keys(statusToTab).forEach((status, i) => {
const tab = statusToTab[status];
counts[tab] = (counts[tab] || 0) + perStatus[i].totalItems;
allCount += perStatus[i].totalItems;
});
counts.templates = perStatus[perStatus.length - 2].totalItems;
counts.delivery_note = perStatus[perStatus.length - 1].totalItems;
// "All" includes every tab EXCEPT templates (templates live in their own tab).
counts.all = allCount;
return counts;
} catch (err) {
if (!is400(err)) throw err;
warnSchemaMissing("is_template (status counts)");
const perStatus = await Promise.all(buildRequests(false));
const counts: Record<string, number> = {};
for (const key of tabKeys) counts[key] = 0;
counts.templates = 0;
counts.delivery_note = 0;
let allCount = 0;
Object.keys(statusToTab).forEach((status, i) => {
const tab = statusToTab[status];
counts[tab] = (counts[tab] || 0) + perStatus[i].totalItems;
allCount += perStatus[i].totalItems;
});
counts.templates = perStatus[perStatus.length - 2].totalItems;
counts.delivery_note = perStatus[perStatus.length - 1].totalItems;
counts.all = allCount;
return counts;
}
}
export async function getQuoteItems(quoteId: string) {
const pb = getPocketBase();
return pb.collection("quote_items").getFullList<QuoteItem>({
filter: `quote = "${quoteId}"`,
expand: "supplier",
});
}
/* ============================================================================
* searchQuotesWithItems — multi-field client-side search, parallel to
* `searchOrdersWithItems`. Matches (case-insensitive substring):
*
* - quote_number, quote_reference, rfq_number, external_number
* - customer name (via expand)
* - company name (via expand)
* - quote_items.description (any line item)
* - quote_items.selling_price / cost_price (numeric exact match if the
* query parses as a plain decimal)
*
* Templates ARE included — the user can search them the same way. The
* list page's tab filter still hides templates from non-template tabs,
* but the search itself doesn't gate by status.
*
* Pagination is intentionally suppressed while searching (same trade-off
* as `searchOrdersWithItems`): the operator is hunting for a specific
* match, not paging through.
*
* Capped at the same `SEARCH_HARD_CAP` (500) as the orders search so a
* runaway query can't OOM the browser.
* ========================================================================== */
function quoteMatchesSearch(
quote: Quote,
items: QuoteItem[],
qLower: string,
numericMatch: number | null
): boolean {
// 1. Quote identifiers contain query
if ((quote.quote_number || "").toLowerCase().includes(qLower)) return true;
if ((quote.quote_reference || "").toLowerCase().includes(qLower)) return true;
if ((quote.rfq_number || "").toLowerCase().includes(qLower)) return true;
if ((quote.external_number || "").toLowerCase().includes(qLower)) return true;
// 2. Customer name contains query
const customer = quote.expand?.customer?.customer || "";
if (customer.toLowerCase().includes(qLower)) return true;
// 3. Company name contains query
const company = quote.expand?.company?.company || "";
if (company.toLowerCase().includes(qLower)) return true;
// 4. Any line item description contains query
for (const it of items) {
if ((it.description || "").toLowerCase().includes(qLower)) return true;
}
// 5. If the query is numeric, match against line prices. Tolerant
// epsilon so "12" matches 12.00 and "12.5" matches 12.50.
if (numericMatch !== null) {
const epsilon = 0.001;
for (const it of items) {
if (
Math.abs(Number(it.selling_price || 0) - numericMatch) < epsilon ||
Math.abs(Number(it.cost_price || 0) - numericMatch) < epsilon
) {
return true;
}
}
}
return false;
}
/**
* Fetch all `quote_items` rows linked to the given `quote` ids.
*
* Used by the search / fuzzy-match helpers. The naive approach —
* one big filter like `quote = "a" || quote = "b" || ...` — blows
* up once you have 50+ quote ids because PB's filter expression
* has a max length (HTTP 400 "Failed to load resource").
*
* Strategy:
* 1. Try PB's `IN` syntax first — `quote IN ("a", "b", "c")` —
* which is far more compact. Most modern PB versions support it.
* 2. If PB rejects the IN filter (older versions), fall back to
* chunked OR-chains of QUOTE_FILTER_CHUNK ids each.
*
* Either way, returns the merged list of items, with `supplier`
* expanded if the caller asked for it. Caller is responsible for
* bucketing the result by `quote` id (the shape used by both
* `searchQuotesWithItems` and `findMatchingQuotesForOrder`).
*/
const QUOTE_FILTER_CHUNK = 40;
export async function fetchQuoteItemsForQuotes(
quoteIds: string[],
options: { expand?: string } = {}
): Promise<QuoteItem[]> {
if (quoteIds.length === 0) return [];
const pb = getPocketBase();
// Strip the raw list of ids and escape any embedded quotes.
const escape = (id: string) => id.replace(/"/g, '\\"');
const safeIds = quoteIds.map(escape);
// 1. Try IN syntax — most PB 0.8+ instances support this.
try {
return await pb.collection("quote_items").getFullList<QuoteItem>({
filter: `quote IN (${safeIds.map((id) => `"${id}"`).join(",")})`,
...(options.expand ? { expand: options.expand } : {}),
});
} catch {
// 2. Fall back to chunked OR-chains for older PB versions.
}
const out: QuoteItem[] = [];
for (let i = 0; i < safeIds.length; i += QUOTE_FILTER_CHUNK) {
const batch = safeIds.slice(i, i + QUOTE_FILTER_CHUNK);
const orChain = batch.map((id) => `quote = "${id}"`).join(" || ");
const items = await pb.collection("quote_items").getFullList<QuoteItem>({
filter: orChain,
...(options.expand ? { expand: options.expand } : {}),
});
out.push(...items);
}
return out;
}
export async function searchQuotesWithItems(
q: string,
sort = "-created"
): Promise<Array<{ quote: Quote; items: QuoteItem[] }>> {
const pb = getPocketBase();
const trimmed = q.trim();
if (!trimmed) return [];
const quotes = await pb.collection("quotes").getList<Quote>(1, SEARCH_HARD_CAP, {
sort,
expand: "customer,customer.company,customer.rep,company",
});
const quoteIds = quotes.items.map((q0) => q0.id);
// Pull items via the chunked helper — the old single OR-chain
// filter overflowed PB's max filter length once the search
// returned 50+ quotes.
const items = await fetchQuoteItemsForQuotes(quoteIds, { expand: "supplier" });
const itemsByQuote: Record<string, QuoteItem[]> = {};
for (const item of items) {
(itemsByQuote[item.quote] ||= []).push(item);
}
const qLower = trimmed.toLowerCase();
const numericMatch = isNumericQuery(trimmed) ? Number(trimmed) : null;
const result: Array<{ quote: Quote; items: QuoteItem[] }> = [];
for (const quote of quotes.items) {
const qItems = itemsByQuote[quote.id] || [];
if (quoteMatchesSearch(quote, qItems, qLower, numericMatch)) {
result.push({ quote, items: qItems });
}
}
return result;
}
/* ============================================================================
* Order ↔ Quote matching
*
* Strategy ladder — each step is tried in order; the first non-empty hit
* is returned and recorded as the audit trail. The UI surfaces the
* method so the operator understands WHY a suggestion exists.
*
* 1. exact_quote_number — `quotes.quote_number == ref`
* 2. exact_quote_reference — `quotes.quote_reference == ref`
* 3. exact_rfq_number — `quotes.rfq_number == ref`
* 4. exact_external_number — `quotes.external_number == ref`
* 5. company+customer+desc+price (the fuzzy fallback) — used when
* no reference text is set OR when none of the exact lookups
* returned a hit. Filters quotes by company (and customer when
* known), scores each candidate by description-token overlap
* (Jaccard) plus price tolerance, and takes the best.
*
* Only ~55% of quotes have a meaningful quote_number (per the operator),
* so the fuzzy fallback is the path that handles the rest of the
* population. We cap the fuzzy candidate set at FUZZY_CANDIDATE_CAP
* so a runaway scan can't OOM the browser.
*
* The pure scoring helpers (tokenize, jaccard, priceScore,
* scoreCandidate) live in `src/lib/server/order-quote-match.ts` so
* they're unit-testable without PocketBase.
* ========================================================================== */
export type QuoteMatchMethod =
| "exact_quote_number"
| "exact_quote_reference"
| "exact_rfq_number"
| "exact_external_number"
| "company_customer_desc_price"
| "no_match";
export interface QuoteMatchCandidate {
quote: Quote;
items: QuoteItem[];
confidence: number; // 0..1
/** Per-signal score breakdown so the UI can show operators WHY a
* quote was chosen. Optional for backward compatibility with
* exact-lookup paths (which don't run the scorer). */
breakdown?: import("./server/order-quote-match").ScoreBreakdown;
method: Exclude<QuoteMatchMethod, "no_match">;
}
export interface QuoteMatchResult {
/** Best (and only) suggestion, or null if no candidate cleared the
* confidence threshold. */
best: QuoteMatchCandidate | null;
/** Up to 5 runners-up, useful for the "see all candidates" UI later. */
alternatives: QuoteMatchCandidate[];
method: QuoteMatchMethod;
}
function normRef(s: string): string {
return (s || "").trim();
}
/** Try to find one quote that matches `ref` against the four identifier
* fields. Returns the first hit (in priority order) or null. */
async function findExactMatch(
pb: ReturnType<typeof getPocketBase>,
ref: string
): Promise<{ quote: Quote; method: Exclude<QuoteMatchMethod, "no_match" | "company_customer_desc_price"> } | null> {
if (!ref) return null;
// Quote_number first (most common), then quote_reference (the customer's
// own PO ref we recorded when we issued), then rfq_number, then
// external_number.
for (const [field, method] of [
["quote_number", "exact_quote_number"],
["quote_reference", "exact_quote_reference"],
["rfq_number", "exact_rfq_number"],
["external_number", "exact_external_number"],
] as const) {
try {
const escaped = ref.replace(/"/g, '\\"');
const list = await pb.collection("quotes").getList<Quote>(1, 5, {
filter: `${field} = "${escaped}"`,
});
if (list.items.length > 0) {
return { quote: list.items[0], method };
}
} catch {
// Field might not exist in this PB schema (older deploys) —
// skip and try the next one.
}
}
return null;
}
/** Score & rank candidate quotes by company (+ customer) match. */
async function findFuzzyMatches(
pb: ReturnType<typeof getPocketBase>,
order: Order,
orderItems: OrderItem[]
): Promise<QuoteMatchCandidate[]> {
if (orderItems.length === 0) return []; // no signal — bail
// PB filter: company is required, customer narrows further when known.
const filters: string[] = [];
if (order.company) filters.push(`company = "${order.company}"`);
if (order.customer) filters.push(`(customer = "${order.customer}" || customer = "")`);
// Exclude templates — they aren't real orders.
filters.push("is_template != true");
const filter = filters.join(" && ");
const candidates = await pb.collection("quotes").getList<Quote>(1, FUZZY_CANDIDATE_CAP, {
filter,
sort: "-created",
expand: "customer",
});
if (candidates.items.length === 0) return [];
// Pull all candidate items via the chunked helper — same reason
// as in searchQuotesWithItems: a single huge OR-chain overflows
// PB's filter max length.
const qIds = candidates.items.map((q) => q.id);
const allItems = await fetchQuoteItemsForQuotes(qIds);
const itemsByQuote: Record<string, QuoteItem[]> = {};
for (const it of allItems) {
(itemsByQuote[it.quote] ||= []).push(it);
}
const projectedOrderItems = orderItems.map((it) => ({
description: it.description || "",
selling_price: Number(it.selling_price) || 0,
quantity: Number(it.quantity) || 1,
}));
const scored: QuoteMatchCandidate[] = [];
for (const q of candidates.items) {
const qItems = itemsByQuote[q.id] || [];
const projectedQuoteItems = qItems.map((it) => ({
description: it.description || "",
selling_price: Number(it.selling_price) || 0,
quantity: Number(it.quantity) || 1,
}));
// Rule-based scorer — uses company/customer relations and per-line
// signal matches. See `scoreOrderVsQuote` for the point budget.
const breakdown = scoreOrderVsQuote(
{
companyId: order.company,
customerId: order.customer,
items: projectedOrderItems,
},
{
company: q.company,
customer: q.customer,
items: projectedQuoteItems,
}
);
if (breakdown.confidence >= MATCH_CONFIDENCE_THRESHOLD) {
scored.push({
quote: q,
items: qItems,
confidence: breakdown.confidence,
breakdown,
method: "company_customer_desc_price",
});
}
}
// Sort: highest confidence first, then most recently created on tie.
// The PB query already returns candidates `-created`, but the local
// sort above only sees confidence — without a tie-breaker, an older
// quote can win over a fresher one when they score the same. Using
// `created` (not `updated`) so a no-op re-save of an old quote can't
// bump it past a genuinely newer competitor.
scored.sort((a, b) => {
if (b.confidence !== a.confidence) return b.confidence - a.confidence;
return (b.quote.created || "").localeCompare(a.quote.created || "");
});
return scored;
}
/**
* Run the matcher against an order and return the best suggestion
* (or null) plus up to 5 alternatives. Doesn't persist anything —
* the caller decides whether to accept the suggestion.
*
* `pb` is optional: when omitted the matcher uses the client-side
* singleton from `@/lib/pocketbase` (the normal browser path).
* Pass an explicit `pb` to use a server-side or admin-authenticated
* instance — useful for server scripts, n8n workflows, and tests
* that don't have the browser's auth context.
*/
export async function findMatchingQuotesForOrder(
order: Order,
orderItems: OrderItem[],
pb?: ReturnType<typeof getPocketBase>
): Promise<QuoteMatchResult> {
const client = pb ?? getPocketBase();
const ref = normRef(order.quote_reference_text || "");
// 1-4: Exact-key lookups when the operator gave us a reference. This
// is a deterministic fast path — we run it before the fuzzy scan
// because the operator typed something specific and we should
// honor it first when it matches.
if (ref) {
const exact = await findExactMatch(client, ref);
if (exact) {
return {
best: {
quote: exact.quote,
items: [], // not pre-fetched — caller can lazy-load if they open it
confidence: 1.0,
method: exact.method,
},
alternatives: [],
method: exact.method,
};
}
}
// 5: Fuzzy fallback. Runs whether or not the operator supplied a
// reference — the rule-based scorer can find matches from company +
// customer + items + price alone, without needing a typed
// identifier. This is what makes "Re-match" work on a freshly
// loaded order that has nothing in `quote_reference_text`.
const fuzzy = await findFuzzyMatches(client, order, orderItems);
if (fuzzy.length === 0) {
return { best: null, alternatives: [], method: "no_match" };
}
return {
best: fuzzy[0],
alternatives: fuzzy.slice(1, 6),
method: "company_customer_desc_price",
};
}
/**
* Persist the matcher's verdict on the order record. Sets
* `quote_matching_attempted = true` so the UI can show "we tried but
* found nothing" distinctly from "we haven't tried yet".
*
* If `accept` is true AND a candidate was found, the candidate's
* quote is promoted from `potential_quote` to the hard-linked
* `quote` field. If false, only the suggestion is recorded.
*
* `pb` is optional: same convention as `findMatchingQuotesForOrder`.
*/
export async function applyOrderQuoteMatch(
orderId: string,
result: QuoteMatchResult,
accept: boolean,
pb?: ReturnType<typeof getPocketBase>
): Promise<void> {
const client = pb ?? getPocketBase();
const patch: Record<string, unknown> = {
quote_matching_attempted: true,
quote_match_method: result.method,
};
if (result.best) {
patch.potential_quote = result.best.quote.id;
patch.potential_quote_confidence = result.best.confidence;
if (accept) {
patch.quote = result.best.quote.id;
}
} else {
// No suggestion — clear any stale soft-match.
patch.potential_quote = null;
patch.potential_quote_confidence = 0;
}
await client.collection("orders").update(orderId, patch);
}
/**
* Reset the matching verdict on an order so the operator can retry.
* Same `pb` convention as the other match helpers.
*/
export async function clearOrderQuoteMatch(
orderId: string,
pb?: ReturnType<typeof getPocketBase>
): Promise<void> {
const client = pb ?? getPocketBase();
await client.collection("orders").update(orderId, {
quote_matching_attempted: false,
quote_match_method: null,
potential_quote: null,
potential_quote_confidence: 0,
});
}
/**
* Manually hard-link an order to a quote. Used by the QuoteMatchCard
* "Browse quotes…" affordance when the operator picks a quote from
* the search dropdown — there's no reference text to match against,
* and we don't want to fake a QuoteMatchResult just to round-trip
* through `applyOrderQuoteMatch`.
*
* Sets the same fields as an accepted auto-match (`quote`,
* `quote_matching_attempted`, `quote_match_method = "manual"`) but
* does NOT populate `potential_quote` — manual links bypass the
* suggestion stage and become the hard link directly.
*
* `pb` is optional for symmetry with the other match helpers — pass
* it in server-side to keep one auth path.
*/
export async function setOrderQuoteManually(
orderId: string,
quoteId: string,
pb?: ReturnType<typeof getPocketBase>
): Promise<void> {
const client = pb ?? getPocketBase();
await client.collection("orders").update(orderId, {
quote: quoteId,
quote_matching_attempted: true,
quote_match_method: "manual",
potential_quote: null,
potential_quote_confidence: 0,
});
}
// Re-export the pure scoring helpers so unit tests can import them
// without poking into the PB layer.
export {
tokenize,
jaccard,
priceScore,
scoreCandidate,
scoreOrderVsQuote,
RULE_POINTS,
MATCH_CONFIDENCE_THRESHOLD,
} from "./server/order-quote-match";
export type { ScoreBreakdown, LineMatchBreakdown } from "./server/order-quote-match";
/**
* Fetch all attachments saved against a quote. Used by the
* "Original Email" card on the quote detail page so the quoter can
* download the files that came in with the original RFQ email.
*
* Separate query rather than a reverse-relation expand because PB's
* expand syntax doesn't easily cover reverse relations, and the
* `quote_attachments` collection is small per quote (usually 0–3 rows).
*
* Direction filter: pass "inbound" to fetch only email attachments,
* "outbound" for operator-uploaded files, "generated" for the
* rendered PDF, or omit to fetch all. Rows predating the 2026-06-23
* migration have `direction = null`; we treat those as "inbound"
* because every historical row came in from an email.
*/
export async function getQuoteAttachments(
quoteId: string,
direction?: "inbound" | "outbound" | "generated" | "all",
) {
const pb = getPocketBase();
const dir = direction && direction !== "all" ? direction : null;
const filterParts = [`quote = "${quoteId}"`];
if (dir) {
filterParts.push(`direction = "${dir}"`);
} else if (direction === "all") {
// No direction filter — return everything.
} else {
// Default to "inbound" (the historical meaning of attachments
// before the direction migration). We use a deliberately loose
// filter: `direction = "inbound" || direction = ""`. PB treats
// null and missing as equal for text compares, so this catches
// both pre-migration rows and explicitly-nulled rows.
filterParts.push(`(direction = "inbound" || direction = "")`);
}
const filter = filterParts.join(" && ");
try {
return await pb.collection("quote_attachments").getFullList<QuoteAttachment>({
filter,
sort: "-uploaded_at",
});
} catch (err) {
// Same fallback pattern as getQuotes: if the `direction` field
// doesn't exist in this PB yet (pre-2026-06-23 migration), PB
// returns 400. Retry without the direction clause so the page
// still renders; the user gets the historical "all attachments"
// view until they run the migration.
if (!is400(err)) throw err;
warnSchemaMissing("direction (attachments)");
return pb.collection("quote_attachments").getFullList<QuoteAttachment>({
filter: `quote = "${quoteId}"`,
sort: "-uploaded_at",
});
}
}
export async function getOrders(page = 1, perPage = 50, search = "", sort = "-created") {
const pb = getPocketBase();
const filter = search ? `order_number ~ "${search}"` : "";
// List view expands company, customer, quote, and potential_quote
// so the cards can show the matched-quote pill inline (and the
// soft-suggestion indicator) without an extra round-trip per row.
// The price is a slightly larger PB response per row, but at the
// WFDASH fleet scale this is well under a hundred bytes per
// extra field.
return pb.collection("orders").getList<Order>(page, perPage, {
filter,
sort,
expand: "company,customer,quote,potential_quote",
});
}
/**
* Per-tab-group totals for the orders list tabs.
*
* Counts the number of UNIQUE orders that have at least one item in
* each tab's item-status group. The actual data fetch happens in
* `getOrdersWithItems`; this helper is here so the list page can show
* tab badges without re-running the orders query.
*
* Implementation: one PB call per tab (6 total) using `getFullList`
* with a narrow `fields: "order"` projection, then deduping in JS.
* For the WFDASH fleet scale (hundreds of orders, not millions)
* this is well under a thousand records scanned total — cheap
* enough that we don't bother caching.
*/
export async function getOrderItemStatusTabCounts(): Promise<Record<TabGroup, number>> {
const pb = getPocketBase();
const tabs: TabGroup[] = [
"new",
"processing",
"collection_pool",
"collected",
"delivered",
"cancelled",
];
const results = await Promise.all(
tabs.map((tab) => {
const filter = TAB_GROUP_ITEM_STATUSES[tab]
.map((s) => `item_status = "${s}"`)
.join(" || ");
return pb.collection("order_items").getFullList<{ order: string }>({
filter,
fields: "order",
});
})
);
const counts = {} as Record<TabGroup, number>;
tabs.forEach((tab, i) => {
const uniqueOrders = new Set(results[i].map((row) => row.order).filter(Boolean));
counts[tab] = uniqueOrders.size;
});
return counts;
}
/**
* Orders with their line items and the tab groups each order touches.
*
* The list page needs the full picture for every visible row because
* an order can appear on multiple tabs (a 5-item order with items in
* `pending` + `processing` + `delivered` shows up on three tabs).
*
* Strategy:
* 1. Fetch the page of orders (no expand beyond company/customer).
* 2. Fetch all line items whose `order` is in this page (one PB
* call with an `IN` filter — far cheaper than N+1).
* 3. Bucket items onto their parent order.
* 4. Compute the set of tab groups each order touches (deduped).
*
* `search` filters by order_number. The returned `OrderWithItems`
* shape is what the page renders directly.
*/
export interface OrderWithItems {
order: Order;
items: OrderItem[];
/** Tab groups this order has at least one item in. */
tabGroups: TabGroup[];
}
export async function getOrdersWithItems(
page = 1,
perPage = 20,
search = "",
sort = "-created"
): Promise<{ items: OrderWithItems[]; totalPages: number; totalItems: number }> {
const pb = getPocketBase();
const ordersPage = await getOrders(page, perPage, search, sort);
const orderIds = ordersPage.items.map((o) => o.id);
let itemsByOrder: Record<string, OrderItem[]> = {};
if (orderIds.length > 0) {
// PocketBase 0.26 doesn't accept the `IN` operator in raw filter
// strings (returns HTTP 400). The portable form is OR-joined
// equality clauses, which PB evaluates fine for short lists
// (the page is capped at 20 ids). For larger datasets we'd
// batch this — but at the WFDASH fleet scale (hundreds of
// orders) a single OR-chain stays well under PB's filter-length
// budget and avoids the round-trip cost of N+1 fetches.
//
// Each id is double-quoted; PB ids are 15-char lowercase
// alphanumeric so escaping is theoretical but cheap to keep safe.
const orChain = orderIds.map((id) => `order = "${id.replace(/"/g, '\\"')}"`).join(" || ");
const items = await pb.collection("order_items").getFullList<OrderItem>({
filter: orChain,
expand: "supplier",
sort: "created",
});
itemsByOrder = items.reduce<Record<string, OrderItem[]>>((acc, item) => {
// `item.order` is now optional (ad-hoc delivery note items
// have no parent order). Bucket them under an empty-string
// key so they don't pollute the per-order lookup — the
// existing flow ignores them.
const key = item.order ?? "";
(acc[key] ||= []).push(item);
return acc;
}, {});
}
const items: OrderWithItems[] = ordersPage.items.map((order) => {
const orderItems = itemsByOrder[order.id] || [];
const tabSet = new Set<TabGroup>();
for (const it of orderItems) {
tabSet.add(itemStatusToTabGroup(it.item_status));
}
return {
order,
items: orderItems,
tabGroups: Array.from(tabSet),
};
});
return {
items,
totalPages: ordersPage.totalPages,
totalItems: ordersPage.totalItems,
};
}
/* ============================================================================
* searchOrdersWithItems — multi-field, client-side filtered search.
*
* Searches across (case-insensitive substring):
* - orders.order_number
* - company name (via expand)
* - customer name (via expand)
* - order_items.description (any line item)
* - order_items.selling_price / cost_price (exact numeric match if the
* query parses as a number — so "12" finds a line priced at 12 or
* 120; "12.22" finds a line priced at exactly 12.22)
*
* Why client-side instead of a single PB filter: PocketBase 0.26's
* filter syntax can't join across collections, and we want one search
* box that matches five different fields on two collections. Fetching
* up to `SEARCH_HARD_CAP` orders + their items, then filtering in JS,
* is the cleanest path at the WFDASH fleet scale (hundreds of orders).
*
* Returns a flat list — pagination is intentionally suppressed while
* searching because the operator is looking for a specific match, not
* browsing. The page surfaces "X matches" instead of page controls.
*
* Capped at `SEARCH_HARD_CAP` so a runaway query can't OOM the browser
* — at WFDASH's scale this is well above what real searches ever hit.
* ========================================================================== */
const SEARCH_HARD_CAP = 500;
/** True if the query parses as a plain decimal number (no currency, no commas). */
function isNumericQuery(q: string): boolean {
if (!/^\d+(\.\d+)?$/.test(q.trim())) return false;
return !Number.isNaN(Number(q));
}
function orderMatchesSearch(
order: Order,
items: OrderItem[],
qLower: string,
numericMatch: number | null
): boolean {
// 1. Order number contains query
if ((order.order_number || "").toLowerCase().includes(qLower)) return true;
// 2. Company name contains query
const company = order.expand?.company?.company || "";
if (company.toLowerCase().includes(qLower)) return true;
// 3. Customer name contains query
const customer = order.expand?.customer?.customer || "";
if (customer.toLowerCase().includes(qLower)) return true;
// 4. Any line item description contains query
for (const it of items) {
if ((it.description || "").toLowerCase().includes(qLower)) return true;
if (it.po_description && it.po_description.toLowerCase().includes(qLower)) return true;
}
// 5. If the query is numeric, match against line prices.
// Use a tolerant epsilon for float comparison so "12" matches 12.00,
// "12.5" matches 12.50, etc.
if (numericMatch !== null) {
const epsilon = 0.001;
for (const it of items) {
if (
Math.abs(Number(it.selling_price || 0) - numericMatch) < epsilon ||
Math.abs(Number(it.cost_price || 0) - numericMatch) < epsilon
) {
return true;
}
}
}
return false;
}
export async function searchOrdersWithItems(
q: string,
sort = "-created"
): Promise<OrderWithItems[]> {
const pb = getPocketBase();
const trimmed = q.trim();
if (!trimmed) return [];
const orders = await pb.collection("orders").getList<Order>(1, SEARCH_HARD_CAP, {
sort,
expand: "company,customer",
});
const orderIds = orders.items.map((o) => o.id);
// Fetch all items for these orders in one OR-chain call. PB 0.26
// doesn't accept IN, so we OR the equality clauses (see
// getOrdersWithItems for the rationale).
let items: OrderItem[] = [];
if (orderIds.length > 0) {
const orChain = orderIds.map((id) => `order = "${id.replace(/"/g, '\\"')}"`).join(" || ");
items = await pb.collection("order_items").getFullList<OrderItem>({
filter: orChain,
expand: "supplier",
});
}
// Bucket items onto their parent order for O(1) lookup during match.
// `item.order` is now optional (ad-hoc delivery note items have
// no parent order) — bucket those under an empty-string key so
// they don't get associated with a real order.
const itemsByOrder: Record<string, OrderItem[]> = {};
for (const item of items) {
const key = item.order ?? "";
(itemsByOrder[key] ||= []).push(item);
}
const qLower = trimmed.toLowerCase();
const numericMatch = isNumericQuery(trimmed) ? Number(trimmed) : null;
const result: OrderWithItems[] = [];
for (const order of orders.items) {
const orderItems = itemsByOrder[order.id] || [];
if (!orderMatchesSearch(order, orderItems, qLower, numericMatch)) continue;
const tabSet = new Set<TabGroup>();
for (const it of orderItems) {
tabSet.add(itemStatusToTabGroup(it.item_status));
}
result.push({ order, items: orderItems, tabGroups: Array.from(tabSet) });
}
return result;
}
export async function getOrder(id: string) {
const pb = getPocketBase();
return pb.collection("orders").getOne<Order>(id, { expand: "company,customer,quote" });
}
export async function getOrderByQuote(quoteId: string) {
const pb = getPocketBase();
try {
return await pb.collection("orders").getFirstListItem<Order>(`quote = "${quoteId}"`, {
expand: "company,customer,quote",
});
} catch {
return null;
}
}
export async function getOrderItems(orderId: string) {
const pb = getPocketBase();
return pb.collection("order_items").getFullList<OrderItem>({
filter: `order = "${orderId}"`,
expand: "supplier",
});
}
export async function createOrderFromQuote(quoteId: string) {
const pb = getPocketBase();
const existingOrder = await getOrderByQuote(quoteId);
if (existingOrder) {
return { order: existingOrder, alreadyExists: true };
}
const quote = await pb.collection("quotes").getOne<Quote>(quoteId);
const quoteItems = await pb.collection("quote_items").getFullList<QuoteItem>({
filter: `quote = "${quoteId}"`,
sort: "created",
});
if (quoteItems.length === 0) {
throw new Error("Quote has no items to convert.");
}
// Pull rep from the linked customer record when present, so the order
// inherits it instead of starting empty. The operator can still override
// it on the order detail page if needed.
let rep = "";
if (quote.customer) {
try {
const cust = await pb.collection("customers").getOne<Customer>(quote.customer);
rep = cust.rep || "";
} catch {
// Customer record missing — leave rep empty, the operator can
// type it on the order detail page.
}
}
const orderNumber = await generateOrderNumber();
const order = await pb.collection("orders").create<Order>({
order_number: orderNumber,
company: quote.company,
customer: quote.customer || "",
quote: quote.id,
notes: quote.notes || "",
quote_matching_attempted: true,
quote_match_confidence: 1,
potential_quote: quote.id,
potential_quote_confidence: 1,
is_repair_order: false,
invoiced: false,
rep,
});
for (const item of quoteItems) {
const quantity = Number(item.quantity || 1);
await pb.collection("order_items").create({
order: order.id,
description: item.description || "",
po_description: item.description || "",
quantity,
order_qty: quantity,
selling_price: Number(item.selling_price || 0),
supplier: item.supplier || null,
cost_price: Number(item.cost_price || 0),
markup: Number(item.markup || 0),
markup_percent: Number(item.markup || 0),
item_status: "pending",
drv_status: "unassigned",
driver_status: "unassigned",
notes: item.notes || "",
quote_item: item.id,
});
}
return { order, alreadyExists: false };
}
export async function getCollectionPool(page = 1, perPage = 50, status = "") {
const pb = getPocketBase();
const filter = status ? `pool_status = "${status}"` : "";
return pb.collection("collection_pool").getList<CollectionPool>(page, perPage, {
filter,
sort: "-created",
expand: "order_item,order,supplier,drv_id",
});
}
export async function updateOrderItemWorkflowStatus(
itemId: string,
patch: Partial<Pick<OrderItem, "item_status" | "drv_status" | "notes">>
) {
const pb = getPocketBase();
return pb.collection("order_items").update<OrderItem>(itemId, patch);
}
export async function updateCollectionPoolStatus(
poolItem: CollectionPool,
poolStatus: CollectionPool["pool_status"]
) {
const pb = getPocketBase();
const payload: Record<string, unknown> = { pool_status: poolStatus };
if (poolStatus === "assigned") {
payload.assigned_at = new Date().toISOString();
}
if (poolStatus === "completed") {
payload.completed_at = new Date().toISOString();
}
const updated = await pb.collection("collection_pool").update<CollectionPool>(poolItem.id, payload);
if (poolStatus === "assigned") {
await pb.collection("order_items").update(poolItem.order_item, {
item_status: "assigned",
drv_status: "assigned",
});
}
if (poolStatus === "in_progress") {
await pb.collection("order_items").update(poolItem.order_item, {
drv_status: "en_route",
});
}
if (poolStatus === "completed") {
await pb.collection("order_items").update(poolItem.order_item, {
item_status: "collected",
drv_status: "collected",
});
}
if (poolStatus === "failed") {
await pb.collection("order_items").update(poolItem.order_item, {
drv_status: "failed",
problem_flag: true,
problem_type: "driver",
});
}
return updated;
}
export async function getDeliveryNotes(page = 1, perPage = 50, search = "") {
const pb = getPocketBase();
const filter = search ? `delivery_note_number ~ "${search}" || notes ~ "${search}"` : "";
return pb.collection("delivery_notes").getList<DeliveryNote>(page, perPage, {
filter,
sort: "-created",
expand: "order,company,customer",
});
}
/**
* Fetch a single delivery note by id, with the relations the
* Delivery Notes page actions need (customer, company, order,
* and the operator who created the note). Use this from the
* server-side handlers that want the full shape; the list page
* already has its own per-row fields.
*/
export async function getDeliveryNoteById(id: string): Promise<DeliveryNote> {
const pb = getPocketBase();
return pb.collection("delivery_notes").getOne<DeliveryNote>(id, {
expand: "order,company,customer,created_by",
});
}
/**
* Read the order_items that belong to a delivery note, grouped
* by their shared `delivery_note_number` string. Client-side
* helper for the Delivery Notes page's "items in this note"
* expansion (when we add one) — the list-page rows already
* carry the count via the order_items relationship.
*/
export async function getDeliveryNoteItems(deliveryNoteNumber: string) {
const pb = getPocketBase();
return pb.collection("order_items").getFullList<import("@/lib/types").OrderItem>({
filter: `delivery_note_number = "${deliveryNoteNumber.replace(/"/g, '\\"')}"`,
expand: "supplier",
});
}
/**
* Find the Quote that prices a given delivery note. Returns the
* quote regardless of its current status (claimed, processed,
* emailed, etc.) — the detail page surfaces "open in quote" for
* any non-cancelled DN-backed quote.
*
* The relation is one-way: `quotes.delivery_note` points at the
* delivery note. A delivery note should have at most one active
* quote; older or cancelled quotes can still match if the
* operator re-runs `createQuoteFromDeliveryNote` (which the
* service refuses unless the existing one is gone). We return
* the most recent match in case of a historical edge case.
*/
export async function getQuoteByDeliveryNote(
deliveryNoteId: string,
): Promise<Quote | null> {
const pb = getPocketBase();
const result = await pb.collection("quotes").getList<Quote>(1, 1, {
filter: `delivery_note = "${deliveryNoteId.replace(/"/g, '\\"')}"`,
sort: "-created",
expand: "customer,company",
});
return result.items[0] ?? null;
}
/**
* Batch-fetch the most-recent quote for each of the given
* delivery notes. Used by the list page so the Q icon can
* distinguish "create quote" (no quote yet) from "open quote"
* (quote already exists) without a round-trip per row.
*
* PB doesn't have a native IN operator, so we OR-chain the
* equality clauses. With a list of <100 DNs this is fine;
* past that the filter gets long and we'd want to switch to
* a per-DN fetch (which the dev list never needs).
*
* Returns a `Map<deliveryNoteId, Quote>`. DNs with no quote
* simply don't appear in the map.
*/
export async function getQuotesForDeliveryNotes(
deliveryNoteIds: string[],
): Promise<Map<string, Quote>> {
const out = new Map<string, Quote>();
if (deliveryNoteIds.length === 0) return out;
const pb = getPocketBase();
const clauses = deliveryNoteIds
.map((id) => id.replace(/"/g, '\\"'))
.map((id) => `delivery_note = "${id}"`)
.join(" || ");
// PB pages the result — for the realistic list size (≤50
// rows on a 20-per-page table) one page is enough. If we
// ever hit a "no rows" response we still want the caller
// to get an empty map, not throw.
let page = 1;
// Generous cap: list pages won't show > 200 DNs; the cap
// exists so a malformed input doesn't loop forever.
while (page < 20) {
const result = await pb.collection("quotes").getList<Quote>(
page,
200,
{
filter: clauses,
sort: "-created",
},
);
for (const q of result.items) {
if (q.delivery_note && !out.has(q.delivery_note)) {
out.set(q.delivery_note, q);
}
}
if (result.items.length < 200) break;
page++;
}
return out;
}
export async function generateDeliveryNoteNumber(): Promise<string> {
const pb = getPocketBase();
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, "0");
const prefix = `DN-${year}-${month}-`;
const existing = await pb.collection("delivery_notes").getList<DeliveryNote>(1, 1, {
filter: `delivery_note_number ~ "${prefix}"`,
sort: "-delivery_note_number",
});
let seq = 1;
if (existing.items.length > 0) {
const lastNum = parseInt(existing.items[0].delivery_note_number.split("-").pop() || "0", 10);
seq = lastNum + 1;
}
return `${prefix}${String(seq).padStart(3, "0")}`;
}
export async function createDeliveryNoteFromOrder(order: Order, items: OrderItem[]) {
const pb = getPocketBase();
const eligible = items.filter((item) => ["ready_delivery", "delivered"].includes(item.item_status));
if (eligible.length === 0) {
throw new Error("No ready or delivered items available for a delivery note.");
}
const deliveryNoteNumber = await generateDeliveryNoteNumber();
const deliveryNote = await pb.collection("delivery_notes").create<DeliveryNote>({
delivery_note_number: deliveryNoteNumber,
order: order.id,
company: order.company || null,
customer: order.customer || null,
status: "draft",
notes: order.notes || "",
});
for (const item of eligible) {
await pb.collection("delivery_note_items").create({
delivery_note: deliveryNote.id,
order_item: item.id,
description: item.description,
quantity: item.quantity,
});
}
return deliveryNote;
}
export async function getRepairs(page = 1, perPage = 50, search = "", status = "") {
const pb = getPocketBase();
const filters: string[] = [];
if (search) {
filters.push(`repair_number ~ "${search}" || item_description ~ "${search}" || fault_description ~ "${search}"`);
}
if (status) {
filters.push(`status = "${status}"`);
}
return pb.collection("repairs").getList<Repair>(page, perPage, {
filter: filters.join(" && "),
sort: "-created",
expand: "customer,company,order",
});
}
export async function generateRepairNumber(): Promise<string> {
const pb = getPocketBase();
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, "0");
const prefix = `REP-${year}-${month}-`;
const existing = await pb.collection("repairs").getList<Repair>(1, 1, {
filter: `repair_number ~ "${prefix}"`,
sort: "-repair_number",
});
let seq = 1;
if (existing.items.length > 0) {
const lastNum = parseInt(existing.items[0].repair_number.split("-").pop() || "0", 10);
seq = lastNum + 1;
}
return `${prefix}${String(seq).padStart(3, "0")}`;
}
/**
* Pricelist — fetch a page of rows from the `raw_price_list` collection.
*
* Backs the `/pricelist` page. The live collection has these fields
* (discovered via the admin API):
* - supplier_file: the file
* - supplier_name: relation → suppliers
* - status: select
* - log_summary: free-text
*
* Search hits `log_summary` and the expanded supplier's `suppliername`.
* (PB can't substring-match file tokens, so the filename isn't in the
* search filter — but PB's URL token is the filename in practice, so
* the user can find rows by scrolling.)
*
* Newest first — the table is a feed of recent uploads, not an
* alphabetised directory.
*/
export async function getRawPriceLists(
page = 1,
perPage = 50,
search = "",
sort = "-created"
) {
const pb = getPocketBase();
// `~` is PB's case-insensitive contains operator. We match against
// log_summary directly; for supplier we OR against the expanded
// supplier's suppliername. PB 0.26 doesn't accept the `IN` operator
// (see MEMORY.md), so we do a simple OR-chain.
const filter = search
? `log_summary ~ "${search}" || supplier_name.suppliername ~ "${search}"`
: "";
return pb.collection("raw_price_list").getList<RawPriceList>(page, perPage, {
filter,
sort,
expand: "supplier_name",
});
}
export async function deleteRawPriceList(id: string) {
const pb = getPocketBase();
return pb.collection("raw_price_list").delete(id);
}
/**
* Catalogue — fetch a page of rows from the `price_list` collection.
*
* Backs the "Catalogue" tab on the `/pricelist` page. Search hits
* every searchable text field on the row (OR'd, case-insensitive):
*
* Direct columns (PB `~` operator, each indexed):
* - sku, description, brand, supplier
* - category, subcategory, unit, source_file
*
* Catch-all (denormalised blob, indexed):
* - search_text — every token from the row, including the full
* `specifications` JSON (keys + string values + number values
* + array values + nested-object keys), all four number
* columns tokenised, and the resolved supplier name. PB can't
* substring-match JSON, so without this the rich bits
* (synonyms, dimensions, material, etc.) would be unreachable.
*
* Why two layers: the explicit columns let PB's filter planner use
* their indexes; search_text is the catch-all for everything else
* (the JSON bits, numbers, supplier-name resolution). Adding a
* new text column to price_list only requires adding it to
* `PRICE_LIST_SEARCHABLE_TEXT_COLUMNS` in `price-list-search.ts`.
*
* Optional `category` and `brand` filters AND the search (narrow
* further).
*
* Newest first — the table is a feed of recent extractions, not an
* alphabetised directory.
*/
export async function getPriceLists(
page = 1,
perPage = 50,
search = "",
filters: { category?: string; brand?: string } = {},
sort = "-updated"
) {
const pb = getPocketBase();
const clauses: string[] = [];
const searchClause = buildPriceListSearchFilter(search);
if (searchClause) clauses.push(searchClause);
if (filters.category) {
clauses.push(`category = "${filters.category.replace(/"/g, '\\"')}"`);
}
if (filters.brand) {
clauses.push(`brand = "${filters.brand.replace(/"/g, '\\"')}"`);
}
const filter = clauses.join(" && ");
return pb.collection("price_list").getList<PriceList>(page, perPage, {
filter,
sort,
});
}
/**
* Look up a single price_list row for an exact SKU. Used by the
* ad-hoc "Create Delivery Note" form to auto-fill cost + selling
* price when the operator picks a stock item — price lives on
* `price_list`, not on `stock_items` (stock_items is about
* inventory; price_list is the supplier's catalogue).
*
* Returns the most-recent match (sort by `-updated`). When the
* SKU is missing from price_list, returns null — the form then
* shows a "no price for this SKU" hint and the operator can
* either pick a different supplier's catalogue row, or type the
* price manually.
*
* Note: this matches the catalogue's `sku` field exactly. The
* `sku_normalised` field is used by the dedicated catalogue
* search flow (which tolerates formatting variations); for the
* ad-hoc DN flow we trust the SKU the operator already typed.
*/
export async function getPriceListForSku(sku: string): Promise<PriceList | null> {
const pb = getPocketBase();
const trimmed = sku.trim();
if (!trimmed) return null;
const escaped = trimmed.replace(/"/g, '\\"');
const result = await pb.collection("price_list").getList<PriceList>(1, 1, {
filter: `sku = "${escaped}"`,
sort: "-updated",
});
return result.items[0] ?? null;
}
export async function createRepair(data: {
customer?: string;
company?: string;
order?: string;
item_description: string;
fault_description: string;
notes?: string;
}) {
const pb = getPocketBase();
const repairNumber = await generateRepairNumber();
return pb.collection("repairs").create<Repair>({
repair_number: repairNumber,
customer: data.customer || null,
company: data.company || null,
order: data.order || null,
status: "new",
item_description: data.item_description,
fault_description: data.fault_description,
notes: data.notes || "",
});
}
/**
* @deprecated For new code, call `/api/v1/quotes/next-number` instead. This
* client-side wrapper is kept for backwards compatibility with the quote
* form. The server endpoint uses an atomic counter (no race), while this
* client function reads-then-writes the last quote number (racy under
* concurrent creates).
*/
export async function generateQuoteNumber(): Promise<string> {
const pb = getPocketBase();
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, "0");
const prefix = `Q${year}${month}`;
const existing = await pb.collection("quotes").getList<Quote>(1, 1, {
filter: `quote_number ~ "${prefix}"`,
sort: "-quote_number",
});
let seq = 1;
if (existing.items.length > 0) {
const lastRef = existing.items[0].quote_number;
// Extract trailing digits (handles both Q202607011 and legacy
// Q-2026-07-011 formats).
const m = lastRef.match(/(\d+)$/);
const lastNum = m ? parseInt(m[1], 10) : 0;
seq = lastNum + 1;
}
return `${prefix}${String(seq).padStart(3, "0")}`;
}
export async function generateOrderNumber(): Promise<string> {
const pb = getPocketBase();
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, "0");
const prefix = `ORD-${year}-${month}-`;
const existing = await pb.collection("orders").getList<Order>(1, 1, {
filter: `order_number ~ "${prefix}"`,
sort: "-order_number",
});
let seq = 1;
if (existing.items.length > 0) {
const lastNum = parseInt(existing.items[0].order_number.split("-").pop() || "0", 10);
seq = lastNum + 1;
}
return `${prefix}${String(seq).padStart(3, "0")}`;
}
export async function generatePONumber(): Promise<string> {
const pb = getPocketBase();
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, "0");
const prefix = `PO-${year}-${month}-`;
const existing = await pb.collection("purchase_orders").getList<PurchaseOrder>(1, 1, {
filter: `po_number ~ "${prefix}"`,
sort: "-po_number",
});
let seq = 1;
if (existing.items.length > 0) {
const lastNum = parseInt(existing.items[0].po_number.split("-").pop() || "0", 10);
seq = lastNum + 1;
}
return `${prefix}${String(seq).padStart(3, "0")}`;
}
export function calculateTotals(items: { selling_price: number; quantity: number }[]) {
const subtotal = Number(items.reduce((sum, item) => sum + item.selling_price * item.quantity, 0).toFixed(2));
const vat_amount = Number((subtotal * 0.15).toFixed(2));
const grand_total = Number((subtotal + vat_amount).toFixed(2));
return { subtotal, vat_amount, grand_total };
}
export function formatCurrency(amount: number): string {
return new Intl.NumberFormat("en-ZA", { style: "currency", currency: "ZAR" }).format(amount);
}
/**
* Two-piece currency format for KPI cards that don't have enough width
* for the full `formatCurrency` string. `main` is always short enough
* to fit in a 4-col grid card at `text-2xl`; `full` is the precise
* value to show as a sub-label or tooltip.
*
* R 492 369,00 -> main "R 492K" full "R 492,369"
* R 12 345,00 -> main "R 12.3K" full "R 12,345"
* R 999,00 -> main "R 999" full "R 999"
* R 0,00 -> main "R 0" full "R 0"
* R -1 200,00 -> main "-R 1.2K" full "-R 1,200"
*/
export function formatCurrencyCompact(amount: number): {
main: string;
full: string;
} {
const sign = amount < 0 ? "-" : "";
const abs = Math.abs(amount);
const full = `${sign}R ${new Intl.NumberFormat("en-ZA").format(Math.round(abs))}`;
if (abs >= 1_000_000) {
const v = abs / 1_000_000;
return {
main: `${sign}R ${v >= 10 ? v.toFixed(0) : v.toFixed(1)}M`,
full,
};
}
if (abs >= 10_000) {
const v = abs / 1_000;
return {
main: `${sign}R ${v >= 100 ? v.toFixed(0) : v.toFixed(1)}K`,
full,
};
}
return { main: full, full };
}
export function formatDate(dateStr: string): string {
if (!dateStr) return "";
return new Date(dateStr).toLocaleDateString("en-ZA", {
year: "numeric",
month: "short",
day: "numeric",
});
}
export function exportToCSV(data: Record<string, unknown>[], filename: string) {
if (data.length === 0) return;
const headers = Object.keys(data[0]);
const csv = [
headers.join(","),
...data.map((row) =>
headers
.map((h) => {
const val = row[h];
const str = val === null || val === undefined ? "" : String(val);
return str.includes(",") || str.includes('"') ? `"${str.replace(/"/g, '""')}"` : str;
})
.join(",")
),
].join("\n");
const blob = new Blob([csv], { type: "text/csv" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${filename}.csv`;
a.click();
URL.revokeObjectURL(url);
}
/* ============================================================================
* Duplicate detection — added 2026-07-22.
*
* Three concerns: catch "I typed it twice" cases on Customers / Companies /
* Suppliers without flooding the operator with false positives. Each table
* has its own rules because the natural unique key differs:
*
* - Companies: the `company` name is the practical unique key. Vendor
* numbers are sometimes reused across orgs (parent / child), so we
* don't fold them into the check.
* - Suppliers: `suppliername`. Email + number are contact details that
* legitimately change when a supplier switches accounts.
* - Customers: the same person can legitimately appear at multiple
* companies, so `customer` alone is too loose. The natural key is
* (name, company) — same name AND works for the same company. Email
* is a secondary consistency check: if both records have an email
* set, those must also match (catches "I typed a slightly different
* email for the same person" without blocking legitimately shared
* inboxes at small companies).
*
* Implementation note: PB's `=` is case-sensitive, and there's no built-in
* case-insensitive exact-match operator. We use `~` (case-insensitive
* substring) as the PB filter, then post-filter client-side for an exact
* case-insensitive trimmed match. At WFDASH scale (hundreds to low
* thousands per table) the substring set is small and the client-side
* filter is O(matches).
*
* `excludeId` is what lets the form use these helpers in edit mode
* without flagging the record being edited as a duplicate of itself.
* ========================================================================== */
/** Trim + lower-case for duplicate comparisons. Empty in → empty out. */
function normDup(s: string | null | undefined): string {
return (s ?? "").trim().toLowerCase();
}
/** Escape a string for use inside a PB filter literal (double-quoted). */
function escapeDup(s: string): string {
return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
}
/**
* Common fetch path: pull a small page of records whose `field` matches
* the trimmed value as a case-insensitive substring, then narrow to an
* exact case-insensitive trimmed match. Returns up to `limit` matches.
*/
async function fetchNameMatches<T extends BaseRecord>(
collectionName: "companies" | "suppliers" | "customers",
field: string,
value: string,
limit: number,
excludeId: string | undefined,
expand?: string
): Promise<T[]> {
const trimmed = (value ?? "").trim();
if (!trimmed) return [];
const pb = getPocketBase();
const filter = `${field} ~ "${escapeDup(trimmed)}"${excludeId ? ` && id != "${escapeDup(excludeId)}"` : ""}`;
const result = await pb.collection(collectionName).getList<T>(1, limit, {
filter,
sort: "-created",
...(expand ? { expand } : {}),
});
const target = normDup(trimmed);
return result.items.filter((row) => normDup((row as unknown as Record<string, string>)[field]) === target);
}
/**
* Find duplicate companies by name (case-insensitive, trimmed). Used by
* the company form to flag "I typed Acme twice" before save.
*/
export async function findCompanyDuplicates(
companyName: string,
excludeId?: string
): Promise<Company[]> {
return fetchNameMatches<Company>("companies", "company", companyName, 20, excludeId);
}
/**
* Find duplicate suppliers by name (case-insensitive, trimmed).
*/
export async function findSupplierDuplicates(
supplierName: string,
excludeId?: string
): Promise<Supplier[]> {
return fetchNameMatches<Supplier>("suppliers", "suppliername", supplierName, 20, excludeId);
}
/**
* Find duplicate customers. A candidate is a duplicate when:
* - `customer` matches the form's name (case-insensitive, trimmed) AND
* - `company` matches the form's company id (relation) AND
* - if both records have a non-empty `email`, the emails also match
* (case-insensitive, trimmed). If either side has no email, the
* email check is skipped (otherwise the form would miss every
* customer that's missing an email on either side).
*
* The `company` relation is stored as an id string on the customer row,
* so equality is a straight string compare — no name-lookup needed.
*/
export async function findCustomerDuplicates(
customerName: string,
companyId: string,
email: string,
excludeId?: string
): Promise<Customer[]> {
const candidates = await fetchNameMatches<Customer>(
"customers",
"customer",
customerName,
50,
excludeId,
"company"
);
const targetName = normDup(customerName);
const targetEmail = normDup(email);
return candidates.filter((c) => {
if (normDup(c.customer) !== targetName) return false;
if ((c.company || "") !== (companyId || "")) return false;
if (targetEmail && normDup(c.email)) {
if (normDup(c.email) !== targetEmail) return false;
}
return true;
});
}