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"; // The customers-page search now lives in `src/lib/server/customers-service.ts` // (with rep-name support) and is exposed to the browser via // `/api/v1/customers/list`. Migrated 2026-08 as part of the customers-page // data-layer cleanup — see AGENTS.md "data-layer discipline". // // `getCustomer` and `getAllCustomers` still live here because they have // other callers (customer detail page, order-form, quote-form, etc.) // that haven't been migrated yet. They share the same naming convention // with the service functions but use the cookie-auth PB instance // (browser-side). 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", }); } /* ============================================================================ * `fetchQuoteItemsForQuotes` — shared by the order-quote matcher * (just below) and the new search service. 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. * * Returns the merged list of items. Caller is responsible for * bucketing the result by `quote` id. * ========================================================================== */ 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 safeIds = quoteIds.map((id) => id.replace(/"/g, '\\"')); type Slim = { id?: string; quote: string; is_processed: boolean }; const fetchSlim = (filter: string) => pb.collection("quote_items").getFullList({ filter, fields: "id,quote,is_processed", }); // Build the chunked filters first, then issue them in parallel. PB // accepts the same number of concurrent reads without rate-limiting // for a small page-worth of chunks, and `Promise.all` collapses // wall-clock to the slowest chunk instead of the sum. Sequential // was used historically because the IN-then-fallback code path // bailed early on success; with the always-OR-chain strategy // parallel is strictly better and the chunks are decoupled. const chunks: string[] = []; for (let i = 0; i < safeIds.length; i += QUOTE_FILTER_CHUNK) { const batch = safeIds.slice(i, i + QUOTE_FILTER_CHUNK); chunks.push(batch.map((id) => `quote = "${id}"`).join(" || ")); } const perChunk = await Promise.all(chunks.map(fetchSlim)); const items: Slim[] = perChunk.flat(); 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; } /* ============================================================================ * 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; } /* ============================================================================ * Quote matcher — DELETED in Task 13 of the order↔quote linking plan. * * The browser-side matcher block (findMatchingQuotesForOrder, * applyOrderQuoteMatch, clearOrderQuoteMatch, setOrderQuoteManually + * the private normRef / findExactMatch / findFuzzyMatches helpers * they used to depend on, plus the ScoreBreakdown / LineMatchBreakdown * re-exports from order-quote-match) was deleted. The matcher moved * to the server-side service (`src/lib/server/order-quote-link-service.ts`) * so the route handler, the intake `after()` hook, and the on-demand * re-check can all share one source of truth without needing the * browser's PB singleton in scope. * * The page migrated to fetch against /api/v1/orders/{id}/quote-match * and /api/v1/orders/{id}/quote as part of the same task. Tests that * previously imported these helpers from `@/lib/pb-helpers` now * import directly from `@/lib/server/order-quote-match` (pure) or * `@/lib/server/order-quote-link-service` (PB-backed). * ========================================================================== */ /** * 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; } /** * Fetch delivery notes for the list page. * * ## Why there is no `search` parameter any more * * This used to take a search term and push it into a PocketBase * filter over `delivery_note_number || notes`. Two problems, both * visible to the operator: * * 1. **It missed most of what a row shows.** Customer name, company * and order number were not searched — and "what's outstanding * for Acme" is the first thing anyone types on a list that is * grouped BY company. Those fields only arrive via `expand`, so * they can't be filtered server-side by this shape of query * without a join on every row. * 2. **The term was never escaped.** The `repId` clause on the very * next line escapes its quotes; the search clause didn't. A * customer called `Acme "Ltd"` — or anyone typing a stray * double quote — produced a broken filter literal and a list * that failed to load. * * So the search moved client-side (`matchesDeliveryNoteSearch` in * `src/lib/delivery-note-stage.ts`), over rows that are already * fetched WITH their customer / company / order expanded. One code * path, every visible field, and no way to build a malformed filter. */ export async function getDeliveryNotes( page = 1, perPage = 50, repId: string | null = null, ) { const pb = getPocketBase(); const clauses: string[] = []; if (repId) { clauses.push(`customer.rep = "${repId.replace(/"/g, '\\"')}"`); } const filter = clauses.join(" && "); return pb.collection("delivery_notes").getList(page, perPage, { filter, sort: "-created", expand: "order,company,customer", }); } /** * 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(); // CHUNKED, deliberately. // // This used to build ONE filter of `delivery_note = "x" || ... ` // across every id. The list page fetches 500 delivery notes, so // that filter carried ~250 clauses — and PocketBase rejects it // with a bare `400 {"message":"Something went wrong..."}`. Measured // against this instance: 75 clauses succeed, 100 fail. // // The caller treats this fetch as non-fatal and swallows the error, // so the symptom was completely silent: `quoteByDnId` came back // EMPTY, and therefore // - the Q icon never switched to "open quote" mode, // - `hasQuote` was always false, // - the blue "quoted" row tint never appeared, and // - the Send action could never be enabled. // Found in the browser, after a whole session of tests passing: // the unit tests all use a handful of ids, well under the limit. // // 50 is comfortably under the measured ceiling. Five sequential // requests for a 250-note list is nothing next to the full // delivery_notes page already being fetched alongside it. const CHUNK = 50; const ids = deliveryNoteIds.map((id) => id.replace(/"/g, '\\"')); for (let start = 0; start < ids.length; start += CHUNK) { const slice = ids.slice(start, start + CHUNK); const clauses = slice.map((id) => `delivery_note = "${id}"`).join(" || "); // PB pages the result — the caller only ever needs the most // recent quote per note, and one page covers a 50-note chunk. // Generous cap so a malformed input can't loop forever. let page = 1; while (page < 20) { const result = await pb.collection("quotes").getList( page, 200, { filter: clauses, sort: "-created", // The delivery-notes list mounts `SendQuoteModal` directly, // and that modal reads `quote.expand.customer.rep` to // pre-fill the rep on CC. Without this expand it silently // sends with no CC, so the office never sees a copy of what // went out. expand: "customer,company", }, ); 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")}`; } // PO numbers are generated server-side only: see createPurchaseOrderWithNumber // in src/lib/server/po-number.ts (the client copy raced, 2026-10-19). 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; }); }