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";
|
|
|
|
|
|
|
2026-08-13 18:55:22 +00:00
|
|
|
|
// The customers-page search now lives in `src/lib/server/customers-service.ts`
|
|
|
|
|
|
// (with rep-name support) and is exposed to the browser via
|
|
|
|
|
|
// `/api/v1/customers/list`. Migrated 2026-08 as part of the customers-page
|
|
|
|
|
|
// data-layer cleanup — see AGENTS.md "data-layer discipline".
|
|
|
|
|
|
//
|
|
|
|
|
|
// `getCustomer` and `getAllCustomers` still live here because they have
|
|
|
|
|
|
// other callers (customer detail page, order-form, quote-form, etc.)
|
|
|
|
|
|
// that haven't been migrated yet. They share the same naming convention
|
|
|
|
|
|
// with the service functions but use the cookie-auth PB instance
|
|
|
|
|
|
// (browser-side).
|
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 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",
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ============================================================================
|
2026-08-14 09:15:38 +00:00
|
|
|
|
* `fetchQuoteItemsForQuotes` — shared by the order-quote matcher
|
|
|
|
|
|
* (just below) and the new search service. The naive approach —
|
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
|
|
|
|
* 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.
|
|
|
|
|
|
*
|
2026-08-14 09:15:38 +00:00
|
|
|
|
* Returns the merged list of items. Caller is responsible for
|
|
|
|
|
|
* bucketing the result by `quote` id.
|
|
|
|
|
|
* ========================================================================== */
|
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 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat: collection-map updates, todays-stops, quote/approval rework, buyer-tasks, e2e
- Add todays-stops test and component to collection-map
- Expand collection-map sidebar and pin-model
- Rework orders/[id]/process page
- Add quotes/[id]/page and process pages
- Add quotes/approvals/[id] page
- Add buyer-tasks generate-po route
- Add quotes partial-approve and pdf routes
- Add purchase-orders [id]/pdf route
- Add e2e create-flows spec
- Add superpowers plan doc
- Update pb_schema-2.json
- Update form components (company, customer, order, quote, supplier)
- Update UI components (button, command, dialog)
- Add collection-pool test components
Co-authored-by: Orca <help@stably.ai>
2026-08-03 07:33:45 +00:00
|
|
|
|
/**
|
|
|
|
|
|
* 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();
|
2026-08-19 08:16:49 +00:00
|
|
|
|
const safeIds = quoteIds.map((id) => id.replace(/"/g, '\\"'));
|
feat: collection-map updates, todays-stops, quote/approval rework, buyer-tasks, e2e
- Add todays-stops test and component to collection-map
- Expand collection-map sidebar and pin-model
- Rework orders/[id]/process page
- Add quotes/[id]/page and process pages
- Add quotes/approvals/[id] page
- Add buyer-tasks generate-po route
- Add quotes partial-approve and pdf routes
- Add purchase-orders [id]/pdf route
- Add e2e create-flows spec
- Add superpowers plan doc
- Update pb_schema-2.json
- Update form components (company, customer, order, quote, supplier)
- Update UI components (button, command, dialog)
- Add collection-pool test components
Co-authored-by: Orca <help@stably.ai>
2026-08-03 07:33:45 +00:00
|
|
|
|
|
|
|
|
|
|
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",
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-08-19 08:16:49 +00:00
|
|
|
|
// Build the chunked filters first, then issue them in parallel. PB
|
|
|
|
|
|
// accepts the same number of concurrent reads without rate-limiting
|
|
|
|
|
|
// for a small page-worth of chunks, and `Promise.all` collapses
|
|
|
|
|
|
// wall-clock to the slowest chunk instead of the sum. Sequential
|
|
|
|
|
|
// was used historically because the IN-then-fallback code path
|
|
|
|
|
|
// bailed early on success; with the always-OR-chain strategy
|
|
|
|
|
|
// parallel is strictly better and the chunks are decoupled.
|
|
|
|
|
|
const chunks: string[] = [];
|
|
|
|
|
|
for (let i = 0; i < safeIds.length; i += QUOTE_FILTER_CHUNK) {
|
|
|
|
|
|
const batch = safeIds.slice(i, i + QUOTE_FILTER_CHUNK);
|
|
|
|
|
|
chunks.push(batch.map((id) => `quote = "${id}"`).join(" || "));
|
feat: collection-map updates, todays-stops, quote/approval rework, buyer-tasks, e2e
- Add todays-stops test and component to collection-map
- Expand collection-map sidebar and pin-model
- Rework orders/[id]/process page
- Add quotes/[id]/page and process pages
- Add quotes/approvals/[id] page
- Add buyer-tasks generate-po route
- Add quotes partial-approve and pdf routes
- Add purchase-orders [id]/pdf route
- Add e2e create-flows spec
- Add superpowers plan doc
- Update pb_schema-2.json
- Update form components (company, customer, order, quote, supplier)
- Update UI components (button, command, dialog)
- Add collection-pool test components
Co-authored-by: Orca <help@stably.ai>
2026-08-03 07:33:45 +00:00
|
|
|
|
}
|
2026-08-19 08:16:49 +00:00
|
|
|
|
const perChunk = await Promise.all(chunks.map(fetchSlim));
|
|
|
|
|
|
const items: Slim[] = perChunk.flat();
|
feat: collection-map updates, todays-stops, quote/approval rework, buyer-tasks, e2e
- Add todays-stops test and component to collection-map
- Expand collection-map sidebar and pin-model
- Rework orders/[id]/process page
- Add quotes/[id]/page and process pages
- Add quotes/approvals/[id] page
- Add buyer-tasks generate-po route
- Add quotes partial-approve and pdf routes
- Add purchase-orders [id]/pdf route
- Add e2e create-flows spec
- Add superpowers plan doc
- Update pb_schema-2.json
- Update form components (company, customer, order, quote, supplier)
- Update UI components (button, command, dialog)
- Add collection-pool test components
Co-authored-by: Orca <help@stably.ai>
2026-08-03 07:33:45 +00:00
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
/* ============================================================================
|
|
|
|
|
|
* 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat(orders): show quote line notes and attachments on the order detail page
Closes the same vanishing-buyer-notes bug B12 fixed on the process
page, this time on the order detail page that operators see first.
- Inline row indicators (MessageSquare icon for a buyer note,
Paperclip + count for per-line attachments) inside the Description
TableCell. Hidden when the line has no signal. Click toggles a
QuoteLineContext panel below the row.
- QuoteLineContext panel wrapped in a Fragment-keyed extra row,
colSpan=8 (matches the eight TableHead columns: Description, Qty,
Selling, Supplier, Cost, Total, Status, Edit). The plan called
for colSpan=9, but the Driver column was removed 2026-08-24 and
the live count is 8.
- quoteAttachments intentionally empty in the detail-page panel —
order-level attachments have their own card below the table.
Migration off pb-helpers (AGENTS.md §'data access layer discipline'):
- /api/v1/orders/{id}/quote route now treats empty quote_id as the
unlink path (clears quote + potential_quote + matching fields),
and dropped assertAgentAuth so the page can call it from a
browser session (admin auth happens via the PB session cookie at
the PocketBase layer; the MCP forwarder still works without that
gate because the MCP route gates at its own boundary).
- /api/v1/orders/{id}/quote-match is a thin wrapper around
suggestQuoteForOrder (returns the new SuggestQuoteResult shape —
best.quoteId / best.quoteNumber, not the old best.quote.id /
best.quote.quote_number).
- Page migrated four pb-helpers calls: findMatchingQuotesForOrder →
POST quote-match, applyOrderQuoteMatch → merge into quote-match +
QuoteMatchCard accepts the result via SuggestionCard from the
fetched order, clearOrderQuoteMatch → POST quote with empty
quote_id, setOrderQuoteManually → POST quote with the picked id.
- runMatch signature simplified — accept-vs-suggest was the old
split; the new endpoint only ever suggests. 'Accept' now goes
through linkQuoteManually via POST /quote.
- Stale matcher block (4 functions + 3 helpers + re-exports +
imports) deleted from pb-helpers.ts. The pure scoring helpers now
import directly from @/lib/server/order-quote-match.
Known follow-up: QuoteMatchCard still reads from matchResult (a
QuoteMatchResult shape). Clearing matchResult after runMatch means
the SuggestionCard renders empty until the next fetchOrder lands.
Reads from order.expand.potential_quote after that. A future task
can rewrite the card to drop the in-memory result entirely.
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-30 10:45:02 +00:00
|
|
|
|
/* ============================================================================
|
|
|
|
|
|
* Quote matcher — DELETED in Task 13 of the order↔quote linking plan.
|
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
|
|
|
|
*
|
feat(orders): show quote line notes and attachments on the order detail page
Closes the same vanishing-buyer-notes bug B12 fixed on the process
page, this time on the order detail page that operators see first.
- Inline row indicators (MessageSquare icon for a buyer note,
Paperclip + count for per-line attachments) inside the Description
TableCell. Hidden when the line has no signal. Click toggles a
QuoteLineContext panel below the row.
- QuoteLineContext panel wrapped in a Fragment-keyed extra row,
colSpan=8 (matches the eight TableHead columns: Description, Qty,
Selling, Supplier, Cost, Total, Status, Edit). The plan called
for colSpan=9, but the Driver column was removed 2026-08-24 and
the live count is 8.
- quoteAttachments intentionally empty in the detail-page panel —
order-level attachments have their own card below the table.
Migration off pb-helpers (AGENTS.md §'data access layer discipline'):
- /api/v1/orders/{id}/quote route now treats empty quote_id as the
unlink path (clears quote + potential_quote + matching fields),
and dropped assertAgentAuth so the page can call it from a
browser session (admin auth happens via the PB session cookie at
the PocketBase layer; the MCP forwarder still works without that
gate because the MCP route gates at its own boundary).
- /api/v1/orders/{id}/quote-match is a thin wrapper around
suggestQuoteForOrder (returns the new SuggestQuoteResult shape —
best.quoteId / best.quoteNumber, not the old best.quote.id /
best.quote.quote_number).
- Page migrated four pb-helpers calls: findMatchingQuotesForOrder →
POST quote-match, applyOrderQuoteMatch → merge into quote-match +
QuoteMatchCard accepts the result via SuggestionCard from the
fetched order, clearOrderQuoteMatch → POST quote with empty
quote_id, setOrderQuoteManually → POST quote with the picked id.
- runMatch signature simplified — accept-vs-suggest was the old
split; the new endpoint only ever suggests. 'Accept' now goes
through linkQuoteManually via POST /quote.
- Stale matcher block (4 functions + 3 helpers + re-exports +
imports) deleted from pb-helpers.ts. The pure scoring helpers now
import directly from @/lib/server/order-quote-match.
Known follow-up: QuoteMatchCard still reads from matchResult (a
QuoteMatchResult shape). Clearing matchResult after runMatch means
the SuggestionCard renders empty until the next fetchOrder lands.
Reads from order.expand.potential_quote after that. A future task
can rewrite the card to drop the in-memory result entirely.
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-30 10:45:02 +00:00
|
|
|
|
* The browser-side matcher block (findMatchingQuotesForOrder,
|
|
|
|
|
|
* applyOrderQuoteMatch, clearOrderQuoteMatch, setOrderQuoteManually +
|
|
|
|
|
|
* the private normRef / findExactMatch / findFuzzyMatches helpers
|
|
|
|
|
|
* they used to depend on, plus the ScoreBreakdown / LineMatchBreakdown
|
|
|
|
|
|
* re-exports from order-quote-match) was deleted. The matcher moved
|
|
|
|
|
|
* to the server-side service (`src/lib/server/order-quote-link-service.ts`)
|
|
|
|
|
|
* so the route handler, the intake `after()` hook, and the on-demand
|
|
|
|
|
|
* re-check can all share one source of truth without needing the
|
|
|
|
|
|
* browser's PB singleton in scope.
|
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
|
|
|
|
*
|
feat(orders): show quote line notes and attachments on the order detail page
Closes the same vanishing-buyer-notes bug B12 fixed on the process
page, this time on the order detail page that operators see first.
- Inline row indicators (MessageSquare icon for a buyer note,
Paperclip + count for per-line attachments) inside the Description
TableCell. Hidden when the line has no signal. Click toggles a
QuoteLineContext panel below the row.
- QuoteLineContext panel wrapped in a Fragment-keyed extra row,
colSpan=8 (matches the eight TableHead columns: Description, Qty,
Selling, Supplier, Cost, Total, Status, Edit). The plan called
for colSpan=9, but the Driver column was removed 2026-08-24 and
the live count is 8.
- quoteAttachments intentionally empty in the detail-page panel —
order-level attachments have their own card below the table.
Migration off pb-helpers (AGENTS.md §'data access layer discipline'):
- /api/v1/orders/{id}/quote route now treats empty quote_id as the
unlink path (clears quote + potential_quote + matching fields),
and dropped assertAgentAuth so the page can call it from a
browser session (admin auth happens via the PB session cookie at
the PocketBase layer; the MCP forwarder still works without that
gate because the MCP route gates at its own boundary).
- /api/v1/orders/{id}/quote-match is a thin wrapper around
suggestQuoteForOrder (returns the new SuggestQuoteResult shape —
best.quoteId / best.quoteNumber, not the old best.quote.id /
best.quote.quote_number).
- Page migrated four pb-helpers calls: findMatchingQuotesForOrder →
POST quote-match, applyOrderQuoteMatch → merge into quote-match +
QuoteMatchCard accepts the result via SuggestionCard from the
fetched order, clearOrderQuoteMatch → POST quote with empty
quote_id, setOrderQuoteManually → POST quote with the picked id.
- runMatch signature simplified — accept-vs-suggest was the old
split; the new endpoint only ever suggests. 'Accept' now goes
through linkQuoteManually via POST /quote.
- Stale matcher block (4 functions + 3 helpers + re-exports +
imports) deleted from pb-helpers.ts. The pure scoring helpers now
import directly from @/lib/server/order-quote-match.
Known follow-up: QuoteMatchCard still reads from matchResult (a
QuoteMatchResult shape). Clearing matchResult after runMatch means
the SuggestionCard renders empty until the next fetchOrder lands.
Reads from order.expand.potential_quote after that. A future task
can rewrite the card to drop the in-memory result entirely.
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-30 10:45:02 +00:00
|
|
|
|
* The page migrated to fetch against /api/v1/orders/{id}/quote-match
|
|
|
|
|
|
* and /api/v1/orders/{id}/quote as part of the same task. Tests that
|
|
|
|
|
|
* previously imported these helpers from `@/lib/pb-helpers` now
|
|
|
|
|
|
* import directly from `@/lib/server/order-quote-match` (pure) or
|
|
|
|
|
|
* `@/lib/server/order-quote-link-service` (PB-backed).
|
|
|
|
|
|
* ========================================================================== */
|
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
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 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,
|
feat: collection-map updates, todays-stops, quote/approval rework, buyer-tasks, e2e
- Add todays-stops test and component to collection-map
- Expand collection-map sidebar and pin-model
- Rework orders/[id]/process page
- Add quotes/[id]/page and process pages
- Add quotes/approvals/[id] page
- Add buyer-tasks generate-po route
- Add quotes partial-approve and pdf routes
- Add purchase-orders [id]/pdf route
- Add e2e create-flows spec
- Add superpowers plan doc
- Update pb_schema-2.json
- Update form components (company, customer, order, quote, supplier)
- Update UI components (button, command, dialog)
- Add collection-pool test components
Co-authored-by: Orca <help@stably.ai>
2026-08-03 07:33:45 +00:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
fix(delivery-notes): merge cancelled into completed, and fix the search
Tabs
- Cancelled no longer has its own tab. The operator asked for it to
sit under the done heading with a filter between the two, rather
than spending a tab on a small cold set nobody visits. Tabs are
now Active | Completed | All.
- Inside Completed, an always-visible Completed / Cancelled / Both
sub-filter, each option carrying its own count. It is rendered in
the open, not behind a click — an invisible control on this screen
was the original "what does that do?" complaint, so a hidden
filter would have reintroduced it. Defaults to "both" so the tab's
count and its rows always reconcile.
- Cancelled keeps a red badge, and no row tint. It now shares a tab
with Completed, so the two must be distinguishable at a glance
without opening each row.
Search (was quietly broken)
- It only covered `delivery_note_number` and `notes`. Customer name,
company and order number were NOT searched — so "what's
outstanding for Acme", the first thing anyone types on a list
grouped by company, returned nothing.
- The search term was never escaped into the PocketBase filter,
while the `repId` clause directly below it escapes its quotes. A
customer called `Acme "Ltd"`, or any stray double quote, produced
a malformed filter and a list that failed to load.
- Search is now client-side over the already-fetched notes, which
carry customer / company / order expanded. Covers DN number, order
number, customer, company, the customer's own PO and notes. Every
word in a multi-word query must match somewhere on the row, so
"acme bolt" narrows rather than returning nothing. No round trip
per keystroke.
Also removed
- Dead pagination state. The page fetched a single wide page and
handed the DataTable page={1} totalPages={1} with a no-op
onPageChange, so page/setPage/totalPages could never do anything.
That also retires the `setPage(1)` the search handler was calling.
Empty state now distinguishes "nothing matches your search" (and
names what is searchable) from "nothing on this tab".
Tests
- 30 in delivery-note-stage.test.ts, covering the search matcher and
the tab/sub-filter bucketing. Verified they bite by reverting the
search to its old two-field coverage: 5 fail.
- matchesDoneFilter refuses an in-flight status even under "both".
It didn't need to — the filter only runs inside the Completed tab —
but a function that would return a draft for "both" relies on its
call site staying correct, so it now guards itself.
Full suite: 3630 passing. tsc clean. No new lint warnings.
2026-10-04 21:29:48 +00:00
|
|
|
|
/**
|
|
|
|
|
|
* Fetch delivery notes for the list page.
|
|
|
|
|
|
*
|
|
|
|
|
|
* ## Why there is no `search` parameter any more
|
|
|
|
|
|
*
|
|
|
|
|
|
* This used to take a search term and push it into a PocketBase
|
|
|
|
|
|
* filter over `delivery_note_number || notes`. Two problems, both
|
|
|
|
|
|
* visible to the operator:
|
|
|
|
|
|
*
|
|
|
|
|
|
* 1. **It missed most of what a row shows.** Customer name, company
|
|
|
|
|
|
* and order number were not searched — and "what's outstanding
|
|
|
|
|
|
* for Acme" is the first thing anyone types on a list that is
|
|
|
|
|
|
* grouped BY company. Those fields only arrive via `expand`, so
|
|
|
|
|
|
* they can't be filtered server-side by this shape of query
|
|
|
|
|
|
* without a join on every row.
|
|
|
|
|
|
* 2. **The term was never escaped.** The `repId` clause on the very
|
|
|
|
|
|
* next line escapes its quotes; the search clause didn't. A
|
|
|
|
|
|
* customer called `Acme "Ltd"` — or anyone typing a stray
|
|
|
|
|
|
* double quote — produced a broken filter literal and a list
|
|
|
|
|
|
* that failed to load.
|
|
|
|
|
|
*
|
|
|
|
|
|
* So the search moved client-side (`matchesDeliveryNoteSearch` in
|
|
|
|
|
|
* `src/lib/delivery-note-stage.ts`), over rows that are already
|
|
|
|
|
|
* fetched WITH their customer / company / order expanded. One code
|
|
|
|
|
|
* path, every visible field, and no way to build a malformed filter.
|
|
|
|
|
|
*/
|
2026-08-31 21:28:02 +00:00
|
|
|
|
export async function getDeliveryNotes(
|
|
|
|
|
|
page = 1,
|
|
|
|
|
|
perPage = 50,
|
|
|
|
|
|
repId: string | null = null,
|
|
|
|
|
|
) {
|
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();
|
2026-08-31 21:28:02 +00:00
|
|
|
|
const clauses: string[] = [];
|
|
|
|
|
|
if (repId) {
|
|
|
|
|
|
clauses.push(`customer.rep = "${repId.replace(/"/g, '\\"')}"`);
|
|
|
|
|
|
}
|
|
|
|
|
|
const filter = clauses.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
|
|
|
|
return pb.collection("delivery_notes").getList<DeliveryNote>(page, perPage, {
|
|
|
|
|
|
filter,
|
|
|
|
|
|
sort: "-created",
|
|
|
|
|
|
expand: "order,company,customer",
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Batch-fetch the most-recent quote for each of the given
|
|
|
|
|
|
* delivery notes. Used by the list page so the Q icon can
|
|
|
|
|
|
* distinguish "create quote" (no quote yet) from "open quote"
|
|
|
|
|
|
* (quote already exists) without a round-trip per row.
|
|
|
|
|
|
*
|
|
|
|
|
|
* PB doesn't have a native IN operator, so we OR-chain the
|
|
|
|
|
|
* equality clauses. With a list of <100 DNs this is fine;
|
|
|
|
|
|
* past that the filter gets long and we'd want to switch to
|
|
|
|
|
|
* a per-DN fetch (which the dev list never needs).
|
|
|
|
|
|
*
|
|
|
|
|
|
* Returns a `Map<deliveryNoteId, Quote>`. DNs with no quote
|
|
|
|
|
|
* simply don't appear in the map.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export async function getQuotesForDeliveryNotes(
|
|
|
|
|
|
deliveryNoteIds: string[],
|
|
|
|
|
|
): Promise<Map<string, Quote>> {
|
|
|
|
|
|
const out = new Map<string, Quote>();
|
|
|
|
|
|
if (deliveryNoteIds.length === 0) return out;
|
|
|
|
|
|
const pb = getPocketBase();
|
feat(delivery-notes): one linear flow control, and fix the quote lookup
The operator asked for one action at the end of the row that walks
her through the work, instead of a strip of unlabelled icons.
[ Create quote ] [ Send ] [ Create invoice ]
Once a quote exists the FIRST slot becomes the quote number itself,
clickable, so she never hunts for where the quote went. Clicking it
opens the quote PDF in a popup with Print and Download — she stays on
the delivery-notes list, which is the whole point.
Deliberately not a fixed step 1 -> 2 -> 3: the counter also has
customer-to-customer work that never needs a quote, so "Create
invoice" is always offered and never depends on the quote having
happened. A fixed sequence would strand that path.
Send is mounted in place rather than by navigating to the quote page,
using the existing purpose-built modal (loud To field, alt-email
toggle, rep auto-CC, PDF attached, retry banner on a bounce). The
quote fetch now expands `customer,company` because that modal reads
`quote.expand.customer.rep` to pre-fill CC — without it, sends went
out silently with no CC and the office never saw a copy.
Everything else (signed copy, capture PO, mark invoiced, cancel,
regenerate PDF) stays on the detail page. That is a deliberate
hierarchy, not the drift this component was extracted to prevent: the
list is for scanning plus the three things you do to every note.
--- the bug this uncovered ---
The flow control could never show a quote, because the quote map was
always empty. `getQuotesForDeliveryNotes` built ONE PocketBase filter
of `delivery_note = "x" || ... ` across every id. The list fetches 500
notes, so that filter carried ~250 clauses, and PocketBase rejects it
with a bare `400 Something went wrong`. Measured on this instance: 75
clauses succeed, 100 fail.
The caller treats that fetch as non-fatal and swallows the error, so
it was completely silent. For the life of the feature:
- the Q icon never switched to "open quote" mode,
- `hasQuote` was always false,
- the blue "quoted" row tint never appeared, and
- the Send action could never be enabled.
A whole session of 3676 tests passed without catching it — they all
use a handful of ids, far under the limit. Only driving the real app
surfaced it.
Now chunked at 50 ids per request. Five sequential requests for a
250-note list is nothing next to the full delivery_notes page already
fetched alongside it.
Verified in the browser as David Els: DN26100599 now shows
Q202610102 in slot 1, Send is enabled, and clicking the quote opens
the generated PDF in a popup showing the priced line (1/2" X 2 1/4"
UNF BOLT, qty 10, R150.00, total R1 500.00) with Print and Download.
Full suite 3676 passing, tsc clean.
2026-10-05 16:21:19 +00:00
|
|
|
|
|
|
|
|
|
|
// CHUNKED, deliberately.
|
|
|
|
|
|
//
|
|
|
|
|
|
// This used to build ONE filter of `delivery_note = "x" || ... `
|
|
|
|
|
|
// across every id. The list page fetches 500 delivery notes, so
|
|
|
|
|
|
// that filter carried ~250 clauses — and PocketBase rejects it
|
|
|
|
|
|
// with a bare `400 {"message":"Something went wrong..."}`. Measured
|
|
|
|
|
|
// against this instance: 75 clauses succeed, 100 fail.
|
|
|
|
|
|
//
|
|
|
|
|
|
// The caller treats this fetch as non-fatal and swallows the error,
|
|
|
|
|
|
// so the symptom was completely silent: `quoteByDnId` came back
|
|
|
|
|
|
// EMPTY, and therefore
|
|
|
|
|
|
// - the Q icon never switched to "open quote" mode,
|
|
|
|
|
|
// - `hasQuote` was always false,
|
|
|
|
|
|
// - the blue "quoted" row tint never appeared, and
|
|
|
|
|
|
// - the Send action could never be enabled.
|
|
|
|
|
|
// Found in the browser, after a whole session of tests passing:
|
|
|
|
|
|
// the unit tests all use a handful of ids, well under the limit.
|
|
|
|
|
|
//
|
|
|
|
|
|
// 50 is comfortably under the measured ceiling. Five sequential
|
|
|
|
|
|
// requests for a 250-note list is nothing next to the full
|
|
|
|
|
|
// delivery_notes page already being fetched alongside it.
|
|
|
|
|
|
const CHUNK = 50;
|
|
|
|
|
|
const ids = deliveryNoteIds.map((id) => id.replace(/"/g, '\\"'));
|
|
|
|
|
|
|
|
|
|
|
|
for (let start = 0; start < ids.length; start += CHUNK) {
|
|
|
|
|
|
const slice = ids.slice(start, start + CHUNK);
|
|
|
|
|
|
const clauses = slice.map((id) => `delivery_note = "${id}"`).join(" || ");
|
|
|
|
|
|
|
|
|
|
|
|
// PB pages the result — the caller only ever needs the most
|
|
|
|
|
|
// recent quote per note, and one page covers a 50-note chunk.
|
|
|
|
|
|
// Generous cap so a malformed input can't loop forever.
|
|
|
|
|
|
let page = 1;
|
|
|
|
|
|
while (page < 20) {
|
|
|
|
|
|
const result = await pb.collection("quotes").getList<Quote>(
|
|
|
|
|
|
page,
|
|
|
|
|
|
200,
|
|
|
|
|
|
{
|
|
|
|
|
|
filter: clauses,
|
|
|
|
|
|
sort: "-created",
|
|
|
|
|
|
// The delivery-notes list mounts `SendQuoteModal` directly,
|
|
|
|
|
|
// and that modal reads `quote.expand.customer.rep` to
|
|
|
|
|
|
// pre-fill the rep on CC. Without this expand it silently
|
|
|
|
|
|
// sends with no CC, so the office never sees a copy of what
|
|
|
|
|
|
// went out.
|
|
|
|
|
|
expand: "customer,company",
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
for (const q of result.items) {
|
|
|
|
|
|
if (q.delivery_note && !out.has(q.delivery_note)) {
|
|
|
|
|
|
out.set(q.delivery_note, q);
|
|
|
|
|
|
}
|
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
|
|
|
|
}
|
feat(delivery-notes): one linear flow control, and fix the quote lookup
The operator asked for one action at the end of the row that walks
her through the work, instead of a strip of unlabelled icons.
[ Create quote ] [ Send ] [ Create invoice ]
Once a quote exists the FIRST slot becomes the quote number itself,
clickable, so she never hunts for where the quote went. Clicking it
opens the quote PDF in a popup with Print and Download — she stays on
the delivery-notes list, which is the whole point.
Deliberately not a fixed step 1 -> 2 -> 3: the counter also has
customer-to-customer work that never needs a quote, so "Create
invoice" is always offered and never depends on the quote having
happened. A fixed sequence would strand that path.
Send is mounted in place rather than by navigating to the quote page,
using the existing purpose-built modal (loud To field, alt-email
toggle, rep auto-CC, PDF attached, retry banner on a bounce). The
quote fetch now expands `customer,company` because that modal reads
`quote.expand.customer.rep` to pre-fill CC — without it, sends went
out silently with no CC and the office never saw a copy.
Everything else (signed copy, capture PO, mark invoiced, cancel,
regenerate PDF) stays on the detail page. That is a deliberate
hierarchy, not the drift this component was extracted to prevent: the
list is for scanning plus the three things you do to every note.
--- the bug this uncovered ---
The flow control could never show a quote, because the quote map was
always empty. `getQuotesForDeliveryNotes` built ONE PocketBase filter
of `delivery_note = "x" || ... ` across every id. The list fetches 500
notes, so that filter carried ~250 clauses, and PocketBase rejects it
with a bare `400 Something went wrong`. Measured on this instance: 75
clauses succeed, 100 fail.
The caller treats that fetch as non-fatal and swallows the error, so
it was completely silent. For the life of the feature:
- the Q icon never switched to "open quote" mode,
- `hasQuote` was always false,
- the blue "quoted" row tint never appeared, and
- the Send action could never be enabled.
A whole session of 3676 tests passed without catching it — they all
use a handful of ids, far under the limit. Only driving the real app
surfaced it.
Now chunked at 50 ids per request. Five sequential requests for a
250-note list is nothing next to the full delivery_notes page already
fetched alongside it.
Verified in the browser as David Els: DN26100599 now shows
Q202610102 in slot 1, Send is enabled, and clicking the quote opens
the generated PDF in a popup showing the priced line (1/2" X 2 1/4"
UNF BOLT, qty 10, R150.00, total R1 500.00) with Print and Download.
Full suite 3676 passing, tsc clean.
2026-10-05 16:21:19 +00:00
|
|
|
|
if (result.items.length < 200) break;
|
|
|
|
|
|
page++;
|
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
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return out;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat(delivery-notes): one linear flow control, and fix the quote lookup
The operator asked for one action at the end of the row that walks
her through the work, instead of a strip of unlabelled icons.
[ Create quote ] [ Send ] [ Create invoice ]
Once a quote exists the FIRST slot becomes the quote number itself,
clickable, so she never hunts for where the quote went. Clicking it
opens the quote PDF in a popup with Print and Download — she stays on
the delivery-notes list, which is the whole point.
Deliberately not a fixed step 1 -> 2 -> 3: the counter also has
customer-to-customer work that never needs a quote, so "Create
invoice" is always offered and never depends on the quote having
happened. A fixed sequence would strand that path.
Send is mounted in place rather than by navigating to the quote page,
using the existing purpose-built modal (loud To field, alt-email
toggle, rep auto-CC, PDF attached, retry banner on a bounce). The
quote fetch now expands `customer,company` because that modal reads
`quote.expand.customer.rep` to pre-fill CC — without it, sends went
out silently with no CC and the office never saw a copy.
Everything else (signed copy, capture PO, mark invoiced, cancel,
regenerate PDF) stays on the detail page. That is a deliberate
hierarchy, not the drift this component was extracted to prevent: the
list is for scanning plus the three things you do to every note.
--- the bug this uncovered ---
The flow control could never show a quote, because the quote map was
always empty. `getQuotesForDeliveryNotes` built ONE PocketBase filter
of `delivery_note = "x" || ... ` across every id. The list fetches 500
notes, so that filter carried ~250 clauses, and PocketBase rejects it
with a bare `400 Something went wrong`. Measured on this instance: 75
clauses succeed, 100 fail.
The caller treats that fetch as non-fatal and swallows the error, so
it was completely silent. For the life of the feature:
- the Q icon never switched to "open quote" mode,
- `hasQuote` was always false,
- the blue "quoted" row tint never appeared, and
- the Send action could never be enabled.
A whole session of 3676 tests passed without catching it — they all
use a handful of ids, far under the limit. Only driving the real app
surfaced it.
Now chunked at 50 ids per request. Five sequential requests for a
250-note list is nothing next to the full delivery_notes page already
fetched alongside it.
Verified in the browser as David Els: DN26100599 now shows
Q202610102 in slot 1, Send is enabled, and clicking the quote opens
the generated PDF in a popup showing the priced line (1/2" X 2 1/4"
UNF BOLT, qty 10, R150.00, total R1 500.00) with Print and Download.
Full suite 3676 passing, tsc clean.
2026-10-05 16:21:19 +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
|
|
|
|
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")}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-19 13:08:12 +00:00
|
|
|
|
// PO numbers are generated server-side only: see createPurchaseOrderWithNumber
|
|
|
|
|
|
// in src/lib/server/po-number.ts (the client copy raced, 2026-10-19).
|
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 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;
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|