WFDASH-WEBAPP/src/lib/pb-helpers.ts

2116 lines
75 KiB
TypeScript
Raw Normal View History

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