WFDASH-WEBAPP/src/lib/pb-helpers.ts
SJCoetz e9cf8aaf04 feat(stock): implement stock management workflow
This commit adds the complete v2/v3 stock management functionality:
- add new stock-specific item statuses and update the status visual map
- implement full stock purchase API suite (create, list, retrieve, send, receive)
- reorganize sidebar navigation to group stock-related pages together
- add stock item CRUD APIs, status utilities, low stock filtering, and surplus booking logic
- add database migration for the `stock_items.target_qty` field
- update type definitions and add comprehensive tests for all new stock components
2026-07-15 21:53:20 +02:00

1618 lines
58 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 } from "./types";
import { TAB_GROUP_ITEM_STATUSES, itemStatusToTabGroup, type TabGroup } from "./item-status-ui";
import { buildPriceListSearchFilter } from "./price-list-search";
import { scoreOrderVsQuote, MATCH_CONFIDENCE_THRESHOLD, FUZZY_CANDIDATE_CAP } from "./server/order-quote-match";
export async function getCustomers(
page = 1,
perPage = 50,
search = "",
sort = "-created",
expand?: string
) {
const pb = getPocketBase();
// Note: `company` was converted from text to a relation; the old
// `company ~ "${search}"` filter clause no longer makes sense (it's
// matching against the relation id, not a name). Search now hits
// name / email / phone only. To find a customer by company name,
// start from the Companies page.
const filter = search
? `customer ~ "${search}" || email ~ "${search}" || number ~ "${search}"`
: "";
return pb.collection("customers").getList<Customer>(page, perPage, {
filter,
sort,
expand,
});
}
export async function getCustomer(id: string, expand?: string) {
const pb = getPocketBase();
return pb.collection("customers").getOne<Customer>(id, { expand });
}
export async function getAllCustomers(expand?: string) {
const pb = getPocketBase();
return pb.collection("customers").getFullList<Customer>({
sort: "-created",
expand,
});
}
export async function getCompanies(page = 1, perPage = 50, search = "", sort = "-created") {
const pb = getPocketBase();
const filter = search
? `company ~ "${search}" || vendor ~ "${search}" || company_email ~ "${search}" || company_tel ~ "${search}"`
: "";
return pb.collection("companies").getList<Company>(page, perPage, { filter, sort });
}
export async function getCompany(id: string) {
const pb = getPocketBase();
return pb.collection("companies").getOne<Company>(id);
}
export async function getAllCompanies() {
const pb = getPocketBase();
return pb.collection("companies").getFullList<Company>({ sort: "-created" });
}
export async function getSuppliers(page = 1, perPage = 50, search = "", sort = "-created") {
const pb = getPocketBase();
const filter = search
? `suppliername ~ "${search}" || email ~ "${search}" || suppliernumber ~ "${search}" || supply_tags ~ "${search}"`
: "";
return pb.collection("suppliers").getList<Supplier>(page, perPage, { filter, sort });
}
export async function getSupplier(id: string) {
const pb = getPocketBase();
return pb.collection("suppliers").getOne<Supplier>(id);
}
export async function getAllSuppliers() {
const pb = getPocketBase();
return pb.collection("suppliers").getFullList<Supplier>({ sort: "-created" });
}
/**
* Fields the supplier-map actually renders. Kept narrow so a 5k-row
* supplier table doesn't ship `closingtime` and the rest of the blob
* over the wire just to show pins on a Leaflet map.
*/
const SUPPLIER_MAP_FIELDS =
"id,suppliername,supplieraddress,suppliernumber,email,supply_tags,coordinates";
/**
* Suppliers that can actually be placed on the map — only rows with
* non-empty `coordinates`, and only the fields the map renders.
*
* Distinct from `getAllSuppliers` (which the quote/order forms still
* want full records from). Introduced when the supplier-map was
* choking on zoom-in with a few thousand suppliers: shipping the
* whole table was the biggest single contributor.
*/
export async function getMappableSuppliers() {
const pb = getPocketBase();
return pb.collection("suppliers").getFullList<Supplier>({
sort: "-created",
filter: 'coordinates != ""',
fields: SUPPLIER_MAP_FIELDS,
});
}
/* ============================================================================
* Stock items — added 2026-07-14 (Stream E: v3 stock-out flow).
*
* `getActiveStockItems` is what the order-entry pages call to feed
* the "Fulfill from stock" picker. Filters by `is_active=true` so
* deactivated items don't pollute the dropdown — same convention as
* the supplier map's `coordinates != ""` filter.
*
* `getStockItem` is the single-record variant used by the
* /api/v1/stock/fulfill route when it needs to re-read current_qty
* just before debiting (defensive — catches the race where the
* picker was filled with an earlier `current_qty` than the current
* row holds).
* ========================================================================== */
export async function getActiveStockItems() {
const pb = getPocketBase();
return pb.collection("stock_items").getFullList<StockItem>({
sort: "name",
filter: "is_active = true",
expand: "supplier",
});
}
export async function getStockItem(id: string) {
const pb = getPocketBase();
return pb.collection("stock_items").getOne<StockItem>(id, {
expand: "supplier",
});
}
/**
* Quote queries reference `is_template`, `quote_date`, `validity_days`,
* `follow_up_due`, `template_source_id`, `generated_from_template_id` —
* fields that are added in v5 by importing `pb_schema (1).json`.
*
* Until the schema is imported, PB rejects the filter with HTTP 400,
* which spams the browser console. We try the new filter first; on
* failure we fall back to the legacy unfiltered query and log a
* one-time reminder. After schema import everything works as designed.
*/
function is400(err: unknown): boolean {
if (!err || typeof err !== "object") return false;
const status = (err as { status?: number }).status;
return status === 400;
}
let warnedSchemaMissing = false;
function warnSchemaMissing(what: string) {
if (warnedSchemaMissing) return;
warnedSchemaMissing = true;
console.warn(
`[pb-helpers] Quote query falling back to legacy filter (${what}). ` +
"Import the new fields from pb_schema (1).json into PocketBase to enable the new filter."
);
}
/**
* Fetch a page of quotes, optionally filtered by one or more statuses.
*
* @param statuses One or more `quotes.status` values to OR-chain into a
* `status = "x" || status = "y" || …` filter. PB 0.26
* doesn't accept the `IN` operator, so we build the
* OR-chain ourselves (see MEMORY.md / PocketBase 0.26:
* no `IN` operator in raw filter strings). Pass `[]`
* (default) to skip the status filter.
*
* The Templates tab historically passed a single `status = "template"`
* string; that flow is now handled by passing `["template"]` instead.
*/
export async function getQuotes(
page = 1,
perPage = 50,
search = "",
sort = "-created",
statuses: string[] = []
) {
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})`);
}
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",
sending: "pending_approval",
approved: "completed",
complete: "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 }));
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;
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 - 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;
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 - 1].totalItems;
counts.all = allCount;
return counts;
}
}
export async function getQuoteItems(quoteId: string) {
const pb = getPocketBase();
return pb.collection("quote_items").getFullList<QuoteItem>({
filter: `quote = "${quoteId}"`,
expand: "supplier",
});
}
/* ============================================================================
* searchQuotesWithItems — multi-field client-side search, parallel to
* `searchOrdersWithItems`. Matches (case-insensitive substring):
*
* - quote_number, quote_reference, rfq_number, external_number
* - customer name (via expand)
* - company name (via expand)
* - quote_items.description (any line item)
* - quote_items.selling_price / cost_price (numeric exact match if the
* query parses as a plain decimal)
*
* Templates ARE included — the user can search them the same way. The
* list page's tab filter still hides templates from non-template tabs,
* but the search itself doesn't gate by status.
*
* Pagination is intentionally suppressed while searching (same trade-off
* as `searchOrdersWithItems`): the operator is hunting for a specific
* match, not paging through.
*
* Capped at the same `SEARCH_HARD_CAP` (500) as the orders search so a
* runaway query can't OOM the browser.
* ========================================================================== */
function quoteMatchesSearch(
quote: Quote,
items: QuoteItem[],
qLower: string,
numericMatch: number | null
): boolean {
// 1. Quote identifiers contain query
if ((quote.quote_number || "").toLowerCase().includes(qLower)) return true;
if ((quote.quote_reference || "").toLowerCase().includes(qLower)) return true;
if ((quote.rfq_number || "").toLowerCase().includes(qLower)) return true;
if ((quote.external_number || "").toLowerCase().includes(qLower)) return true;
// 2. Customer name contains query
const customer = quote.expand?.customer?.customer || "";
if (customer.toLowerCase().includes(qLower)) return true;
// 3. Company name contains query
const company = quote.expand?.company?.company || "";
if (company.toLowerCase().includes(qLower)) return true;
// 4. Any line item description contains query
for (const it of items) {
if ((it.description || "").toLowerCase().includes(qLower)) return true;
}
// 5. If the query is numeric, match against line prices. Tolerant
// epsilon so "12" matches 12.00 and "12.5" matches 12.50.
if (numericMatch !== null) {
const epsilon = 0.001;
for (const it of items) {
if (
Math.abs(Number(it.selling_price || 0) - numericMatch) < epsilon ||
Math.abs(Number(it.cost_price || 0) - numericMatch) < epsilon
) {
return true;
}
}
}
return false;
}
export async function searchQuotesWithItems(
q: string,
sort = "-created"
): Promise<Array<{ quote: Quote; items: QuoteItem[] }>> {
const pb = getPocketBase();
const trimmed = q.trim();
if (!trimmed) return [];
const quotes = await pb.collection("quotes").getList<Quote>(1, SEARCH_HARD_CAP, {
sort,
expand: "customer,customer.company,customer.rep,company",
});
const quoteIds = quotes.items.map((q0) => q0.id);
// Pull items in one OR-chain query (PB 0.26 doesn't accept IN).
let items: QuoteItem[] = [];
if (quoteIds.length > 0) {
const orChain = quoteIds.map((id) => `quote = "${id.replace(/"/g, '\\"')}"`).join(" || ");
items = await pb.collection("quote_items").getFullList<QuoteItem>({
filter: orChain,
expand: "supplier",
});
}
const itemsByQuote: Record<string, QuoteItem[]> = {};
for (const item of items) {
(itemsByQuote[item.quote] ||= []).push(item);
}
const qLower = trimmed.toLowerCase();
const numericMatch = isNumericQuery(trimmed) ? Number(trimmed) : null;
const result: Array<{ quote: Quote; items: QuoteItem[] }> = [];
for (const quote of quotes.items) {
const qItems = itemsByQuote[quote.id] || [];
if (quoteMatchesSearch(quote, qItems, qLower, numericMatch)) {
result.push({ quote, items: qItems });
}
}
return result;
}
/* ============================================================================
* Order ↔ Quote matching
*
* Strategy ladder — each step is tried in order; the first non-empty hit
* is returned and recorded as the audit trail. The UI surfaces the
* method so the operator understands WHY a suggestion exists.
*
* 1. exact_quote_number — `quotes.quote_number == ref`
* 2. exact_quote_reference — `quotes.quote_reference == ref`
* 3. exact_rfq_number — `quotes.rfq_number == ref`
* 4. exact_external_number — `quotes.external_number == ref`
* 5. company+customer+desc+price (the fuzzy fallback) — used when
* no reference text is set OR when none of the exact lookups
* returned a hit. Filters quotes by company (and customer when
* known), scores each candidate by description-token overlap
* (Jaccard) plus price tolerance, and takes the best.
*
* Only ~55% of quotes have a meaningful quote_number (per the operator),
* so the fuzzy fallback is the path that handles the rest of the
* population. We cap the fuzzy candidate set at FUZZY_CANDIDATE_CAP
* so a runaway scan can't OOM the browser.
*
* The pure scoring helpers (tokenize, jaccard, priceScore,
* scoreCandidate) live in `src/lib/server/order-quote-match.ts` so
* they're unit-testable without PocketBase.
* ========================================================================== */
export type QuoteMatchMethod =
| "exact_quote_number"
| "exact_quote_reference"
| "exact_rfq_number"
| "exact_external_number"
| "company_customer_desc_price"
| "no_match";
export interface QuoteMatchCandidate {
quote: Quote;
items: QuoteItem[];
confidence: number; // 0..1
/** Per-signal score breakdown so the UI can show operators WHY a
* quote was chosen. Optional for backward compatibility with
* exact-lookup paths (which don't run the scorer). */
breakdown?: import("./server/order-quote-match").ScoreBreakdown;
method: Exclude<QuoteMatchMethod, "no_match">;
}
export interface QuoteMatchResult {
/** Best (and only) suggestion, or null if no candidate cleared the
* confidence threshold. */
best: QuoteMatchCandidate | null;
/** Up to 5 runners-up, useful for the "see all candidates" UI later. */
alternatives: QuoteMatchCandidate[];
method: QuoteMatchMethod;
}
function normRef(s: string): string {
return (s || "").trim();
}
/** Try to find one quote that matches `ref` against the four identifier
* fields. Returns the first hit (in priority order) or null. */
async function findExactMatch(
pb: ReturnType<typeof getPocketBase>,
ref: string
): Promise<{ quote: Quote; method: Exclude<QuoteMatchMethod, "no_match" | "company_customer_desc_price"> } | null> {
if (!ref) return null;
// Quote_number first (most common), then quote_reference (the customer's
// own PO ref we recorded when we issued), then rfq_number, then
// external_number.
for (const [field, method] of [
["quote_number", "exact_quote_number"],
["quote_reference", "exact_quote_reference"],
["rfq_number", "exact_rfq_number"],
["external_number", "exact_external_number"],
] as const) {
try {
const escaped = ref.replace(/"/g, '\\"');
const list = await pb.collection("quotes").getList<Quote>(1, 5, {
filter: `${field} = "${escaped}"`,
});
if (list.items.length > 0) {
return { quote: list.items[0], method };
}
} catch {
// Field might not exist in this PB schema (older deploys) —
// skip and try the next one.
}
}
return null;
}
/** Score & rank candidate quotes by company (+ customer) match. */
async function findFuzzyMatches(
pb: ReturnType<typeof getPocketBase>,
order: Order,
orderItems: OrderItem[]
): Promise<QuoteMatchCandidate[]> {
if (orderItems.length === 0) return []; // no signal — bail
// PB filter: company is required, customer narrows further when known.
const filters: string[] = [];
if (order.company) filters.push(`company = "${order.company}"`);
if (order.customer) filters.push(`(customer = "${order.customer}" || customer = "")`);
// Exclude templates — they aren't real orders.
filters.push("is_template != true");
const filter = filters.join(" && ");
const candidates = await pb.collection("quotes").getList<Quote>(1, FUZZY_CANDIDATE_CAP, {
filter,
sort: "-created",
expand: "customer",
});
if (candidates.items.length === 0) return [];
// Pull all candidate items in one OR-chain query.
const qIds = candidates.items.map((q) => q.id);
const orChain = qIds.map((id) => `quote = "${id.replace(/"/g, '\\"')}"`).join(" || ");
const allItems = await pb.collection("quote_items").getFullList<QuoteItem>({
filter: orChain,
});
const itemsByQuote: Record<string, QuoteItem[]> = {};
for (const it of allItems) {
(itemsByQuote[it.quote] ||= []).push(it);
}
const projectedOrderItems = orderItems.map((it) => ({
description: it.description || "",
selling_price: Number(it.selling_price) || 0,
quantity: Number(it.quantity) || 1,
}));
const scored: QuoteMatchCandidate[] = [];
for (const q of candidates.items) {
const qItems = itemsByQuote[q.id] || [];
const projectedQuoteItems = qItems.map((it) => ({
description: it.description || "",
selling_price: Number(it.selling_price) || 0,
quantity: Number(it.quantity) || 1,
}));
// Rule-based scorer — uses company/customer relations and per-line
// signal matches. See `scoreOrderVsQuote` for the point budget.
const breakdown = scoreOrderVsQuote(
{
companyId: order.company,
customerId: order.customer,
items: projectedOrderItems,
},
{
company: q.company,
customer: q.customer,
items: projectedQuoteItems,
}
);
if (breakdown.confidence >= MATCH_CONFIDENCE_THRESHOLD) {
scored.push({
quote: q,
items: qItems,
confidence: breakdown.confidence,
breakdown,
method: "company_customer_desc_price",
});
}
}
// Sort: highest confidence first, then most recently created on tie.
// The PB query already returns candidates `-created`, but the local
// sort above only sees confidence — without a tie-breaker, an older
// quote can win over a fresher one when they score the same. Using
// `created` (not `updated`) so a no-op re-save of an old quote can't
// bump it past a genuinely newer competitor.
scored.sort((a, b) => {
if (b.confidence !== a.confidence) return b.confidence - a.confidence;
return (b.quote.created || "").localeCompare(a.quote.created || "");
});
return scored;
}
/**
* Run the matcher against an order and return the best suggestion
* (or null) plus up to 5 alternatives. Doesn't persist anything —
* the caller decides whether to accept the suggestion.
*
* `pb` is optional: when omitted the matcher uses the client-side
* singleton from `@/lib/pocketbase` (the normal browser path).
* Pass an explicit `pb` to use a server-side or admin-authenticated
* instance — useful for server scripts, n8n workflows, and tests
* that don't have the browser's auth context.
*/
export async function findMatchingQuotesForOrder(
order: Order,
orderItems: OrderItem[],
pb?: ReturnType<typeof getPocketBase>
): Promise<QuoteMatchResult> {
const client = pb ?? getPocketBase();
const ref = normRef(order.quote_reference_text || "");
// 1-4: Exact-key lookups when the operator gave us a reference. This
// is a deterministic fast path — we run it before the fuzzy scan
// because the operator typed something specific and we should
// honor it first when it matches.
if (ref) {
const exact = await findExactMatch(client, ref);
if (exact) {
return {
best: {
quote: exact.quote,
items: [], // not pre-fetched — caller can lazy-load if they open it
confidence: 1.0,
method: exact.method,
},
alternatives: [],
method: exact.method,
};
}
}
// 5: Fuzzy fallback. Runs whether or not the operator supplied a
// reference — the rule-based scorer can find matches from company +
// customer + items + price alone, without needing a typed
// identifier. This is what makes "Re-match" work on a freshly
// loaded order that has nothing in `quote_reference_text`.
const fuzzy = await findFuzzyMatches(client, order, orderItems);
if (fuzzy.length === 0) {
return { best: null, alternatives: [], method: "no_match" };
}
return {
best: fuzzy[0],
alternatives: fuzzy.slice(1, 6),
method: "company_customer_desc_price",
};
}
/**
* Persist the matcher's verdict on the order record. Sets
* `quote_matching_attempted = true` so the UI can show "we tried but
* found nothing" distinctly from "we haven't tried yet".
*
* If `accept` is true AND a candidate was found, the candidate's
* quote is promoted from `potential_quote` to the hard-linked
* `quote` field. If false, only the suggestion is recorded.
*
* `pb` is optional: same convention as `findMatchingQuotesForOrder`.
*/
export async function applyOrderQuoteMatch(
orderId: string,
result: QuoteMatchResult,
accept: boolean,
pb?: ReturnType<typeof getPocketBase>
): Promise<void> {
const client = pb ?? getPocketBase();
const patch: Record<string, unknown> = {
quote_matching_attempted: true,
quote_match_method: result.method,
};
if (result.best) {
patch.potential_quote = result.best.quote.id;
patch.potential_quote_confidence = result.best.confidence;
if (accept) {
patch.quote = result.best.quote.id;
}
} else {
// No suggestion — clear any stale soft-match.
patch.potential_quote = null;
patch.potential_quote_confidence = 0;
}
await client.collection("orders").update(orderId, patch);
}
/**
* Reset the matching verdict on an order so the operator can retry.
* Same `pb` convention as the other match helpers.
*/
export async function clearOrderQuoteMatch(
orderId: string,
pb?: ReturnType<typeof getPocketBase>
): Promise<void> {
const client = pb ?? getPocketBase();
await client.collection("orders").update(orderId, {
quote_matching_attempted: false,
quote_match_method: null,
potential_quote: null,
potential_quote_confidence: 0,
});
}
/**
* Manually hard-link an order to a quote. Used by the QuoteMatchCard
* "Browse quotes…" affordance when the operator picks a quote from
* the search dropdown — there's no reference text to match against,
* and we don't want to fake a QuoteMatchResult just to round-trip
* through `applyOrderQuoteMatch`.
*
* Sets the same fields as an accepted auto-match (`quote`,
* `quote_matching_attempted`, `quote_match_method = "manual"`) but
* does NOT populate `potential_quote` — manual links bypass the
* suggestion stage and become the hard link directly.
*
* `pb` is optional for symmetry with the other match helpers — pass
* it in server-side to keep one auth path.
*/
export async function setOrderQuoteManually(
orderId: string,
quoteId: string,
pb?: ReturnType<typeof getPocketBase>
): Promise<void> {
const client = pb ?? getPocketBase();
await client.collection("orders").update(orderId, {
quote: quoteId,
quote_matching_attempted: true,
quote_match_method: "manual",
potential_quote: null,
potential_quote_confidence: 0,
});
}
// Re-export the pure scoring helpers so unit tests can import them
// without poking into the PB layer.
export {
tokenize,
jaccard,
priceScore,
scoreCandidate,
scoreOrderVsQuote,
RULE_POINTS,
MATCH_CONFIDENCE_THRESHOLD,
} from "./server/order-quote-match";
export type { ScoreBreakdown, LineMatchBreakdown } from "./server/order-quote-match";
/**
* Fetch all attachments saved against a quote. Used by the
* "Original Email" card on the quote detail page so the quoter can
* download the files that came in with the original RFQ email.
*
* Separate query rather than a reverse-relation expand because PB's
* expand syntax doesn't easily cover reverse relations, and the
* `quote_attachments` collection is small per quote (usually 0–3 rows).
*
* Direction filter: pass "inbound" to fetch only email attachments,
* "outbound" for operator-uploaded files, "generated" for the
* rendered PDF, or omit to fetch all. Rows predating the 2026-06-23
* migration have `direction = null`; we treat those as "inbound"
* because every historical row came in from an email.
*/
export async function getQuoteAttachments(
quoteId: string,
direction?: "inbound" | "outbound" | "generated" | "all",
) {
const pb = getPocketBase();
const dir = direction && direction !== "all" ? direction : null;
const filterParts = [`quote = "${quoteId}"`];
if (dir) {
filterParts.push(`direction = "${dir}"`);
} else if (direction === "all") {
// No direction filter — return everything.
} else {
// Default to "inbound" (the historical meaning of attachments
// before the direction migration). We use a deliberately loose
// filter: `direction = "inbound" || direction = ""`. PB treats
// null and missing as equal for text compares, so this catches
// both pre-migration rows and explicitly-nulled rows.
filterParts.push(`(direction = "inbound" || direction = "")`);
}
const filter = filterParts.join(" && ");
try {
return await pb.collection("quote_attachments").getFullList<QuoteAttachment>({
filter,
sort: "-uploaded_at",
});
} catch (err) {
// Same fallback pattern as getQuotes: if the `direction` field
// doesn't exist in this PB yet (pre-2026-06-23 migration), PB
// returns 400. Retry without the direction clause so the page
// still renders; the user gets the historical "all attachments"
// view until they run the migration.
if (!is400(err)) throw err;
warnSchemaMissing("direction (attachments)");
return pb.collection("quote_attachments").getFullList<QuoteAttachment>({
filter: `quote = "${quoteId}"`,
sort: "-uploaded_at",
});
}
}
export async function getOrders(page = 1, perPage = 50, search = "", sort = "-created") {
const pb = getPocketBase();
const filter = search ? `order_number ~ "${search}"` : "";
// List view expands company, customer, quote, and potential_quote
// so the cards can show the matched-quote pill inline (and the
// soft-suggestion indicator) without an extra round-trip per row.
// The price is a slightly larger PB response per row, but at the
// WFDASH fleet scale this is well under a hundred bytes per
// extra field.
return pb.collection("orders").getList<Order>(page, perPage, {
filter,
sort,
expand: "company,customer,quote,potential_quote",
});
}
/**
* Per-tab-group totals for the orders list tabs.
*
* Counts the number of UNIQUE orders that have at least one item in
* each tab's item-status group. The actual data fetch happens in
* `getOrdersWithItems`; this helper is here so the list page can show
* tab badges without re-running the orders query.
*
* Implementation: one PB call per tab (6 total) using `getFullList`
* with a narrow `fields: "order"` projection, then deduping in JS.
* For the WFDASH fleet scale (hundreds of orders, not millions)
* this is well under a thousand records scanned total — cheap
* enough that we don't bother caching.
*/
export async function getOrderItemStatusTabCounts(): Promise<Record<TabGroup, number>> {
const pb = getPocketBase();
const tabs: TabGroup[] = [
"new",
"processing",
"collection_pool",
"collected",
"delivered",
"cancelled",
];
const results = await Promise.all(
tabs.map((tab) => {
const filter = TAB_GROUP_ITEM_STATUSES[tab]
.map((s) => `item_status = "${s}"`)
.join(" || ");
return pb.collection("order_items").getFullList<{ order: string }>({
filter,
fields: "order",
});
})
);
const counts = {} as Record<TabGroup, number>;
tabs.forEach((tab, i) => {
const uniqueOrders = new Set(results[i].map((row) => row.order).filter(Boolean));
counts[tab] = uniqueOrders.size;
});
return counts;
}
/**
* Orders with their line items and the tab groups each order touches.
*
* The list page needs the full picture for every visible row because
* an order can appear on multiple tabs (a 5-item order with items in
* `pending` + `processing` + `delivered` shows up on three tabs).
*
* Strategy:
* 1. Fetch the page of orders (no expand beyond company/customer).
* 2. Fetch all line items whose `order` is in this page (one PB
* call with an `IN` filter — far cheaper than N+1).
* 3. Bucket items onto their parent order.
* 4. Compute the set of tab groups each order touches (deduped).
*
* `search` filters by order_number. The returned `OrderWithItems`
* shape is what the page renders directly.
*/
export interface OrderWithItems {
order: Order;
items: OrderItem[];
/** Tab groups this order has at least one item in. */
tabGroups: TabGroup[];
}
export async function getOrdersWithItems(
page = 1,
perPage = 20,
search = "",
sort = "-created"
): Promise<{ items: OrderWithItems[]; totalPages: number; totalItems: number }> {
const pb = getPocketBase();
const ordersPage = await getOrders(page, perPage, search, sort);
const orderIds = ordersPage.items.map((o) => o.id);
let itemsByOrder: Record<string, OrderItem[]> = {};
if (orderIds.length > 0) {
// PocketBase 0.26 doesn't accept the `IN` operator in raw filter
// strings (returns HTTP 400). The portable form is OR-joined
// equality clauses, which PB evaluates fine for short lists
// (the page is capped at 20 ids). For larger datasets we'd
// batch this — but at the WFDASH fleet scale (hundreds of
// orders) a single OR-chain stays well under PB's filter-length
// budget and avoids the round-trip cost of N+1 fetches.
//
// Each id is double-quoted; PB ids are 15-char lowercase
// alphanumeric so escaping is theoretical but cheap to keep safe.
const orChain = orderIds.map((id) => `order = "${id.replace(/"/g, '\\"')}"`).join(" || ");
const items = await pb.collection("order_items").getFullList<OrderItem>({
filter: orChain,
expand: "supplier",
sort: "created",
});
itemsByOrder = items.reduce<Record<string, OrderItem[]>>((acc, item) => {
(acc[item.order] ||= []).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.
const itemsByOrder: Record<string, OrderItem[]> = {};
for (const item of items) {
(itemsByOrder[item.order] ||= []).push(item);
}
const qLower = trimmed.toLowerCase();
const numericMatch = isNumericQuery(trimmed) ? Number(trimmed) : null;
const result: OrderWithItems[] = [];
for (const order of orders.items) {
const orderItems = itemsByOrder[order.id] || [];
if (!orderMatchesSearch(order, orderItems, qLower, numericMatch)) continue;
const tabSet = new Set<TabGroup>();
for (const it of orderItems) {
tabSet.add(itemStatusToTabGroup(it.item_status));
}
result.push({ order, items: orderItems, tabGroups: Array.from(tabSet) });
}
return result;
}
export async function getOrder(id: string) {
const pb = getPocketBase();
return pb.collection("orders").getOne<Order>(id, { expand: "company,customer,quote" });
}
export async function getOrderByQuote(quoteId: string) {
const pb = getPocketBase();
try {
return await pb.collection("orders").getFirstListItem<Order>(`quote = "${quoteId}"`, {
expand: "company,customer,quote",
});
} catch {
return null;
}
}
export async function getOrderItems(orderId: string) {
const pb = getPocketBase();
return pb.collection("order_items").getFullList<OrderItem>({
filter: `order = "${orderId}"`,
expand: "supplier",
});
}
export async function createOrderFromQuote(quoteId: string) {
const pb = getPocketBase();
const existingOrder = await getOrderByQuote(quoteId);
if (existingOrder) {
return { order: existingOrder, alreadyExists: true };
}
const quote = await pb.collection("quotes").getOne<Quote>(quoteId);
const quoteItems = await pb.collection("quote_items").getFullList<QuoteItem>({
filter: `quote = "${quoteId}"`,
sort: "created",
});
if (quoteItems.length === 0) {
throw new Error("Quote has no items to convert.");
}
// Pull rep from the linked customer record when present, so the order
// inherits it instead of starting empty. The operator can still override
// it on the order detail page if needed.
let rep = "";
if (quote.customer) {
try {
const cust = await pb.collection("customers").getOne<Customer>(quote.customer);
rep = cust.rep || "";
} catch {
// Customer record missing — leave rep empty, the operator can
// type it on the order detail page.
}
}
const orderNumber = await generateOrderNumber();
const order = await pb.collection("orders").create<Order>({
order_number: orderNumber,
company: quote.company,
customer: quote.customer || "",
quote: quote.id,
notes: quote.notes || "",
quote_matching_attempted: true,
quote_match_confidence: 1,
potential_quote: quote.id,
potential_quote_confidence: 1,
is_repair_order: false,
invoiced: false,
rep,
});
for (const item of quoteItems) {
const quantity = Number(item.quantity || 1);
await pb.collection("order_items").create({
order: order.id,
description: item.description || "",
po_description: item.description || "",
quantity,
order_qty: quantity,
selling_price: Number(item.selling_price || 0),
supplier: item.supplier || null,
cost_price: Number(item.cost_price || 0),
markup: Number(item.markup || 0),
markup_percent: Number(item.markup || 0),
item_status: "pending",
drv_status: "unassigned",
driver_status: "unassigned",
notes: item.notes || "",
quote_item: item.id,
});
}
return { order, alreadyExists: false };
}
export async function getCollectionPool(page = 1, perPage = 50, status = "") {
const pb = getPocketBase();
const filter = status ? `pool_status = "${status}"` : "";
return pb.collection("collection_pool").getList<CollectionPool>(page, perPage, {
filter,
sort: "-created",
expand: "order_item,order,supplier,drv_id",
});
}
export async function updateOrderItemWorkflowStatus(
itemId: string,
patch: Partial<Pick<OrderItem, "item_status" | "drv_status" | "notes">>
) {
const pb = getPocketBase();
return pb.collection("order_items").update<OrderItem>(itemId, patch);
}
export async function updateCollectionPoolStatus(
poolItem: CollectionPool,
poolStatus: CollectionPool["pool_status"]
) {
const pb = getPocketBase();
const payload: Record<string, unknown> = { pool_status: poolStatus };
if (poolStatus === "assigned") {
payload.assigned_at = new Date().toISOString();
}
if (poolStatus === "completed") {
payload.completed_at = new Date().toISOString();
}
const updated = await pb.collection("collection_pool").update<CollectionPool>(poolItem.id, payload);
if (poolStatus === "assigned") {
await pb.collection("order_items").update(poolItem.order_item, {
item_status: "assigned",
drv_status: "assigned",
});
}
if (poolStatus === "in_progress") {
await pb.collection("order_items").update(poolItem.order_item, {
drv_status: "en_route",
});
}
if (poolStatus === "completed") {
await pb.collection("order_items").update(poolItem.order_item, {
item_status: "collected",
drv_status: "collected",
});
}
if (poolStatus === "failed") {
await pb.collection("order_items").update(poolItem.order_item, {
drv_status: "failed",
problem_flag: true,
problem_type: "driver",
});
}
return updated;
}
export async function getDeliveryNotes(page = 1, perPage = 50, search = "") {
const pb = getPocketBase();
const filter = search ? `delivery_note_number ~ "${search}" || notes ~ "${search}"` : "";
return pb.collection("delivery_notes").getList<DeliveryNote>(page, perPage, {
filter,
sort: "-created",
expand: "order,company,customer",
});
}
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,
});
}
export async function createRepair(data: {
customer?: string;
company?: string;
order?: string;
item_description: string;
fault_description: string;
notes?: string;
}) {
const pb = getPocketBase();
const repairNumber = await generateRepairNumber();
return pb.collection("repairs").create<Repair>({
repair_number: repairNumber,
customer: data.customer || null,
company: data.company || null,
order: data.order || null,
status: "new",
item_description: data.item_description,
fault_description: data.fault_description,
notes: data.notes || "",
});
}
/**
* @deprecated For new code, call `/api/v1/quotes/next-number` instead. This
* client-side wrapper is kept for backwards compatibility with the quote
* form. The server endpoint uses an atomic counter (no race), while this
* client function reads-then-writes the last quote number (racy under
* concurrent creates).
*/
export async function generateQuoteNumber(): Promise<string> {
const pb = getPocketBase();
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, "0");
const prefix = `Q${year}${month}`;
const existing = await pb.collection("quotes").getList<Quote>(1, 1, {
filter: `quote_number ~ "${prefix}"`,
sort: "-quote_number",
});
let seq = 1;
if (existing.items.length > 0) {
const lastRef = existing.items[0].quote_number;
// Extract trailing digits (handles both Q202607011 and legacy
// Q-2026-07-011 formats).
const m = lastRef.match(/(\d+)$/);
const lastNum = m ? parseInt(m[1], 10) : 0;
seq = lastNum + 1;
}
return `${prefix}${String(seq).padStart(3, "0")}`;
}
export async function generateOrderNumber(): Promise<string> {
const pb = getPocketBase();
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, "0");
const prefix = `ORD-${year}-${month}-`;
const existing = await pb.collection("orders").getList<Order>(1, 1, {
filter: `order_number ~ "${prefix}"`,
sort: "-order_number",
});
let seq = 1;
if (existing.items.length > 0) {
const lastNum = parseInt(existing.items[0].order_number.split("-").pop() || "0", 10);
seq = lastNum + 1;
}
return `${prefix}${String(seq).padStart(3, "0")}`;
}
export async function generatePONumber(): Promise<string> {
const pb = getPocketBase();
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, "0");
const prefix = `PO-${year}-${month}-`;
const existing = await pb.collection("purchase_orders").getList<PurchaseOrder>(1, 1, {
filter: `po_number ~ "${prefix}"`,
sort: "-po_number",
});
let seq = 1;
if (existing.items.length > 0) {
const lastNum = parseInt(existing.items[0].po_number.split("-").pop() || "0", 10);
seq = lastNum + 1;
}
return `${prefix}${String(seq).padStart(3, "0")}`;
}
export function calculateTotals(items: { selling_price: number; quantity: number }[]) {
const subtotal = Number(items.reduce((sum, item) => sum + item.selling_price * item.quantity, 0).toFixed(2));
const vat_amount = Number((subtotal * 0.15).toFixed(2));
const grand_total = Number((subtotal + vat_amount).toFixed(2));
return { subtotal, vat_amount, grand_total };
}
export function formatCurrency(amount: number): string {
return new Intl.NumberFormat("en-ZA", { style: "currency", currency: "ZAR" }).format(amount);
}
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);
}