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(page, perPage, { filter, sort, expand, }); } export async function getCustomer(id: string, expand?: string) { const pb = getPocketBase(); return pb.collection("customers").getOne(id, { expand }); } export async function getAllCustomers(expand?: string) { const pb = getPocketBase(); return pb.collection("customers").getFullList({ 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(page, perPage, { filter, sort }); } export async function getCompany(id: string) { const pb = getPocketBase(); return pb.collection("companies").getOne(id); } export async function getAllCompanies() { const pb = getPocketBase(); return pb.collection("companies").getFullList({ 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(page, perPage, { filter, sort }); } export async function getSupplier(id: string) { const pb = getPocketBase(); return pb.collection("suppliers").getOne(id); } export async function getAllSuppliers() { const pb = getPocketBase(); return pb.collection("suppliers").getFullList({ 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({ 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({ sort: "name", filter: "is_active = true", expand: "supplier", }); } export async function getStockItem(id: string) { const pb = getPocketBase(); return pb.collection("stock_items").getOne(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(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(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(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({ 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(id, { expand: "customer,customer.company,customer.rep,company", }); } /** * Per-tab quote counts for the list-page tabs. Returns a `Record` * 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 = { 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(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(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(1, 1, { filter: `delivery_note != ""${gate}`, }), ); return requests; }; try { const perStatus = await Promise.all(buildRequests(true)); const counts: Record = {}; 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 = {}; 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({ 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; fields?: string } = {} ): Promise { 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({ filter: `quote IN (${safeIds.map((id) => `"${id}"`).join(",")})`, ...(options.expand ? { expand: options.expand } : {}), ...(options.fields ? { fields: options.fields } : {}), }); } 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({ filter: orChain, ...(options.expand ? { expand: options.expand } : {}), ...(options.fields ? { fields: options.fields } : {}), }); out.push(...items); } return out; } /** * Per-quote line item count summary: `{ total, processed }` keyed by * quote id. Powers the small "5/10" progress badge in the Quote # * column on the list page. * * Single round-trip per page load: tries the PB `IN` filter first, * falls back to chunked OR-chains (mirrors `fetchQuoteItemsForQuotes` * for older PB instances). Only `quote` and `is_processed` are * requested, so the payload stays tiny even for pages with many * heavy items (descriptions, attachments, etc). * * Quotes with zero items simply don't appear in the returned map — * callers treat a missing key as "no items, hide the badge". */ export async function fetchQuoteItemCounts( quoteIds: string[], ): Promise> { if (quoteIds.length === 0) return {}; const pb = getPocketBase(); const escape = (id: string) => id.replace(/"/g, '\\"'); const safeIds = quoteIds.map(escape); // Slim payload — we only need quote + is_processed. The PB // `fields` selector cuts out descriptions, notes, file refs, // expand payloads, etc., which would otherwise dominate the // response on pages with many items. type Slim = { id?: string; quote: string; is_processed: boolean }; const fetchSlim = (filter: string) => pb.collection("quote_items").getFullList({ filter, fields: "id,quote,is_processed", }); let items: Slim[] = []; try { items = await fetchSlim( `quote IN (${safeIds.map((id) => `"${id}"`).join(",")})`, ); } catch { // Older PB without `IN` — chunk through OR-chains. Each chunk // is well under the filter-length limit so the same fallback // `fetchQuoteItemsForQuotes` uses works here. 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(" || "); items.push(...(await fetchSlim(orChain))); } } const counts: Record = {}; for (const item of items) { const c = counts[item.quote] ?? { total: 0, processed: 0 }; c.total += 1; if (item.is_processed) c.processed += 1; counts[item.quote] = c; } return counts; } export async function searchQuotesWithItems( q: string, sort = "-created" ): Promise> { const pb = getPocketBase(); const trimmed = q.trim(); if (!trimmed) return []; // Slim fields + per-expand limits. The quotes collection has 45+ // fields including unbounded `email_body` / `email_message`; the // search path was dragging every full record over the wire. The // service-layer /quotes/list read model uses the same projection. // // `expand` MUST appear in the projection — PB treats `fields` as a // hard allowlist and silently drops the `expand` payload if "expand" // itself isn't listed. Without it, the customer / company / rep // cells on /quotes render "—" the moment the operator types into // search. (Same root cause as the sprint-page bug; see the matching // `listQuotesForDashboard` comment that intentionally omits // `fields` for the same reason.) const QUOTE_SEARCH_FIELDS = [ "id", "quote_number", "status", "claimed_by", "is_template", "follow_up_due", "last_reply_at", "customer", "company", "created", "updated", "company.id", "company.company", "customer.id", "customer.customer", "customer.rep", "customer.rep.id", "customer.rep.name", "customer.rep.email", // PB quirk: keep the expand block in the response. Without this, // `expand.customer.customer` etc. resolve to `undefined` and the // row's Customer column shows "—" for every search hit. "expand", ].join(","); const quotes = await pb.collection("quotes").getList(1, SEARCH_HARD_CAP, { sort, expand: "customer,customer.rep,company", fields: QUOTE_SEARCH_FIELDS, }); 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. The items path keeps the supplier expand // because the per-row chevron expansion renders the supplier name. // // `expand` is included in the projection for the same reason as // QUOTE_SEARCH_FIELDS above — PB drops the expand block if "expand" // isn't listed, and the row's Supplier cell would render blank. const items = await fetchQuoteItemsForQuotes(quoteIds, { expand: "supplier", fields: "id,quote,description,quantity,selling_price,is_processed,supplier,supplier.suppliername,expand", }); const itemsByQuote: Record = {}; 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; } 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, ref: string ): Promise<{ quote: Quote; method: Exclude } | 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(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, order: Order, orderItems: OrderItem[] ): Promise { 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(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 = {}; 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 ): Promise { 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 ): Promise { const client = pb ?? getPocketBase(); const patch: Record = { 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 ): Promise { 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 ): Promise { 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" | "internal" | "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({ 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({ 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(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> { 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; 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 = {}; 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({ filter: orChain, expand: "supplier", sort: "created", }); itemsByOrder = items.reduce>((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(); 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 { const pb = getPocketBase(); const trimmed = q.trim(); if (!trimmed) return []; const orders = await pb.collection("orders").getList(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({ 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 = {}; 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(); 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(id, { expand: "company,customer,quote" }); } export async function getOrderByQuote(quoteId: string) { const pb = getPocketBase(); try { return await pb.collection("orders").getFirstListItem(`quote = "${quoteId}"`, { expand: "company,customer,quote", }); } catch { return null; } } export async function getOrderItems(orderId: string) { const pb = getPocketBase(); return pb.collection("order_items").getFullList({ 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(quoteId); const quoteItems = await pb.collection("quote_items").getFullList({ 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(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_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(page, perPage, { filter, sort: "-created", expand: "order_item,order,supplier,drv_id", }); } export async function updateOrderItemWorkflowStatus( itemId: string, patch: Partial> ) { const pb = getPocketBase(); return pb.collection("order_items").update(itemId, patch); } export async function updateCollectionPoolStatus( poolItem: CollectionPool, poolStatus: CollectionPool["pool_status"] ) { const pb = getPocketBase(); const payload: Record = { 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(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(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 { const pb = getPocketBase(); return pb.collection("delivery_notes").getOne(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({ 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 { const pb = getPocketBase(); const result = await pb.collection("quotes").getList(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`. DNs with no quote * simply don't appear in the map. */ export async function getQuotesForDeliveryNotes( deliveryNoteIds: string[], ): Promise> { const out = new Map(); 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( 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 { 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(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({ 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(page, perPage, { filter: filters.join(" && "), sort: "-created", expand: "customer,company,order", }); } export async function generateRepairNumber(): Promise { 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(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(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(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 { const pb = getPocketBase(); const trimmed = sku.trim(); if (!trimmed) return null; const escaped = trimmed.replace(/"/g, '\\"'); const result = await pb.collection("price_list").getList(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_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 { 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(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 { 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(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 { 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(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[], 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( collectionName: "companies" | "suppliers" | "customers", field: string, value: string, limit: number, excludeId: string | undefined, expand?: string ): Promise { 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(1, limit, { filter, sort: "-created", ...(expand ? { expand } : {}), }); const target = normDup(trimmed); return result.items.filter((row) => normDup((row as unknown as Record)[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 { return fetchNameMatches("companies", "company", companyName, 20, excludeId); } /** * Find duplicate suppliers by name (case-insensitive, trimmed). */ export async function findSupplierDuplicates( supplierName: string, excludeId?: string ): Promise { return fetchNameMatches("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 { const candidates = await fetchNameMatches( "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; }); }