WFDASH-WEBAPP/src/lib/pb-helpers.ts
Dave f017a7f985 fix: number customer POs one at a time (duplicate PO numbers)
PO numbers were "read the month's highest, add one", so POs for several
suppliers on one order created together got the same number — 34 numbers
by 2026-10-19 (e.g. PO202610008LIF for BarloWorld CAT and John Ratcliffe,
created 71 ms apart).

- createPurchaseOrderWithNumber (po-number.ts): numbers and creates POs
  one at a time in the server, retries (up to 5) when the unique index
  rejects a clash; ignores any po_number passed in.
- buyer-tasks / MCP generate_po use it.
- The Process page's Send PO and in-stock PO buttons now call
  POST /api/v1/orders/[id]/purchase-orders (browser session) instead of
  numbering in the browser; the client generatePONumber is removed from
  pb-helpers.
- npm run pb:migrate:po-number-unique adds a partial unique index on
  purchase_orders.po_number for POs created from then on; the 34 existing
  duplicates are left as they are.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 15:08:12 +02:00

1691 lines
62 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

import getPocketBase from "./pocketbase";
import type { Customer, Company, Supplier, Quote, QuoteItem, QuoteAttachment, Order, OrderItem, PurchaseOrder, CollectionPool, DeliveryNote, Repair, RawPriceList, PriceList, StockItem, BaseRecord } from "./types";
import { TAB_GROUP_ITEM_STATUSES, itemStatusToTabGroup, type TabGroup } from "./item-status-ui";
import { buildPriceListSearchFilter } from "./price-list-search";
// 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<Customer>(id, { expand });
}
export async function getAllCustomers(expand?: string) {
const pb = getPocketBase();
return pb.collection("customers").getFullList<Customer>({
sort: "-created",
expand,
});
}
export async function getCompanies(page = 1, perPage = 50, search = "", sort = "-created") {
const pb = getPocketBase();
const filter = search
? `company ~ "${search}" || vendor ~ "${search}" || company_email ~ "${search}" || company_tel ~ "${search}"`
: "";
return pb.collection("companies").getList<Company>(page, perPage, { filter, sort });
}
export async function getCompany(id: string) {
const pb = getPocketBase();
return pb.collection("companies").getOne<Company>(id);
}
export async function getAllCompanies() {
const pb = getPocketBase();
return pb.collection("companies").getFullList<Company>({ sort: "-created" });
}
export async function getSuppliers(page = 1, perPage = 50, search = "", sort = "-created") {
const pb = getPocketBase();
const filter = search
? `suppliername ~ "${search}" || email ~ "${search}" || suppliernumber ~ "${search}" || supply_tags ~ "${search}"`
: "";
return pb.collection("suppliers").getList<Supplier>(page, perPage, { filter, sort });
}
export async function getSupplier(id: string) {
const pb = getPocketBase();
return pb.collection("suppliers").getOne<Supplier>(id);
}
export async function getAllSuppliers() {
const pb = getPocketBase();
return pb.collection("suppliers").getFullList<Supplier>({ sort: "-created" });
}
/**
* Fields the supplier-map actually renders. Kept narrow so a 5k-row
* supplier table doesn't ship `closingtime` and the rest of the blob
* over the wire just to show pins on a Leaflet map.
*/
const SUPPLIER_MAP_FIELDS =
"id,suppliername,supplieraddress,suppliernumber,email,supply_tags,coordinates";
/**
* Suppliers that can actually be placed on the map — only rows with
* non-empty `coordinates`, and only the fields the map renders.
*
* Distinct from `getAllSuppliers` (which the quote/order forms still
* want full records from). Introduced when the supplier-map was
* choking on zoom-in with a few thousand suppliers: shipping the
* whole table was the biggest single contributor.
*/
export async function getMappableSuppliers() {
const pb = getPocketBase();
return pb.collection("suppliers").getFullList<Supplier>({
sort: "-created",
filter: 'coordinates != ""',
fields: SUPPLIER_MAP_FIELDS,
});
}
/* ============================================================================
* Stock items — added 2026-07-14 (Stream E: v3 stock-out flow).
*
* `getActiveStockItems` is what the order-entry pages call to feed
* the "Fulfill from stock" picker. Filters by `is_active=true` so
* deactivated items don't pollute the dropdown — same convention as
* the supplier map's `coordinates != ""` filter.
*
* `getStockItem` is the single-record variant used by the
* /api/v1/stock/fulfill route when it needs to re-read current_qty
* just before debiting (defensive — catches the race where the
* picker was filled with an earlier `current_qty` than the current
* row holds).
* ========================================================================== */
export async function getActiveStockItems() {
const pb = getPocketBase();
return pb.collection("stock_items").getFullList<StockItem>({
sort: "name",
filter: "is_active = true",
expand: "supplier",
});
}
export async function getStockItem(id: string) {
const pb = getPocketBase();
return pb.collection("stock_items").getOne<StockItem>(id, {
expand: "supplier",
});
}
/**
* Quote queries reference `is_template`, `quote_date`, `validity_days`,
* `follow_up_due`, `template_source_id`, `generated_from_template_id` —
* fields that are added in v5 by importing `pb_schema (1).json`.
*
* Until the schema is imported, PB rejects the filter with HTTP 400,
* which spams the browser console. We try the new filter first; on
* failure we fall back to the legacy unfiltered query and log a
* one-time reminder. After schema import everything works as designed.
*/
function is400(err: unknown): boolean {
if (!err || typeof err !== "object") return false;
const status = (err as { status?: number }).status;
return status === 400;
}
let warnedSchemaMissing = false;
function warnSchemaMissing(what: string) {
if (warnedSchemaMissing) return;
warnedSchemaMissing = true;
console.warn(
`[pb-helpers] Quote query falling back to legacy filter (${what}). ` +
"Import the new fields from pb_schema (1).json into PocketBase to enable the new filter."
);
}
/**
* Fetch a page of quotes, optionally filtered by one or more statuses.
*
* @param statuses One or more `quotes.status` values to OR-chain into a
* `status = "x" || status = "y" || …` filter. PB 0.26
* doesn't accept the `IN` operator, so we build the
* OR-chain ourselves (see MEMORY.md / PocketBase 0.26:
* no `IN` operator in raw filter strings). Pass `[]`
* (default) to skip the status filter.
*
* The Templates tab historically passed a single `status = "template"`
* string; that flow is now handled by passing `["template"]` instead.
*/
export async function getQuotes(
page = 1,
perPage = 50,
search = "",
sort = "-created",
statuses: string[] = [],
options: { deliveryNoteOnly?: boolean } = {}
) {
const pb = getPocketBase();
// Hide templates from every non-template tab so they don't pollute the
// workflow view. The Templates tab passes ["template"] explicitly —
// `statuses.includes("template")` lets it through.
const filters: string[] = [];
if (!statuses.includes("template")) filters.push("is_template != true");
if (search) {
filters.push(`quote_number ~ "${search}" || quote_reference ~ "${search}" || rfq_number ~ "${search}"`);
}
if (statuses.length > 0) {
const statusClause = statuses.map((s) => `status = "${s}"`).join(" || ");
filters.push(`(${statusClause})`);
}
// The "Delivery Note" tab on the Quotes page lists every quote
// that backs a delivery note, regardless of where it sits in
// the lifecycle (claimed, processed, emailed, etc). This is a
// relation filter, not a status filter — both can be combined
// if we ever need a "DN quotes in sent status" view.
if (options.deliveryNoteOnly) {
filters.push("delivery_note != ''");
}
const newFilter = filters.join(" && ");
try {
return await pb.collection("quotes").getList<Quote>(page, perPage, {
filter: newFilter,
sort,
expand: "customer,customer.company,customer.rep,company",
});
} catch (err) {
if (!is400(err)) throw err;
warnSchemaMissing("is_template");
// Drop just the is_template clause; keep the rest.
const legacyFilters = filters.filter((f) => f !== "is_template != true");
return pb.collection("quotes").getList<Quote>(page, perPage, {
filter: legacyFilters.join(" && "),
sort,
expand: "customer,customer.company,customer.rep,company",
});
}
}
/**
* Fetch only template quotes — for the templates page / dashboard widget.
* Sorted with the most-recently-created first.
*
* Returns an empty list (with a console warning) if the `is_template`
* field doesn't exist in PB yet, since this is brand-new functionality
* that fundamentally needs the field.
*/
export async function getTemplates(page = 1, perPage = 50) {
const pb = getPocketBase();
try {
return await pb.collection("quotes").getList<Quote>(page, perPage, {
filter: "is_template = true",
sort: "-created",
expand: "customer",
});
} catch (err) {
if (!is400(err)) throw err;
warnSchemaMissing("is_template (templates)");
// Minimal shape — only callers destructure .items, the rest is
// cosmetic for the widgets.
return {
items: [] as Quote[],
page: 1,
perPage,
totalItems: 0,
totalPages: 1,
};
}
}
/**
* Quotes whose user-set `follow_up_due` is today or in the past and
* which are not yet in a terminal status. Used by the dashboard widget
* to flag "needs review" work.
*
* Returns an empty list (with a console warning) until `follow_up_due`
* and `is_template` exist on the live PB collection.
*/
export async function getFollowUpDueQuotes() {
const pb = getPocketBase();
const today = new Date().toISOString();
try {
return await pb.collection("quotes").getFullList<Quote>({
filter: `follow_up_due != "" && follow_up_due <= "${today}" && status != "complete" && status != "cancelled" && status != "rejected" && is_template != true`,
sort: "follow_up_due",
expand: "customer",
});
} catch (err) {
if (!is400(err)) throw err;
warnSchemaMissing("follow_up_due / is_template (follow-ups)");
return [] as Quote[];
}
}
export async function getQuote(id: string) {
const pb = getPocketBase();
// Expand customer AND customer's nested relations (company + rep)
// so the detail page can render real names instead of opaque ids.
// PB expand syntax: comma-separated paths, nested via dot.
return pb.collection("quotes").getOne<Quote>(id, {
expand: "customer,customer.company,customer.rep,company",
});
}
/**
* Per-tab quote counts for the list-page tabs. Returns a `Record<tabKey, number>`
* where each key matches a tab key on the quotes list page:
*
* new ← status = new || status = follow_up (follow-up rows live in New)
* claimed ← status = claimed
* pending_approval ← status = processed || status = approval_pending || status = sending
* completed ← status = approved || status = complete || status = emailed
* || status = cancelled || status = rejected
* email_failed ← status = email_failed || status = mail_failed || status = unmatched_bounce
* problem ← status = problem
* templates ← is_template = true
* all ← unfiltered (excludes templates, like every other tab)
*
* One PB call per underlying status (with `perPage: 1` so we only transfer a
* count, not records). We don't make a separate "all" call — we sum the
* per-tab counts and trust PB's atomicity within a single request batch
* (the alternative — an unfiltered call — can disagree with the per-tab
* sum if statuses flip mid-query).
*/
export async function getQuoteStatusCounts() {
const pb = getPocketBase();
// Underlying statuses → tab key. A tab key can map to multiple statuses.
const statusToTab: Record<string, string> = {
new: "new",
follow_up: "new",
claimed: "claimed",
processed: "pending_approval",
approval_pending: "pending_approval",
// Partial-approval variants (added 2026-07-24) live in the same
// "needs eyes" tab as approval_pending — the rep reviews them on
// /quotes/approvals, but the list-page tab is the same.
partial_approval_pending: "pending_approval",
sending: "pending_approval",
approved: "completed",
complete: "completed",
// partial_approved is terminal for THIS batch (the parent quote),
// but a continuation quote (status=new) is the live one. Treat it
// as completed for tab purposes — the operator views the
// continuation in the "new" tab. The /quotes/approvals page still
// sees the parent via its own filter if needed.
partial_approved: "completed",
emailed: "completed",
cancelled: "completed",
rejected: "completed",
email_failed: "email_failed",
mail_failed: "email_failed",
unmatched_bounce: "email_failed",
problem: "problem",
};
const tabKeys = [
"new",
"claimed",
"pending_approval",
"completed",
"email_failed",
"problem",
];
// Try with the v5 template-exclusion filter; fall back if PB doesn't
// know `is_template` yet (schema not imported).
const notTemplate = `is_template != true`;
const buildRequests = (withTemplateGate: boolean) => {
const gate = withTemplateGate ? ` && ${notTemplate}` : "";
const requests = Object.keys(statusToTab).map((status) =>
pb.collection("quotes").getList<Quote>(1, 1, {
filter: `status = "${status}"${gate}`,
})
);
// Templates tab uses `is_template = true` (the status filter would
// exclude templates from the workflow track).
const templateFilter = withTemplateGate
? `is_template = true`
: `is_template = true`;
requests.push(pb.collection("quotes").getList<Quote>(1, 1, { filter: templateFilter }));
// The Delivery Note tab counts every quote linked to a
// delivery note (status-agnostic — covers in-flight + sent +
// invoiced). Skipped in the legacy fallback because
// `delivery_note` is a newer field and the count is just
// visual; the tab still works with count 0 if the field
// doesn't exist.
requests.push(
pb.collection("quotes").getList<Quote>(1, 1, {
filter: `delivery_note != ""${gate}`,
}),
);
return requests;
};
try {
const perStatus = await Promise.all(buildRequests(true));
const counts: Record<string, number> = {};
for (const key of tabKeys) counts[key] = 0;
counts.templates = 0;
counts.delivery_note = 0;
let allCount = 0;
Object.keys(statusToTab).forEach((status, i) => {
const tab = statusToTab[status];
counts[tab] = (counts[tab] || 0) + perStatus[i].totalItems;
allCount += perStatus[i].totalItems;
});
counts.templates = perStatus[perStatus.length - 2].totalItems;
counts.delivery_note = perStatus[perStatus.length - 1].totalItems;
// "All" includes every tab EXCEPT templates (templates live in their own tab).
counts.all = allCount;
return counts;
} catch (err) {
if (!is400(err)) throw err;
warnSchemaMissing("is_template (status counts)");
const perStatus = await Promise.all(buildRequests(false));
const counts: Record<string, number> = {};
for (const key of tabKeys) counts[key] = 0;
counts.templates = 0;
counts.delivery_note = 0;
let allCount = 0;
Object.keys(statusToTab).forEach((status, i) => {
const tab = statusToTab[status];
counts[tab] = (counts[tab] || 0) + perStatus[i].totalItems;
allCount += perStatus[i].totalItems;
});
counts.templates = perStatus[perStatus.length - 2].totalItems;
counts.delivery_note = perStatus[perStatus.length - 1].totalItems;
counts.all = allCount;
return counts;
}
}
export async function getQuoteItems(quoteId: string) {
const pb = getPocketBase();
return pb.collection("quote_items").getFullList<QuoteItem>({
filter: `quote = "${quoteId}"`,
expand: "supplier",
});
}
/* ============================================================================
* `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<QuoteItem[]> {
if (quoteIds.length === 0) return [];
const pb = getPocketBase();
// Strip the raw list of ids and escape any embedded quotes.
const escape = (id: string) => id.replace(/"/g, '\\"');
const safeIds = quoteIds.map(escape);
// 1. Try IN syntax — most PB 0.8+ instances support this.
try {
return await pb.collection("quote_items").getFullList<QuoteItem>({
filter: `quote IN (${safeIds.map((id) => `"${id}"`).join(",")})`,
...(options.expand ? { expand: options.expand } : {}),
...(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<QuoteItem>({
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<Record<string, { total: number; processed: number }>> {
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<Slim>({
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<string, { total: number; processed: number }> = {};
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<QuoteMatchMethod, "no_match">;
}
export interface QuoteMatchResult {
/** Best (and only) suggestion, or null if no candidate cleared the
* confidence threshold. */
best: QuoteMatchCandidate | null;
/** Up to 5 runners-up, useful for the "see all candidates" UI later. */
alternatives: QuoteMatchCandidate[];
method: QuoteMatchMethod;
}
/* ============================================================================
* 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<QuoteAttachment>({
filter,
sort: "-uploaded_at",
});
} catch (err) {
// Same fallback pattern as getQuotes: if the `direction` field
// doesn't exist in this PB yet (pre-2026-06-23 migration), PB
// returns 400. Retry without the direction clause so the page
// still renders; the user gets the historical "all attachments"
// view until they run the migration.
if (!is400(err)) throw err;
warnSchemaMissing("direction (attachments)");
return pb.collection("quote_attachments").getFullList<QuoteAttachment>({
filter: `quote = "${quoteId}"`,
sort: "-uploaded_at",
});
}
}
export async function getOrders(page = 1, perPage = 50, search = "", sort = "-created") {
const pb = getPocketBase();
const filter = search ? `order_number ~ "${search}"` : "";
// List view expands company, customer, quote, and potential_quote
// so the cards can show the matched-quote pill inline (and the
// soft-suggestion indicator) without an extra round-trip per row.
// The price is a slightly larger PB response per row, but at the
// WFDASH fleet scale this is well under a hundred bytes per
// extra field.
return pb.collection("orders").getList<Order>(page, perPage, {
filter,
sort,
expand: "company,customer,quote,potential_quote",
});
}
/**
* Per-tab-group totals for the orders list tabs.
*
* Counts the number of UNIQUE orders that have at least one item in
* each tab's item-status group. The actual data fetch happens in
* `getOrdersWithItems`; this helper is here so the list page can show
* tab badges without re-running the orders query.
*
* Implementation: one PB call per tab (6 total) using `getFullList`
* with a narrow `fields: "order"` projection, then deduping in JS.
* For the WFDASH fleet scale (hundreds of orders, not millions)
* this is well under a thousand records scanned total — cheap
* enough that we don't bother caching.
*/
export async function getOrderItemStatusTabCounts(): Promise<Record<TabGroup, number>> {
const pb = getPocketBase();
const tabs: TabGroup[] = [
"new",
"processing",
"collection_pool",
"collected",
"delivered",
"cancelled",
];
const results = await Promise.all(
tabs.map((tab) => {
const filter = TAB_GROUP_ITEM_STATUSES[tab]
.map((s) => `item_status = "${s}"`)
.join(" || ");
return pb.collection("order_items").getFullList<{ order: string }>({
filter,
fields: "order",
});
})
);
const counts = {} as Record<TabGroup, number>;
tabs.forEach((tab, i) => {
const uniqueOrders = new Set(results[i].map((row) => row.order).filter(Boolean));
counts[tab] = uniqueOrders.size;
});
return counts;
}
/**
* Orders with their line items and the tab groups each order touches.
*
* The list page needs the full picture for every visible row because
* an order can appear on multiple tabs (a 5-item order with items in
* `pending` + `processing` + `delivered` shows up on three tabs).
*
* Strategy:
* 1. Fetch the page of orders (no expand beyond company/customer).
* 2. Fetch all line items whose `order` is in this page (one PB
* call with an `IN` filter — far cheaper than N+1).
* 3. Bucket items onto their parent order.
* 4. Compute the set of tab groups each order touches (deduped).
*
* `search` filters by order_number. The returned `OrderWithItems`
* shape is what the page renders directly.
*/
export interface OrderWithItems {
order: Order;
items: OrderItem[];
/** Tab groups this order has at least one item in. */
tabGroups: TabGroup[];
}
export async function getOrdersWithItems(
page = 1,
perPage = 20,
search = "",
sort = "-created"
): Promise<{ items: OrderWithItems[]; totalPages: number; totalItems: number }> {
const pb = getPocketBase();
const ordersPage = await getOrders(page, perPage, search, sort);
const orderIds = ordersPage.items.map((o) => o.id);
let itemsByOrder: Record<string, OrderItem[]> = {};
if (orderIds.length > 0) {
// PocketBase 0.26 doesn't accept the `IN` operator in raw filter
// strings (returns HTTP 400). The portable form is OR-joined
// equality clauses, which PB evaluates fine for short lists
// (the page is capped at 20 ids). For larger datasets we'd
// batch this — but at the WFDASH fleet scale (hundreds of
// orders) a single OR-chain stays well under PB's filter-length
// budget and avoids the round-trip cost of N+1 fetches.
//
// Each id is double-quoted; PB ids are 15-char lowercase
// alphanumeric so escaping is theoretical but cheap to keep safe.
const orChain = orderIds.map((id) => `order = "${id.replace(/"/g, '\\"')}"`).join(" || ");
const items = await pb.collection("order_items").getFullList<OrderItem>({
filter: orChain,
expand: "supplier",
sort: "created",
});
itemsByOrder = items.reduce<Record<string, OrderItem[]>>((acc, item) => {
// `item.order` is now optional (ad-hoc delivery note items
// have no parent order). Bucket them under an empty-string
// key so they don't pollute the per-order lookup — the
// existing flow ignores them.
const key = item.order ?? "";
(acc[key] ||= []).push(item);
return acc;
}, {});
}
const items: OrderWithItems[] = ordersPage.items.map((order) => {
const orderItems = itemsByOrder[order.id] || [];
const tabSet = new Set<TabGroup>();
for (const it of orderItems) {
tabSet.add(itemStatusToTabGroup(it.item_status));
}
return {
order,
items: orderItems,
tabGroups: Array.from(tabSet),
};
});
return {
items,
totalPages: ordersPage.totalPages,
totalItems: ordersPage.totalItems,
};
}
/* ============================================================================
* searchOrdersWithItems — multi-field, client-side filtered search.
*
* Searches across (case-insensitive substring):
* - orders.order_number
* - company name (via expand)
* - customer name (via expand)
* - order_items.description (any line item)
* - order_items.selling_price / cost_price (exact numeric match if the
* query parses as a number — so "12" finds a line priced at 12 or
* 120; "12.22" finds a line priced at exactly 12.22)
*
* Why client-side instead of a single PB filter: PocketBase 0.26's
* filter syntax can't join across collections, and we want one search
* box that matches five different fields on two collections. Fetching
* up to `SEARCH_HARD_CAP` orders + their items, then filtering in JS,
* is the cleanest path at the WFDASH fleet scale (hundreds of orders).
*
* Returns a flat list — pagination is intentionally suppressed while
* searching because the operator is looking for a specific match, not
* browsing. The page surfaces "X matches" instead of page controls.
*
* Capped at `SEARCH_HARD_CAP` so a runaway query can't OOM the browser
* — at WFDASH's scale this is well above what real searches ever hit.
* ========================================================================== */
const SEARCH_HARD_CAP = 500;
/** True if the query parses as a plain decimal number (no currency, no commas). */
function isNumericQuery(q: string): boolean {
if (!/^\d+(\.\d+)?$/.test(q.trim())) return false;
return !Number.isNaN(Number(q));
}
function orderMatchesSearch(
order: Order,
items: OrderItem[],
qLower: string,
numericMatch: number | null
): boolean {
// 1. Order number contains query
if ((order.order_number || "").toLowerCase().includes(qLower)) return true;
// 2. Company name contains query
const company = order.expand?.company?.company || "";
if (company.toLowerCase().includes(qLower)) return true;
// 3. Customer name contains query
const customer = order.expand?.customer?.customer || "";
if (customer.toLowerCase().includes(qLower)) return true;
// 4. Any line item description contains query
for (const it of items) {
if ((it.description || "").toLowerCase().includes(qLower)) return true;
if (it.po_description && it.po_description.toLowerCase().includes(qLower)) return true;
}
// 5. If the query is numeric, match against line prices.
// Use a tolerant epsilon for float comparison so "12" matches 12.00,
// "12.5" matches 12.50, etc.
if (numericMatch !== null) {
const epsilon = 0.001;
for (const it of items) {
if (
Math.abs(Number(it.selling_price || 0) - numericMatch) < epsilon ||
Math.abs(Number(it.cost_price || 0) - numericMatch) < epsilon
) {
return true;
}
}
}
return false;
}
export async function searchOrdersWithItems(
q: string,
sort = "-created"
): Promise<OrderWithItems[]> {
const pb = getPocketBase();
const trimmed = q.trim();
if (!trimmed) return [];
const orders = await pb.collection("orders").getList<Order>(1, SEARCH_HARD_CAP, {
sort,
expand: "company,customer",
});
const orderIds = orders.items.map((o) => o.id);
// Fetch all items for these orders in one OR-chain call. PB 0.26
// doesn't accept IN, so we OR the equality clauses (see
// getOrdersWithItems for the rationale).
let items: OrderItem[] = [];
if (orderIds.length > 0) {
const orChain = orderIds.map((id) => `order = "${id.replace(/"/g, '\\"')}"`).join(" || ");
items = await pb.collection("order_items").getFullList<OrderItem>({
filter: orChain,
expand: "supplier",
});
}
// Bucket items onto their parent order for O(1) lookup during match.
// `item.order` is now optional (ad-hoc delivery note items have
// no parent order) — bucket those under an empty-string key so
// they don't get associated with a real order.
const itemsByOrder: Record<string, OrderItem[]> = {};
for (const item of items) {
const key = item.order ?? "";
(itemsByOrder[key] ||= []).push(item);
}
const qLower = trimmed.toLowerCase();
const numericMatch = isNumericQuery(trimmed) ? Number(trimmed) : null;
const result: OrderWithItems[] = [];
for (const order of orders.items) {
const orderItems = itemsByOrder[order.id] || [];
if (!orderMatchesSearch(order, orderItems, qLower, numericMatch)) continue;
const tabSet = new Set<TabGroup>();
for (const it of orderItems) {
tabSet.add(itemStatusToTabGroup(it.item_status));
}
result.push({ order, items: orderItems, tabGroups: Array.from(tabSet) });
}
return result;
}
export async function getOrder(id: string) {
const pb = getPocketBase();
return pb.collection("orders").getOne<Order>(id, { expand: "company,customer,quote" });
}
export async function getOrderByQuote(quoteId: string) {
const pb = getPocketBase();
try {
return await pb.collection("orders").getFirstListItem<Order>(`quote = "${quoteId}"`, {
expand: "company,customer,quote",
});
} catch {
return null;
}
}
export async function getOrderItems(orderId: string) {
const pb = getPocketBase();
return pb.collection("order_items").getFullList<OrderItem>({
filter: `order = "${orderId}"`,
expand: "supplier",
});
}
export async function createOrderFromQuote(quoteId: string) {
const pb = getPocketBase();
const existingOrder = await getOrderByQuote(quoteId);
if (existingOrder) {
return { order: existingOrder, alreadyExists: true };
}
const quote = await pb.collection("quotes").getOne<Quote>(quoteId);
const quoteItems = await pb.collection("quote_items").getFullList<QuoteItem>({
filter: `quote = "${quoteId}"`,
sort: "created",
});
if (quoteItems.length === 0) {
throw new Error("Quote has no items to convert.");
}
// Pull rep from the linked customer record when present, so the order
// inherits it instead of starting empty. The operator can still override
// it on the order detail page if needed.
let rep = "";
if (quote.customer) {
try {
const cust = await pb.collection("customers").getOne<Customer>(quote.customer);
rep = cust.rep || "";
} catch {
// Customer record missing — leave rep empty, the operator can
// type it on the order detail page.
}
}
const orderNumber = await generateOrderNumber();
const order = await pb.collection("orders").create<Order>({
order_number: orderNumber,
company: quote.company,
customer: quote.customer || "",
quote: quote.id,
notes: quote.notes || "",
quote_matching_attempted: true,
quote_match_confidence: 1,
potential_quote: quote.id,
potential_quote_confidence: 1,
is_repair_order: false,
invoiced: false,
rep,
});
for (const item of quoteItems) {
const quantity = Number(item.quantity || 1);
await pb.collection("order_items").create({
order: order.id,
description: item.description || "",
po_description: item.description || "",
quantity,
order_qty: quantity,
selling_price: Number(item.selling_price || 0),
supplier: item.supplier || null,
cost_price: Number(item.cost_price || 0),
markup: Number(item.markup || 0),
markup_percent: Number(item.markup || 0),
item_status: "pending",
drv_status: "unassigned",
driver_status: "unassigned",
notes: item.notes || "",
quote_item: item.id,
});
}
return { order, alreadyExists: false };
}
export async function getCollectionPool(page = 1, perPage = 50, status = "") {
const pb = getPocketBase();
const filter = status ? `pool_status = "${status}"` : "";
return pb.collection("collection_pool").getList<CollectionPool>(page, perPage, {
filter,
sort: "-created",
expand: "order_item,order,supplier,drv_id",
});
}
export async function updateOrderItemWorkflowStatus(
itemId: string,
patch: Partial<Pick<OrderItem, "item_status" | "drv_status" | "notes">>
) {
const pb = getPocketBase();
return pb.collection("order_items").update<OrderItem>(itemId, patch);
}
export async function updateCollectionPoolStatus(
poolItem: CollectionPool,
poolStatus: CollectionPool["pool_status"]
) {
const pb = getPocketBase();
const payload: Record<string, unknown> = { pool_status: poolStatus };
if (poolStatus === "assigned") {
payload.assigned_at = new Date().toISOString();
}
if (poolStatus === "completed") {
payload.completed_at = new Date().toISOString();
}
const updated = await pb.collection("collection_pool").update<CollectionPool>(poolItem.id, payload);
if (poolStatus === "assigned") {
await pb.collection("order_items").update(poolItem.order_item, {
item_status: "assigned",
drv_status: "assigned",
});
}
if (poolStatus === "in_progress") {
await pb.collection("order_items").update(poolItem.order_item, {
drv_status: "en_route",
});
}
if (poolStatus === "completed") {
await pb.collection("order_items").update(poolItem.order_item, {
item_status: "collected",
drv_status: "collected",
});
}
if (poolStatus === "failed") {
await pb.collection("order_items").update(poolItem.order_item, {
drv_status: "failed",
problem_flag: true,
problem_type: "driver",
});
}
return updated;
}
/**
* 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<DeliveryNote>(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<deliveryNoteId, Quote>`. DNs with no quote
* simply don't appear in the map.
*/
export async function getQuotesForDeliveryNotes(
deliveryNoteIds: string[],
): Promise<Map<string, Quote>> {
const out = new Map<string, Quote>();
if (deliveryNoteIds.length === 0) return out;
const pb = getPocketBase();
// 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<Quote>(
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<string> {
const pb = getPocketBase();
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, "0");
const prefix = `DN-${year}-${month}-`;
const existing = await pb.collection("delivery_notes").getList<DeliveryNote>(1, 1, {
filter: `delivery_note_number ~ "${prefix}"`,
sort: "-delivery_note_number",
});
let seq = 1;
if (existing.items.length > 0) {
const lastNum = parseInt(existing.items[0].delivery_note_number.split("-").pop() || "0", 10);
seq = lastNum + 1;
}
return `${prefix}${String(seq).padStart(3, "0")}`;
}
export async function createDeliveryNoteFromOrder(order: Order, items: OrderItem[]) {
const pb = getPocketBase();
const eligible = items.filter((item) => ["ready_delivery", "delivered"].includes(item.item_status));
if (eligible.length === 0) {
throw new Error("No ready or delivered items available for a delivery note.");
}
const deliveryNoteNumber = await generateDeliveryNoteNumber();
const deliveryNote = await pb.collection("delivery_notes").create<DeliveryNote>({
delivery_note_number: deliveryNoteNumber,
order: order.id,
company: order.company || null,
customer: order.customer || null,
status: "draft",
notes: order.notes || "",
});
for (const item of eligible) {
await pb.collection("delivery_note_items").create({
delivery_note: deliveryNote.id,
order_item: item.id,
description: item.description,
quantity: item.quantity,
});
}
return deliveryNote;
}
export async function getRepairs(page = 1, perPage = 50, search = "", status = "") {
const pb = getPocketBase();
const filters: string[] = [];
if (search) {
filters.push(`repair_number ~ "${search}" || item_description ~ "${search}" || fault_description ~ "${search}"`);
}
if (status) {
filters.push(`status = "${status}"`);
}
return pb.collection("repairs").getList<Repair>(page, perPage, {
filter: filters.join(" && "),
sort: "-created",
expand: "customer,company,order",
});
}
export async function generateRepairNumber(): Promise<string> {
const pb = getPocketBase();
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, "0");
const prefix = `REP-${year}-${month}-`;
const existing = await pb.collection("repairs").getList<Repair>(1, 1, {
filter: `repair_number ~ "${prefix}"`,
sort: "-repair_number",
});
let seq = 1;
if (existing.items.length > 0) {
const lastNum = parseInt(existing.items[0].repair_number.split("-").pop() || "0", 10);
seq = lastNum + 1;
}
return `${prefix}${String(seq).padStart(3, "0")}`;
}
/**
* Pricelist — fetch a page of rows from the `raw_price_list` collection.
*
* Backs the `/pricelist` page. The live collection has these fields
* (discovered via the admin API):
* - supplier_file: the file
* - supplier_name: relation → suppliers
* - status: select
* - log_summary: free-text
*
* Search hits `log_summary` and the expanded supplier's `suppliername`.
* (PB can't substring-match file tokens, so the filename isn't in the
* search filter — but PB's URL token is the filename in practice, so
* the user can find rows by scrolling.)
*
* Newest first — the table is a feed of recent uploads, not an
* alphabetised directory.
*/
export async function getRawPriceLists(
page = 1,
perPage = 50,
search = "",
sort = "-created"
) {
const pb = getPocketBase();
// `~` is PB's case-insensitive contains operator. We match against
// log_summary directly; for supplier we OR against the expanded
// supplier's suppliername. PB 0.26 doesn't accept the `IN` operator
// (see MEMORY.md), so we do a simple OR-chain.
const filter = search
? `log_summary ~ "${search}" || supplier_name.suppliername ~ "${search}"`
: "";
return pb.collection("raw_price_list").getList<RawPriceList>(page, perPage, {
filter,
sort,
expand: "supplier_name",
});
}
export async function deleteRawPriceList(id: string) {
const pb = getPocketBase();
return pb.collection("raw_price_list").delete(id);
}
/**
* Catalogue — fetch a page of rows from the `price_list` collection.
*
* Backs the "Catalogue" tab on the `/pricelist` page. Search hits
* every searchable text field on the row (OR'd, case-insensitive):
*
* Direct columns (PB `~` operator, each indexed):
* - sku, description, brand, supplier
* - category, subcategory, unit, source_file
*
* Catch-all (denormalised blob, indexed):
* - search_text — every token from the row, including the full
* `specifications` JSON (keys + string values + number values
* + array values + nested-object keys), all four number
* columns tokenised, and the resolved supplier name. PB can't
* substring-match JSON, so without this the rich bits
* (synonyms, dimensions, material, etc.) would be unreachable.
*
* Why two layers: the explicit columns let PB's filter planner use
* their indexes; search_text is the catch-all for everything else
* (the JSON bits, numbers, supplier-name resolution). Adding a
* new text column to price_list only requires adding it to
* `PRICE_LIST_SEARCHABLE_TEXT_COLUMNS` in `price-list-search.ts`.
*
* Optional `category` and `brand` filters AND the search (narrow
* further).
*
* Newest first — the table is a feed of recent extractions, not an
* alphabetised directory.
*/
export async function getPriceLists(
page = 1,
perPage = 50,
search = "",
filters: { category?: string; brand?: string } = {},
sort = "-updated"
) {
const pb = getPocketBase();
const clauses: string[] = [];
const searchClause = buildPriceListSearchFilter(search);
if (searchClause) clauses.push(searchClause);
if (filters.category) {
clauses.push(`category = "${filters.category.replace(/"/g, '\\"')}"`);
}
if (filters.brand) {
clauses.push(`brand = "${filters.brand.replace(/"/g, '\\"')}"`);
}
const filter = clauses.join(" && ");
return pb.collection("price_list").getList<PriceList>(page, perPage, {
filter,
sort,
});
}
/**
* Look up a single price_list row for an exact SKU. Used by the
* ad-hoc "Create Delivery Note" form to auto-fill cost + selling
* price when the operator picks a stock item — price lives on
* `price_list`, not on `stock_items` (stock_items is about
* inventory; price_list is the supplier's catalogue).
*
* Returns the most-recent match (sort by `-updated`). When the
* SKU is missing from price_list, returns null — the form then
* shows a "no price for this SKU" hint and the operator can
* either pick a different supplier's catalogue row, or type the
* price manually.
*
* Note: this matches the catalogue's `sku` field exactly. The
* `sku_normalised` field is used by the dedicated catalogue
* search flow (which tolerates formatting variations); for the
* ad-hoc DN flow we trust the SKU the operator already typed.
*/
export async function getPriceListForSku(sku: string): Promise<PriceList | null> {
const pb = getPocketBase();
const trimmed = sku.trim();
if (!trimmed) return null;
const escaped = trimmed.replace(/"/g, '\\"');
const result = await pb.collection("price_list").getList<PriceList>(1, 1, {
filter: `sku = "${escaped}"`,
sort: "-updated",
});
return result.items[0] ?? null;
}
export async function createRepair(data: {
customer?: string;
company?: string;
order?: string;
item_description: string;
fault_description: string;
notes?: string;
}) {
const pb = getPocketBase();
const repairNumber = await generateRepairNumber();
return pb.collection("repairs").create<Repair>({
repair_number: repairNumber,
customer: data.customer || null,
company: data.company || null,
order: data.order || null,
status: "new",
item_description: data.item_description,
fault_description: data.fault_description,
notes: data.notes || "",
});
}
/**
* @deprecated For new code, call `/api/v1/quotes/next-number` instead. This
* client-side wrapper is kept for backwards compatibility with the quote
* form. The server endpoint uses an atomic counter (no race), while this
* client function reads-then-writes the last quote number (racy under
* concurrent creates).
*/
export async function generateQuoteNumber(): Promise<string> {
const pb = getPocketBase();
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, "0");
const prefix = `Q${year}${month}`;
const existing = await pb.collection("quotes").getList<Quote>(1, 1, {
filter: `quote_number ~ "${prefix}"`,
sort: "-quote_number",
});
let seq = 1;
if (existing.items.length > 0) {
const lastRef = existing.items[0].quote_number;
// Extract trailing digits (handles both Q202607011 and legacy
// Q-2026-07-011 formats).
const m = lastRef.match(/(\d+)$/);
const lastNum = m ? parseInt(m[1], 10) : 0;
seq = lastNum + 1;
}
return `${prefix}${String(seq).padStart(3, "0")}`;
}
export async function generateOrderNumber(): Promise<string> {
const pb = getPocketBase();
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, "0");
const prefix = `ORD-${year}-${month}-`;
const existing = await pb.collection("orders").getList<Order>(1, 1, {
filter: `order_number ~ "${prefix}"`,
sort: "-order_number",
});
let seq = 1;
if (existing.items.length > 0) {
const lastNum = parseInt(existing.items[0].order_number.split("-").pop() || "0", 10);
seq = lastNum + 1;
}
return `${prefix}${String(seq).padStart(3, "0")}`;
}
// 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<string, unknown>[], filename: string) {
if (data.length === 0) return;
const headers = Object.keys(data[0]);
const csv = [
headers.join(","),
...data.map((row) =>
headers
.map((h) => {
const val = row[h];
const str = val === null || val === undefined ? "" : String(val);
return str.includes(",") || str.includes('"') ? `"${str.replace(/"/g, '""')}"` : str;
})
.join(",")
),
].join("\n");
const blob = new Blob([csv], { type: "text/csv" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${filename}.csv`;
a.click();
URL.revokeObjectURL(url);
}
/* ============================================================================
* Duplicate detection — added 2026-07-22.
*
* Three concerns: catch "I typed it twice" cases on Customers / Companies /
* Suppliers without flooding the operator with false positives. Each table
* has its own rules because the natural unique key differs:
*
* - Companies: the `company` name is the practical unique key. Vendor
* numbers are sometimes reused across orgs (parent / child), so we
* don't fold them into the check.
* - Suppliers: `suppliername`. Email + number are contact details that
* legitimately change when a supplier switches accounts.
* - Customers: the same person can legitimately appear at multiple
* companies, so `customer` alone is too loose. The natural key is
* (name, company) — same name AND works for the same company. Email
* is a secondary consistency check: if both records have an email
* set, those must also match (catches "I typed a slightly different
* email for the same person" without blocking legitimately shared
* inboxes at small companies).
*
* Implementation note: PB's `=` is case-sensitive, and there's no built-in
* case-insensitive exact-match operator. We use `~` (case-insensitive
* substring) as the PB filter, then post-filter client-side for an exact
* case-insensitive trimmed match. At WFDASH scale (hundreds to low
* thousands per table) the substring set is small and the client-side
* filter is O(matches).
*
* `excludeId` is what lets the form use these helpers in edit mode
* without flagging the record being edited as a duplicate of itself.
* ========================================================================== */
/** Trim + lower-case for duplicate comparisons. Empty in → empty out. */
function normDup(s: string | null | undefined): string {
return (s ?? "").trim().toLowerCase();
}
/** Escape a string for use inside a PB filter literal (double-quoted). */
function escapeDup(s: string): string {
return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
}
/**
* Common fetch path: pull a small page of records whose `field` matches
* the trimmed value as a case-insensitive substring, then narrow to an
* exact case-insensitive trimmed match. Returns up to `limit` matches.
*/
async function fetchNameMatches<T extends BaseRecord>(
collectionName: "companies" | "suppliers" | "customers",
field: string,
value: string,
limit: number,
excludeId: string | undefined,
expand?: string
): Promise<T[]> {
const trimmed = (value ?? "").trim();
if (!trimmed) return [];
const pb = getPocketBase();
const filter = `${field} ~ "${escapeDup(trimmed)}"${excludeId ? ` && id != "${escapeDup(excludeId)}"` : ""}`;
const result = await pb.collection(collectionName).getList<T>(1, limit, {
filter,
sort: "-created",
...(expand ? { expand } : {}),
});
const target = normDup(trimmed);
return result.items.filter((row) => normDup((row as unknown as Record<string, string>)[field]) === target);
}
/**
* Find duplicate companies by name (case-insensitive, trimmed). Used by
* the company form to flag "I typed Acme twice" before save.
*/
export async function findCompanyDuplicates(
companyName: string,
excludeId?: string
): Promise<Company[]> {
return fetchNameMatches<Company>("companies", "company", companyName, 20, excludeId);
}
/**
* Find duplicate suppliers by name (case-insensitive, trimmed).
*/
export async function findSupplierDuplicates(
supplierName: string,
excludeId?: string
): Promise<Supplier[]> {
return fetchNameMatches<Supplier>("suppliers", "suppliername", supplierName, 20, excludeId);
}
/**
* Find duplicate customers. A candidate is a duplicate when:
* - `customer` matches the form's name (case-insensitive, trimmed) AND
* - `company` matches the form's company id (relation) AND
* - if both records have a non-empty `email`, the emails also match
* (case-insensitive, trimmed). If either side has no email, the
* email check is skipped (otherwise the form would miss every
* customer that's missing an email on either side).
*
* The `company` relation is stored as an id string on the customer row,
* so equality is a straight string compare — no name-lookup needed.
*/
export async function findCustomerDuplicates(
customerName: string,
companyId: string,
email: string,
excludeId?: string
): Promise<Customer[]> {
const candidates = await fetchNameMatches<Customer>(
"customers",
"customer",
customerName,
50,
excludeId,
"company"
);
const targetName = normDup(customerName);
const targetEmail = normDup(email);
return candidates.filter((c) => {
if (normDup(c.customer) !== targetName) return false;
if ((c.company || "") !== (companyId || "")) return false;
if (targetEmail && normDup(c.email)) {
if (normDup(c.email) !== targetEmail) return false;
}
return true;
});
}