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>
This commit is contained in:
parent
720f01e5b6
commit
db1e7d6f76
7 changed files with 901 additions and 80 deletions
|
|
@ -37,6 +37,7 @@
|
|||
"pb:migrate:stock-purchase-send-statuses": "npx tsx scripts/migrations/2026-07-16-add-stock-purchase-send-statuses.ts",
|
||||
"pb:migrate:stock-purchase-problem-fields": "npx tsx scripts/migrations/2026-07-17-add-stock-purchase-problem-fields.ts",
|
||||
"pb:migrate:order-items-item-status-index": "npx tsx scripts/migrations/2026-08-04-add-order-items-item-status-index.ts",
|
||||
"pb:migrate:quotes-status-index": "npx tsx scripts/migrations/2026-08-04-add-quotes-status-index.ts",
|
||||
"smoke:counter": "npx tsx scripts/smoke/counter.ts",
|
||||
"smoke:counter-concurrent": "npx tsx scripts/smoke/counter-concurrent.ts",
|
||||
"smoke:create-from-email": "npx tsx scripts/smoke/create-from-email.ts",
|
||||
|
|
|
|||
86
scripts/migrations/2026-08-04-add-quotes-status-index.ts
Normal file
86
scripts/migrations/2026-08-04-add-quotes-status-index.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
/* eslint-disable */
|
||||
/**
|
||||
* Migration: add an index on `quotes.status`.
|
||||
*
|
||||
* Why this exists
|
||||
* ───────────────
|
||||
* The /quotes page's tab badges ("New (4)", "Completed (7,849)",
|
||||
* "Email Failed (57)") and its server-side tab filter both run
|
||||
* queries of the form `status = "emailed" || status = "approved" || ...`
|
||||
* on the `quotes` collection. With ~8.5k quotes and no `status` index,
|
||||
* every load is a full table scan — the 22 separate per-status count
|
||||
* queries in the old `getQuoteStatusCounts` were 22 scans, costing
|
||||
* ~2.6s wall time on every page load. The new combined-counts query
|
||||
* collapses to 1 scan (~150ms measured on the live instance), but
|
||||
* an index turns even that 1 scan into a sub-100ms B-tree lookup.
|
||||
*
|
||||
* Schema
|
||||
* ──────
|
||||
* quotes.status select (existing field, no schema change)
|
||||
*
|
||||
* The index is non-unique (many rows share each status).
|
||||
*
|
||||
* Idempotent — safe to re-run. Checks for the index before adding
|
||||
* it; subsequent runs print "already present, skipping."
|
||||
*
|
||||
* Run with:
|
||||
* npx tsx scripts/migrations/2026-08-04-add-quotes-status-index.ts
|
||||
*/
|
||||
import PocketBase from "pocketbase";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
function loadEnvFile(name: string) {
|
||||
try {
|
||||
const raw = readFileSync(resolve(process.cwd(), name), "utf8");
|
||||
for (const line of raw.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
const m = trimmed.match(/^([A-Z0-9_]+)\s*=\s*(.+)$/i);
|
||||
if (m && !process.env[m[1]]) {
|
||||
process.env[m[1]] = m[2].replace(/^["']|["']$/g, "").trim();
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// missing file — fine
|
||||
}
|
||||
}
|
||||
loadEnvFile(".env");
|
||||
loadEnvFile(".env.local");
|
||||
|
||||
async function main() {
|
||||
const url = process.env.NEXT_PUBLIC_POCKETBASE_URL;
|
||||
const email = process.env.PB_ADMIN_EMAIL;
|
||||
const password = process.env.PB_ADMIN_PASSWORD;
|
||||
|
||||
if (!url || !email || !password) {
|
||||
throw new Error(
|
||||
"Missing NEXT_PUBLIC_POCKETBASE_URL / PB_ADMIN_EMAIL / PB_ADMIN_PASSWORD in env",
|
||||
);
|
||||
}
|
||||
|
||||
const pb = new PocketBase(url);
|
||||
await pb.admins.authWithPassword(email, password);
|
||||
|
||||
const collection = await pb.collections.getOne("quotes");
|
||||
const INDEX_NAME = "idx_quotes_status";
|
||||
const INDEX_SQL = `CREATE INDEX \`${INDEX_NAME}\` ON \`quotes\` (\`status\`)`;
|
||||
|
||||
const existing = (collection.indexes || []) as string[];
|
||||
if (existing.includes(INDEX_SQL)) {
|
||||
console.log("quotes.status index already present, skipping.");
|
||||
return;
|
||||
}
|
||||
|
||||
await pb.collections.update(collection.id, {
|
||||
indexes: [...existing, INDEX_SQL],
|
||||
});
|
||||
|
||||
console.log("Added quotes.status index (idx_quotes_status).");
|
||||
console.log("Run /quotes in the browser — the initial load should be visibly faster.");
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -32,14 +32,16 @@ import { toast } from "sonner";
|
|||
import { cn } from "@/lib/utils";
|
||||
import getPocketBase from "@/lib/pocketbase";
|
||||
import {
|
||||
getQuotes,
|
||||
getQuoteStatusCounts,
|
||||
getQuoteItems,
|
||||
searchQuotesWithItems,
|
||||
fetchQuoteItemCounts,
|
||||
formatDate,
|
||||
formatCurrency,
|
||||
} from "@/lib/pb-helpers";
|
||||
import {
|
||||
type QuoteTabKey,
|
||||
} from "@/lib/server/quotes-service";
|
||||
import type { QuotesDashboardData } from "@/lib/server/quotes-service";
|
||||
import { formatQuoteStatus, quoteStatusClassName } from "@/lib/quote-status";
|
||||
import { userColor, userDisplayName, type UserColor } from "@/lib/user-color";
|
||||
import { isOlderThanWorkingDays, workingDaysBetween } from "@/lib/working-days";
|
||||
|
|
@ -53,44 +55,13 @@ import type { Quote, QuoteItem } from "@/lib/types";
|
|||
const AGE_WARNING_DAYS = 3;
|
||||
|
||||
/**
|
||||
* Tab → PB status filter. The list page merges several legacy statuses
|
||||
* into one workflow tab so the operator scans fewer buckets:
|
||||
*
|
||||
* new ← new + follow_up (follow-up shows a bell icon on the row)
|
||||
* claimed ← claimed
|
||||
* pending_approval ← processed + approval_pending + sending (in-flight send)
|
||||
* completed ← approved + complete + emailed (awaiting reply)
|
||||
* + cancelled + rejected
|
||||
* email_failed ← email_failed + mail_failed + unmatched_bounce
|
||||
* problem ← problem
|
||||
* templates ← is_template = true (handled in getQuotes directly)
|
||||
* all ← no status filter
|
||||
*
|
||||
* Multi-status tabs are OR-chained inside getQuotes (PB 0.26 doesn't
|
||||
* support the `IN` operator — see MEMORY.md).
|
||||
* Tab → PB status filter is now the canonical source of truth in
|
||||
* `src/lib/server/quotes-service.ts` (see `QUOTE_TAB_STATUSES`). The
|
||||
* server applies the filter as part of the dashboard read model, so
|
||||
* the page no longer needs to know the underlying PB statuses. The
|
||||
* presentation-only tab list (`TAB_ORDER` below) and colour classes
|
||||
* (`tabColorClasses`) stay here because they're UI concerns.
|
||||
*/
|
||||
const tabFilters: Record<string, string[]> = {
|
||||
new: ["new", "follow_up"],
|
||||
claimed: ["claimed"],
|
||||
pending_approval: ["processed", "approval_pending", "sending"],
|
||||
// "Completed" now means the quote actually went through to a
|
||||
// positive outcome. Cancelled + rejected moved to their own
|
||||
// tab so a soft-deleted quote doesn't pollute the success
|
||||
// bucket (and so the office can find a quote they voided
|
||||
// without grepping "All").
|
||||
completed: ["approved", "complete", "emailed"],
|
||||
cancelled: ["cancelled", "rejected"],
|
||||
email_failed: ["email_failed", "mail_failed", "unmatched_bounce"],
|
||||
problem: ["problem"],
|
||||
// "delivery_note" is filtered by the `delivery_note` relation
|
||||
// field, not by `status` — see the `deliveryNoteOnly` option
|
||||
// on `getQuotes`. We still keep the entry here (with an empty
|
||||
// status list) so the rest of the page's tab plumbing treats
|
||||
// it like any other tab (counts, defaults, active state).
|
||||
delivery_note: [],
|
||||
templates: ["template"],
|
||||
all: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* Active-tab background / foreground / border classes. The new palette
|
||||
|
|
@ -166,7 +137,7 @@ export default function QuotesPage() {
|
|||
const [page, setPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [search, setSearch] = useState("");
|
||||
const [activeTab, setActiveTab] = useState("new");
|
||||
const [activeTab, setActiveTab] = useState<QuoteTabKey>("new");
|
||||
const [counts, setCounts] = useState<Record<string, number>>({});
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [users, setUsers] = useState<UserLookup[]>([]);
|
||||
|
|
@ -194,11 +165,13 @@ export default function QuotesPage() {
|
|||
// once — each button is independent.
|
||||
const [loadingActionIds, setLoadingActionIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// `statusFilter` is the list of PB statuses the active tab maps to.
|
||||
// The Templates tab uses a special-cased filter (see getQuotes); the
|
||||
// "All" tab uses [] (no filter). For every other tab it's 1–5 statuses
|
||||
// OR-chained into a single PB filter inside `getQuotes`.
|
||||
const statusFilter = tabFilters[activeTab] ?? [];
|
||||
// The status filter, special-cased for the delivery_note tab (relation
|
||||
// filter, not status IN) and the templates tab (is_template = true),
|
||||
// lives server-side now. The canonical mapping is
|
||||
// `QUOTE_TAB_STATUSES` in `src/lib/server/quotes-service.ts`; the
|
||||
// page just passes `activeTab` to the route and lets the service
|
||||
// build the PB filter — no drift between the list filter and the
|
||||
// badge count.
|
||||
|
||||
const fetchQuotes = useCallback(async () => {
|
||||
setLoading(true);
|
||||
|
|
@ -235,27 +208,41 @@ export default function QuotesPage() {
|
|||
}
|
||||
setItemCounts(counts);
|
||||
} else {
|
||||
// The Delivery Note tab filters by the `delivery_note`
|
||||
// relation, not by status (a DN-backed quote can be in
|
||||
// any of claimed / processed / emailed / etc). Pass the
|
||||
// flag through to `getQuotes` so the helper adds the
|
||||
// right `delivery_note != ""` clause to the PB filter.
|
||||
const getQuotesOpts =
|
||||
activeTab === "delivery_note" ? { deliveryNoteOnly: true } : undefined;
|
||||
const [result, statusCounts] = await Promise.all([
|
||||
getQuotes(page, 20, search, "-created", statusFilter, getQuotesOpts),
|
||||
getQuoteStatusCounts(),
|
||||
]);
|
||||
setQuotes(result.items);
|
||||
setTotalPages(result.totalPages);
|
||||
setCounts(statusCounts);
|
||||
// Server-side tab filter. The route returns:
|
||||
// - items: tab-scoped to the active tab's matching set
|
||||
// - counts: badge counts per tab (combined-query, cached
|
||||
// 60s on the server)
|
||||
// - totalPages: matches the active tab's matching set, not
|
||||
// the global ~8.5k quote count.
|
||||
const pbToken = getPocketBase().authStore.token;
|
||||
if (!pbToken) {
|
||||
throw new Error("Your session has expired. Please sign in again.");
|
||||
}
|
||||
const params = new URLSearchParams({
|
||||
page: String(page),
|
||||
perPage: "20",
|
||||
tab: activeTab,
|
||||
});
|
||||
const resp = await fetch(
|
||||
`/api/v1/quotes/list?${params.toString()}`,
|
||||
{ headers: { Authorization: pbToken }, cache: "no-store" },
|
||||
);
|
||||
const body = (await resp.json()) as QuotesDashboardData & {
|
||||
error?: string;
|
||||
};
|
||||
if (!resp.ok) {
|
||||
throw new Error(body.error || `Request failed (${resp.status})`);
|
||||
}
|
||||
setQuotes(body.items);
|
||||
setTotalPages(body.totalPages);
|
||||
setCounts(body.counts);
|
||||
// Per-quote line item progress for the "5/10" badge in
|
||||
// the Quote # column. Fetches only `quote` + `is_processed`
|
||||
// (via `fields`) so the payload stays small even when a
|
||||
// quote has many heavy items. Done in parallel with the
|
||||
// status-counts fetch above to keep the page-load budget
|
||||
// dashboard fetch above to keep the page-load budget
|
||||
// at one extra round-trip.
|
||||
const ids = result.items.map((q) => q.id);
|
||||
const ids = body.items.map((q) => q.id);
|
||||
const nextCounts =
|
||||
ids.length > 0 ? await fetchQuoteItemCounts(ids) : {};
|
||||
setItemCounts(nextCounts);
|
||||
|
|
@ -266,9 +253,10 @@ export default function QuotesPage() {
|
|||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, search, statusFilter, activeTab]);
|
||||
}, [page, search, activeTab]);
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- data fetch on dep change
|
||||
fetchQuotes();
|
||||
}, [fetchQuotes]);
|
||||
|
||||
|
|
@ -441,7 +429,7 @@ export default function QuotesPage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={(v) => { setActiveTab(v); setPage(1); }}>
|
||||
<Tabs value={activeTab} onValueChange={(v) => { setActiveTab(v as QuoteTabKey); setPage(1); }}>
|
||||
<TabsList className="flex flex-wrap h-auto min-h-fit w-full items-start justify-start gap-0.5 bg-transparent p-0 pb-4">
|
||||
{TAB_ORDER.map((tab) => (
|
||||
<TabsTrigger
|
||||
|
|
|
|||
67
src/app/api/v1/quotes/list/route.ts
Normal file
67
src/app/api/v1/quotes/list/route.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { assertBrowserSession } from "@/lib/server/browser-session";
|
||||
import {
|
||||
QUOTE_TAB_ORDER,
|
||||
getQuotesDashboardData,
|
||||
type QuoteTabKey,
|
||||
} from "@/lib/server/quotes-service";
|
||||
|
||||
/**
|
||||
* Browser-facing read model for /quotes. The external agent endpoint
|
||||
* remains at GET /api/v1/quotes; this route accepts the logged-in
|
||||
* dashboard user's PB token and returns the tab-scoped list + counts.
|
||||
*
|
||||
* Query params:
|
||||
* page — 1-indexed page number (default 1)
|
||||
* perPage — page size, clamped to 1..100 (default 20)
|
||||
* search — free-text filter (matches quote_number / quote_reference / rfq_number)
|
||||
* tab — restrict results to the tab's status set (or special
|
||||
* filter for delivery_note / templates). Without `tab`,
|
||||
* the query returns across all workflow tabs and the
|
||||
* pagination footer reflects the global quote count
|
||||
* (~8.5k today). With `tab`, the server narrows first
|
||||
* so the footer reflects only the matching set — the
|
||||
* "Completed (7,849)" badge then matches what page 1
|
||||
* actually shows.
|
||||
* nocache=1 — bypass the 60s tab-counts cache. Reserved for a
|
||||
* future realtime subscription on /quotes (none today
|
||||
* — the page only refetches on user actions, so the
|
||||
* cache survives each tab-switch without going stale).
|
||||
*/
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const VALID_TABS = new Set<string>(QUOTE_TAB_ORDER);
|
||||
|
||||
function parseTab(raw: string | null): QuoteTabKey | undefined {
|
||||
if (!raw) return undefined;
|
||||
return VALID_TABS.has(raw) ? (raw as QuoteTabKey) : undefined;
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
await assertBrowserSession(req);
|
||||
|
||||
const page = Number(req.nextUrl.searchParams.get("page") || "1");
|
||||
const perPage = Number(req.nextUrl.searchParams.get("perPage") || "20");
|
||||
const search = req.nextUrl.searchParams.get("search") || "";
|
||||
const tab = parseTab(req.nextUrl.searchParams.get("tab"));
|
||||
const bypassCountsCache =
|
||||
req.nextUrl.searchParams.get("nocache") === "1";
|
||||
|
||||
const result = await getQuotesDashboardData({
|
||||
page,
|
||||
perPage,
|
||||
search,
|
||||
sort: "-created",
|
||||
tab,
|
||||
bypassCountsCache,
|
||||
});
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to load quotes";
|
||||
const status = (error as Error & { status?: number }).status || 500;
|
||||
return NextResponse.json({ error: message }, { status });
|
||||
}
|
||||
}
|
||||
|
|
@ -505,7 +505,7 @@ const QUOTE_FILTER_CHUNK = 40;
|
|||
|
||||
export async function fetchQuoteItemsForQuotes(
|
||||
quoteIds: string[],
|
||||
options: { expand?: string } = {}
|
||||
options: { expand?: string; fields?: string } = {}
|
||||
): Promise<QuoteItem[]> {
|
||||
if (quoteIds.length === 0) return [];
|
||||
const pb = getPocketBase();
|
||||
|
|
@ -519,6 +519,7 @@ export async function fetchQuoteItemsForQuotes(
|
|||
return await pb.collection("quote_items").getFullList<QuoteItem>({
|
||||
filter: `quote IN (${safeIds.map((id) => `"${id}"`).join(",")})`,
|
||||
...(options.expand ? { expand: options.expand } : {}),
|
||||
...(options.fields ? { fields: options.fields } : {}),
|
||||
});
|
||||
} catch {
|
||||
// 2. Fall back to chunked OR-chains for older PB versions.
|
||||
|
|
@ -531,6 +532,7 @@ export async function fetchQuoteItemsForQuotes(
|
|||
const items = await pb.collection("quote_items").getFullList<QuoteItem>({
|
||||
filter: orChain,
|
||||
...(options.expand ? { expand: options.expand } : {}),
|
||||
...(options.fields ? { fields: options.fields } : {}),
|
||||
});
|
||||
out.push(...items);
|
||||
}
|
||||
|
|
@ -604,16 +606,46 @@ export async function searchQuotesWithItems(
|
|||
const trimmed = q.trim();
|
||||
if (!trimmed) return [];
|
||||
|
||||
// Slim fields + per-expand limits. The quotes collection has 45+
|
||||
// fields including unbounded `email_body` / `email_message`; the
|
||||
// search path was dragging every full record over the wire. The
|
||||
// service-layer /quotes/list read model uses the same projection.
|
||||
const QUOTE_SEARCH_FIELDS = [
|
||||
"id",
|
||||
"quote_number",
|
||||
"status",
|
||||
"claimed_by",
|
||||
"is_template",
|
||||
"follow_up_due",
|
||||
"last_reply_at",
|
||||
"customer",
|
||||
"company",
|
||||
"created",
|
||||
"updated",
|
||||
"company.id",
|
||||
"company.company",
|
||||
"customer.id",
|
||||
"customer.customer",
|
||||
"customer.rep",
|
||||
"customer.rep.id",
|
||||
"customer.rep.name",
|
||||
"customer.rep.email",
|
||||
].join(",");
|
||||
const quotes = await pb.collection("quotes").getList<Quote>(1, SEARCH_HARD_CAP, {
|
||||
sort,
|
||||
expand: "customer,customer.company,customer.rep,company",
|
||||
expand: "customer,customer.rep,company",
|
||||
fields: QUOTE_SEARCH_FIELDS,
|
||||
});
|
||||
const quoteIds = quotes.items.map((q0) => q0.id);
|
||||
|
||||
// Pull items via the chunked helper — the old single OR-chain
|
||||
// filter overflowed PB's max filter length once the search
|
||||
// returned 50+ quotes.
|
||||
const items = await fetchQuoteItemsForQuotes(quoteIds, { expand: "supplier" });
|
||||
// returned 50+ quotes. The items path keeps the supplier expand
|
||||
// because the per-row chevron expansion renders the supplier name.
|
||||
const items = await fetchQuoteItemsForQuotes(quoteIds, {
|
||||
expand: "supplier",
|
||||
fields: "id,quote,description,quantity,selling_price,is_processed,supplier,supplier.suppliername",
|
||||
});
|
||||
const itemsByQuote: Record<string, QuoteItem[]> = {};
|
||||
for (const item of items) {
|
||||
(itemsByQuote[item.quote] ||= []).push(item);
|
||||
|
|
|
|||
|
|
@ -12,26 +12,121 @@ const { store, pbMock, clearStore } = vi.hoisted(() => {
|
|||
return store[collection];
|
||||
};
|
||||
|
||||
// The mock `where` is intentionally minimal — supports the patterns the
|
||||
// production service actually issues: `field = "value"` (with or
|
||||
// Recursive filter parser. The production service builds filters
|
||||
// like `is_template != true && (status = "a" || status = "b")` —
|
||||
// parens bind the OR to the surrounding AND. A naive
|
||||
// top-level `||` split would break the parens; this parser
|
||||
// walks the string and evaluates each piece as a boolean
|
||||
// expression, recursing into parens.
|
||||
type FilterAST =
|
||||
| { kind: "and"; left: FilterAST; right: FilterAST }
|
||||
| { kind: "or"; left: FilterAST; right: FilterAST }
|
||||
| { kind: "not"; expr: FilterAST }
|
||||
| { kind: "cmp"; field: string; op: "=" | "!=" | "=~" | "!~"; value: string };
|
||||
|
||||
// Strip outer parens if they wrap the whole string. e.g.
|
||||
// `(a = "x")` → `a = "x"`. Lets callers wrap groups in parens
|
||||
// for readability without us thinking they need recursive eval.
|
||||
// Only strips if the matching `(` is at index 0 AND the matching
|
||||
// `)` is at the END — anything else means the parens don't wrap
|
||||
// the whole expression.
|
||||
const stripOuterParens = (s: string): string => {
|
||||
const trimmed = s.trim();
|
||||
if (!trimmed.startsWith("(") || !trimmed.endsWith(")")) return s;
|
||||
let depth = 0;
|
||||
let lastCloseAt = -1;
|
||||
for (let i = 0; i < trimmed.length; i++) {
|
||||
if (trimmed[i] === "(") depth++;
|
||||
else if (trimmed[i] === ")") {
|
||||
depth--;
|
||||
lastCloseAt = i;
|
||||
}
|
||||
}
|
||||
// Wrap the whole string iff depth is 0 at the end AND the
|
||||
// opening `(` is at position 0 AND the matching close is the
|
||||
// last char.
|
||||
if (depth !== 0) return s;
|
||||
if (lastCloseAt !== trimmed.length - 1) return s;
|
||||
return trimmed.slice(1, -1);
|
||||
};
|
||||
|
||||
const parseFilter = (raw: string): FilterAST => {
|
||||
const s = stripOuterParens(raw.trim());
|
||||
|
||||
// Find the LOWEST-precedence top-level operator. `||` is lower
|
||||
// than `&&` in PB, so we look for `||` first and recurse.
|
||||
// Loop bound: `i < s.length - 1` would skip the last char; we
|
||||
// need to scan every char (including the final `)`) to keep
|
||||
// `depth` accurate for nested-paren tracking.
|
||||
let depth = 0;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
const c = s[i];
|
||||
if (c === "(") depth++;
|
||||
else if (c === ")") depth--;
|
||||
else if (depth === 0) {
|
||||
// Two-char operator `||`
|
||||
if (c === "|" && s[i + 1] === "|") {
|
||||
return {
|
||||
kind: "or",
|
||||
left: parseFilter(s.slice(0, i)),
|
||||
right: parseFilter(s.slice(i + 2)),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
const c = s[i];
|
||||
if (c === "(") depth++;
|
||||
else if (c === ")") depth--;
|
||||
else if (depth === 0) {
|
||||
if (c === "&" && s[i + 1] === "&") {
|
||||
return {
|
||||
kind: "and",
|
||||
left: parseFilter(s.slice(0, i)),
|
||||
right: parseFilter(s.slice(i + 2)),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
// Leaf — a comparison. `!=`, `=~`, `!~`, or `=`.
|
||||
// Value can be empty (e.g. `field != ""`) so we use `*?` not `+?`.
|
||||
const m = s.match(/^(\w+)\s*(=~|!~|=|!=)\s*"?([^"]*?)"?$/);
|
||||
if (!m) {
|
||||
throw new Error(`Mock filter parser: cannot parse "${s}"`);
|
||||
}
|
||||
const [, field, op, value] = m;
|
||||
return { kind: "cmp", field, op: op as "=" | "!=" | "=~" | "!~", value };
|
||||
};
|
||||
|
||||
const evalFilter = (item: Record<string, unknown>, ast: FilterAST): boolean => {
|
||||
switch (ast.kind) {
|
||||
case "and": return evalFilter(item, ast.left) && evalFilter(item, ast.right);
|
||||
case "or": return evalFilter(item, ast.left) || evalFilter(item, ast.right);
|
||||
case "not": return !evalFilter(item, ast.expr);
|
||||
case "cmp": {
|
||||
const actualStr = String(item[ast.field] ?? "");
|
||||
switch (ast.op) {
|
||||
case "=": return actualStr === ast.value;
|
||||
case "!=": return actualStr !== ast.value;
|
||||
case "=~": return actualStr.includes(ast.value);
|
||||
case "!~": return !actualStr.includes(ast.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const where = (collection: string, filter: string) => {
|
||||
const map = getOrCreate(collection);
|
||||
const items = Array.from(map.values());
|
||||
if (!filter || !filter.trim()) return items;
|
||||
const clauses = filter.split("&&").map((c) => c.trim());
|
||||
return items.filter((item) =>
|
||||
clauses.every((clause) => {
|
||||
const m = clause.match(/^(\w+)\s*=\s*"?([^"]+)"?$/);
|
||||
if (!m) return true;
|
||||
const [, field, raw] = m;
|
||||
const value = raw.replace(/^"|"$/g, "");
|
||||
if (field === "year" || field === "month") {
|
||||
return Number(item[field]) === Number(value);
|
||||
}
|
||||
return String(item[field] ?? "") === value;
|
||||
})
|
||||
);
|
||||
const ast = parseFilter(filter);
|
||||
return items.filter((item) => evalFilter(item, ast));
|
||||
};
|
||||
|
||||
const pbMock = {
|
||||
autoCancellation: () => undefined,
|
||||
collection: (name: string) => ({
|
||||
async getList(page = 1, perPage = 25, opts: { filter?: string } = {}) {
|
||||
const items = where(name, opts.filter || "");
|
||||
|
|
@ -39,6 +134,7 @@ const { store, pbMock, clearStore } = vi.hoisted(() => {
|
|||
return {
|
||||
items: items.slice(start, start + perPage),
|
||||
totalItems: items.length,
|
||||
totalPages: Math.max(1, Math.ceil(items.length / perPage)),
|
||||
page,
|
||||
perPage,
|
||||
};
|
||||
|
|
@ -134,6 +230,11 @@ import {
|
|||
sendQuoteEmail,
|
||||
retryQuoteEmail,
|
||||
recordSendOutcome,
|
||||
getQuotesDashboardData,
|
||||
getQuoteStatusTabCounts,
|
||||
clearQuoteStatusTabCountsCache,
|
||||
QUOTE_TAB_STATUSES,
|
||||
QUOTE_TAB_ORDER,
|
||||
} from "./quotes-service";
|
||||
|
||||
beforeEach(() => {
|
||||
|
|
@ -1011,3 +1112,153 @@ describe("recordSendOutcome", () => {
|
|||
expect(updated.email_error.length).toBeLessThan(5000);
|
||||
});
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Quotes dashboard read model — tab filter, counts cache.
|
||||
//
|
||||
// Same surface as the orders dashboard tests: prove the server
|
||||
// narrows by tab (so the footer pagination matches the badge count)
|
||||
// and that the counts cache works.
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
describe("QUOTE_TAB_STATUSES (canonical tab → statuses mapping)", () => {
|
||||
it("absorbs cancelled + rejected under the cancelled tab (was bucketed under completed before)", async () => {
|
||||
// Regression guard for the long-standing bug where the
|
||||
// Cancelled tab badge was always empty: the old
|
||||
// getQuoteStatusCounts mapped `cancelled` and `rejected` to
|
||||
// "completed", so the page's "cancelled" tab saw zero count
|
||||
// even when 60+ rejected quotes existed.
|
||||
expect(QUOTE_TAB_STATUSES.cancelled).toEqual(
|
||||
expect.arrayContaining(["cancelled", "rejected"]),
|
||||
);
|
||||
expect(QUOTE_TAB_STATUSES.completed).not.toContain("cancelled");
|
||||
expect(QUOTE_TAB_STATUSES.completed).not.toContain("rejected");
|
||||
});
|
||||
|
||||
it("includes every status the page tab renders, with no tab having an empty status list unless it's a special tab", async () => {
|
||||
// delivery_note, all, and templates are special-cased (no
|
||||
// status filter). Every other tab must have at least one status.
|
||||
for (const tab of QUOTE_TAB_ORDER) {
|
||||
if (tab === "delivery_note" || tab === "all" || tab === "templates") {
|
||||
expect(QUOTE_TAB_STATUSES[tab]).toEqual([]);
|
||||
} else {
|
||||
expect(QUOTE_TAB_STATUSES[tab].length).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("getQuoteStatusTabCounts", () => {
|
||||
beforeEach(() => clearQuoteStatusTabCountsCache());
|
||||
|
||||
it("buckets quotes into the right tab based on status", async () => {
|
||||
await pbMock.collection("quotes").create({
|
||||
id: "q-emailed", quote_number: "Q1", status: "emailed",
|
||||
});
|
||||
await pbMock.collection("quotes").create({
|
||||
id: "q-emailed-2", quote_number: "Q2", status: "emailed",
|
||||
});
|
||||
await pbMock.collection("quotes").create({
|
||||
id: "q-approved", quote_number: "Q3", status: "approved",
|
||||
});
|
||||
await pbMock.collection("quotes").create({
|
||||
id: "q-rejected", quote_number: "Q4", status: "rejected",
|
||||
});
|
||||
await pbMock.collection("quotes").create({
|
||||
id: "q-cancelled", quote_number: "Q5", status: "cancelled",
|
||||
});
|
||||
await pbMock.collection("quotes").create({
|
||||
id: "q-new", quote_number: "Q6", status: "new",
|
||||
});
|
||||
|
||||
const counts = await getQuoteStatusTabCounts(pbMock as never);
|
||||
expect(counts.completed).toBe(3); // 2 emailed + 1 approved
|
||||
expect(counts.cancelled).toBe(2); // 1 rejected + 1 cancelled
|
||||
expect(counts.new).toBe(1);
|
||||
});
|
||||
|
||||
it("returns the same counts on a second call (cache hit)", async () => {
|
||||
await pbMock.collection("quotes").create({
|
||||
id: "q1", quote_number: "Q1", status: "emailed",
|
||||
});
|
||||
|
||||
const first = await getQuoteStatusTabCounts(pbMock as never);
|
||||
// Mutate the underlying store — the cache should still serve
|
||||
// the stale value.
|
||||
await pbMock.collection("quotes").create({
|
||||
id: "q2", quote_number: "Q2", status: "emailed",
|
||||
});
|
||||
const cached = await getQuoteStatusTabCounts(pbMock as never);
|
||||
expect(cached.completed).toBe(first.completed);
|
||||
|
||||
// Bypass the cache to see the new state.
|
||||
const fresh = await getQuoteStatusTabCounts(pbMock as never, { bypassCache: true });
|
||||
expect(fresh.completed).toBe(first.completed + 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getQuotesDashboardData — end-to-end", () => {
|
||||
beforeEach(() => clearQuoteStatusTabCountsCache());
|
||||
|
||||
it("returns tab-scoped items + global counts in a single response", async () => {
|
||||
// Set up: 1 emailed, 1 approved (both → completed tab), 1 new
|
||||
// (new tab), 1 rejected (cancelled tab).
|
||||
await pbMock.collection("quotes").create({
|
||||
id: "q1", quote_number: "Q1", status: "emailed",
|
||||
created: "2026-01-01T00:00:00Z",
|
||||
});
|
||||
await pbMock.collection("quotes").create({
|
||||
id: "q2", quote_number: "Q2", status: "approved",
|
||||
created: "2026-01-02T00:00:00Z",
|
||||
});
|
||||
await pbMock.collection("quotes").create({
|
||||
id: "q3", quote_number: "Q3", status: "new",
|
||||
created: "2026-01-03T00:00:00Z",
|
||||
});
|
||||
await pbMock.collection("quotes").create({
|
||||
id: "q4", quote_number: "Q4", status: "rejected",
|
||||
created: "2026-01-04T00:00:00Z",
|
||||
});
|
||||
|
||||
const result = await getQuotesDashboardData({
|
||||
page: 1,
|
||||
perPage: 20,
|
||||
tab: "completed",
|
||||
});
|
||||
|
||||
// Counts are global (always were) — the badge still tells the truth.
|
||||
expect(result.counts.completed).toBe(2);
|
||||
expect(result.counts.new).toBe(1);
|
||||
expect(result.counts.cancelled).toBe(1);
|
||||
|
||||
// Items are scoped to the active tab.
|
||||
expect(result.items.map((q) => q.id).sort()).toEqual(["q1", "q2"]);
|
||||
expect(result.totalItems).toBe(2);
|
||||
expect(result.totalPages).toBe(1);
|
||||
});
|
||||
|
||||
it("scopes the Cancelled tab to rejected + cancelled statuses (the long-standing bug)", async () => {
|
||||
await pbMock.collection("quotes").create({
|
||||
id: "q-rejected", quote_number: "Q1", status: "rejected",
|
||||
});
|
||||
await pbMock.collection("quotes").create({
|
||||
id: "q-cancelled", quote_number: "Q2", status: "cancelled",
|
||||
});
|
||||
await pbMock.collection("quotes").create({
|
||||
id: "q-emailed", quote_number: "Q3", status: "emailed",
|
||||
});
|
||||
|
||||
const result = await getQuotesDashboardData({
|
||||
page: 1,
|
||||
perPage: 20,
|
||||
tab: "cancelled",
|
||||
});
|
||||
|
||||
expect(result.counts.cancelled).toBe(2); // rejected + cancelled both counted here
|
||||
expect(result.counts.completed).toBe(1); // emailed stays under completed
|
||||
expect(result.items.map((q) => q.id).sort()).toEqual([
|
||||
"q-cancelled",
|
||||
"q-rejected",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -116,6 +116,402 @@ export interface AppendReplyResult {
|
|||
* against the current row state, so concurrent callers cannot collide even
|
||||
* if they read the counter at the same instant.
|
||||
*/
|
||||
/* ============================================================================
|
||||
* Quotes dashboard read model
|
||||
* ----------------------------------------------------------------------------
|
||||
* Read-side service for the `/quotes` page. Mirrors the pattern established
|
||||
* in `orders-service.ts` (server-side tab filter + slim expand + cached
|
||||
* combined counts) but adapted to the quotes data model.
|
||||
*
|
||||
* Why a new module-level section instead of extending `pb-helpers.ts`:
|
||||
* - `pb-helpers.ts` is the legacy layer called directly by Client
|
||||
* Components; `AGENTS.md` says new endpoints / features go through
|
||||
* the service layer. The previous implementation also had several
|
||||
* wire-payload issues (full quote record × 20 in the list response,
|
||||
* no field limits on the deep expand) that are easier to fix in
|
||||
* one place than to retrofit onto the helper.
|
||||
* - The old `getQuoteStatusCounts` fired 22 parallel `getList(1, 1, ...)`
|
||||
* queries, one per PB status. With no `quotes.status` index on the
|
||||
* live PB, that's 22 full scans over ~8.5k quotes = ~2.6s wall time
|
||||
* on every page load. The combined query below collapses to 1 scan
|
||||
* (~150ms measured on the live instance).
|
||||
*
|
||||
* Tab model: 10 tabs on /quotes, each mapped to one or more PB statuses.
|
||||
* The mapping is canonical here (`QUOTE_TAB_STATUSES`) — the page's
|
||||
* `tabFilters` constant now derives from this. Fixes the long-standing
|
||||
* bug where `cancelled` + `rejected` were bucketed under "completed"
|
||||
* in the counts, so the Cancelled tab badge was always empty.
|
||||
* ========================================================================== */
|
||||
|
||||
export type QuoteTabKey =
|
||||
| "new"
|
||||
| "claimed"
|
||||
| "pending_approval"
|
||||
| "completed"
|
||||
| "cancelled"
|
||||
| "delivery_note"
|
||||
| "email_failed"
|
||||
| "problem"
|
||||
| "all"
|
||||
| "templates";
|
||||
|
||||
/**
|
||||
* The /quotes page's tabs, in the operator-facing display order. The
|
||||
* left-to-right order matches the workflow lifecycle (intake → claim →
|
||||
* approval → success → recovery → failures → catch-all → meta). The
|
||||
* `all` tab sums every status except templates; `templates` is its
|
||||
* own thing because templates are scaffolding for FUTURE quotes, not
|
||||
* in-flight work.
|
||||
*/
|
||||
export const QUOTE_TAB_ORDER: QuoteTabKey[] = [
|
||||
"new",
|
||||
"claimed",
|
||||
"pending_approval",
|
||||
"completed",
|
||||
"cancelled",
|
||||
"delivery_note",
|
||||
"email_failed",
|
||||
"problem",
|
||||
"all",
|
||||
"templates",
|
||||
];
|
||||
|
||||
/**
|
||||
* Tab key → list of PB statuses that belong on that tab. An empty
|
||||
* array means the tab uses a non-status filter (delivery_note =
|
||||
* relation filter, all = no filter, templates = is_template = true).
|
||||
*
|
||||
* `cancelled` now correctly absorbs the `rejected` and `cancelled`
|
||||
* statuses — the old `getQuoteStatusCounts` mapped both to
|
||||
* "completed", which is why the Cancelled tab badge was always empty.
|
||||
* The page's `tabFilters` separately groups them under "cancelled",
|
||||
* so a quote that was rejected showed up in the list but not the
|
||||
* count. The new mapping keeps count and list in lock-step.
|
||||
*/
|
||||
export const QUOTE_TAB_STATUSES: Record<QuoteTabKey, string[]> = {
|
||||
new: ["new", "follow_up"],
|
||||
claimed: ["claimed"],
|
||||
pending_approval: ["processed", "approval_pending", "partial_approval_pending", "sending"],
|
||||
completed: ["approved", "complete", "partial_approved", "emailed"],
|
||||
cancelled: ["cancelled", "rejected"],
|
||||
// Delivery Note tab is relation-based (every quote that prices a
|
||||
// delivery note, regardless of where it sits in the lifecycle).
|
||||
// The list filter is `delivery_note != ""` rather than a status
|
||||
// IN (...) clause. The count is derived from the same filter.
|
||||
delivery_note: [],
|
||||
email_failed: ["email_failed", "mail_failed", "unmatched_bounce"],
|
||||
problem: ["problem"],
|
||||
// "All" = no status filter. Treated as the catch-all excluding
|
||||
// templates (templates have their own tab and aren't in-flight work).
|
||||
all: [],
|
||||
// Templates tab uses `is_template = true`, NOT a status filter
|
||||
// (the `status` field for templates can be anything).
|
||||
templates: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* All statuses that drive any tab's count, deduplicated. Used to
|
||||
* build the combined `status = "..." || ...` filter for the
|
||||
* status-counts query so we make ONE PB call instead of one per status.
|
||||
*/
|
||||
const QUOTE_COUNTED_STATUSES: string[] = Array.from(
|
||||
new Set(
|
||||
Object.values(QUOTE_TAB_STATUSES).flat().filter((s) => s.length > 0),
|
||||
),
|
||||
);
|
||||
|
||||
/** PB filter fragment for `status IN (countedStatuses)`. */
|
||||
function quoteCountedStatusesFilter(): string {
|
||||
return QUOTE_COUNTED_STATUSES.map((s) => `status = "${s}"`).join(" || ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Cached counts for the tabs. Same lifetime/rationale as the orders
|
||||
* tab-counts cache: counts are global, so 60s TTL survives rapid
|
||||
* tab-switching without going stale on the operator's own changes
|
||||
* (realtime / refetch with `bypassCache: true` invalidates).
|
||||
*/
|
||||
let cachedQuoteTabCounts: {
|
||||
value: Record<QuoteTabKey, number>;
|
||||
expiresAt: number;
|
||||
} | null = null;
|
||||
const QUOTE_TAB_COUNTS_CACHE_TTL_MS = 60_000;
|
||||
|
||||
/** Invalidate the quote tab-counts cache. Exported for tests. */
|
||||
export function clearQuoteStatusTabCountsCache(): void {
|
||||
cachedQuoteTabCounts = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch size for the combined counts query. Bumped from the PB default
|
||||
* of 500 — at 8.5k+ quotes the default would cost ~17 internal
|
||||
* round-trips; 5000 keeps it at 2-3.
|
||||
*/
|
||||
const QUOTE_COUNTS_BATCH_SIZE = 5000;
|
||||
|
||||
export interface QuotesDashboardData {
|
||||
items: Quote[];
|
||||
totalItems: number;
|
||||
totalPages: number;
|
||||
counts: Record<QuoteTabKey, number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read model for the internal /quotes page.
|
||||
*
|
||||
* Mirrors `getOrdersDashboardData` (orders-service.ts): a search
|
||||
* field-free path uses PB's native pagination; a tab path narrows
|
||||
* to the tab's status set so the footer pagination matches the
|
||||
* badge count. Search bypasses tab filtering (operator hunting
|
||||
* for a specific quote, regardless of status) and reduces to a
|
||||
* single page.
|
||||
*/
|
||||
export async function getQuotesDashboardData(
|
||||
params: {
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
search?: string;
|
||||
sort?: string;
|
||||
tab?: QuoteTabKey;
|
||||
bypassCountsCache?: boolean;
|
||||
} = {},
|
||||
): Promise<QuotesDashboardData> {
|
||||
const pb = await getAdminPocketBase();
|
||||
pb.autoCancellation(false);
|
||||
const [list, counts] = await Promise.all([
|
||||
listQuotesForDashboard(params, pb),
|
||||
getQuoteStatusTabCounts(pb, { bypassCache: params.bypassCountsCache }),
|
||||
]);
|
||||
return { ...list, counts };
|
||||
}
|
||||
|
||||
/**
|
||||
* Slim field list for the list-page response. The quotes collection
|
||||
* has 45+ fields including unbounded `email_body` and `email_message`
|
||||
* — without limiting, 20 quotes × full records = multi-MB JSON even
|
||||
* when most emails are empty (the wasted bandwidth and parse cost
|
||||
* shows up in page-load time). The list view reads:
|
||||
* quote.* : id, quote_number, status, claimed_by, is_template,
|
||||
* follow_up_due, last_reply_at, customer (rel id),
|
||||
* company (rel id), created, updated
|
||||
* expand.customer : customer (name), rep (rel id)
|
||||
* expand.customer.rep : id, name, email
|
||||
* expand.company : company (name)
|
||||
*
|
||||
* `customer.company` is intentionally NOT expanded — the page only
|
||||
* reads `expand.company.company` (the direct relation from quote),
|
||||
* not `expand.customer.expand.company`. Dropping the 3-level expand
|
||||
* saves one IN query per page load.
|
||||
*/
|
||||
const QUOTE_LIST_FIELDS = [
|
||||
// main record
|
||||
"id",
|
||||
"quote_number",
|
||||
"status",
|
||||
"claimed_by",
|
||||
"is_template",
|
||||
"follow_up_due",
|
||||
"last_reply_at",
|
||||
"customer",
|
||||
"company",
|
||||
"created",
|
||||
"updated",
|
||||
// expanded company — just the name
|
||||
"company.id",
|
||||
"company.company",
|
||||
// expanded customer — name + rep relation
|
||||
"customer.id",
|
||||
"customer.customer",
|
||||
"customer.rep",
|
||||
// expanded customer.rep (users) — id, name, email
|
||||
"customer.rep.id",
|
||||
"customer.rep.name",
|
||||
"customer.rep.email",
|
||||
].join(",");
|
||||
|
||||
const QUOTE_LIST_EXPAND = "customer,customer.rep,company";
|
||||
|
||||
async function listQuotesForDashboard(
|
||||
params: {
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
search?: string;
|
||||
sort?: string;
|
||||
tab?: QuoteTabKey;
|
||||
},
|
||||
pbArg?: Awaited<ReturnType<typeof getAdminPocketBase>>,
|
||||
): Promise<Omit<QuotesDashboardData, "counts">> {
|
||||
const pb = pbArg || (await getAdminPocketBase());
|
||||
pb.autoCancellation(false);
|
||||
const page = Math.max(1, params.page || 1);
|
||||
const perPage = Math.min(100, Math.max(1, params.perPage || 20));
|
||||
const search = (params.search || "").trim();
|
||||
const sort = params.sort || "-created";
|
||||
const tab = params.tab;
|
||||
|
||||
// Build the tab's status filter clause. Three classes of tab:
|
||||
// - delivery_note: relation filter (not status)
|
||||
// - templates: is_template = true
|
||||
// - all: no status filter
|
||||
// - any other: status IN (TAB_STATUSES[tab])
|
||||
const tabStatuses = tab ? QUOTE_TAB_STATUSES[tab] : [];
|
||||
let tabFilter = "";
|
||||
if (tab === "delivery_note") {
|
||||
tabFilter = `delivery_note != ""`;
|
||||
} else if (tab === "templates") {
|
||||
tabFilter = `is_template = true`;
|
||||
} else if (tab === "all") {
|
||||
tabFilter = `is_template != true`;
|
||||
} else if (tabStatuses.length > 0) {
|
||||
tabFilter = `(${tabStatuses.map((s) => `status = "${s}"`).join(" || ")})`;
|
||||
}
|
||||
|
||||
// Search + tab combines. Search is a free-text OR across the
|
||||
// quote_number / quote_reference / rfq_number fields. When BOTH
|
||||
// are active, the PB filter chains them with AND.
|
||||
let searchFilter = "";
|
||||
if (search) {
|
||||
searchFilter = `(quote_number ~ "${search}" || quote_reference ~ "${search}" || rfq_number ~ "${search}")`;
|
||||
}
|
||||
// For non-special tabs and "all", we also exclude templates so
|
||||
// they don't pollute the workflow view (the Templates tab shows
|
||||
// them explicitly). For the "templates" tab itself, this is
|
||||
// already handled by `is_template = true`.
|
||||
const excludeTemplate =
|
||||
tab !== "templates" ? `is_template != true` : "";
|
||||
const clauses = [excludeTemplate, tabFilter, searchFilter]
|
||||
.filter((c) => c.length > 0)
|
||||
.join(" && ");
|
||||
|
||||
// For tab + no-search: PB paginates the filtered set, so the
|
||||
// footer total reflects the tab's matching count (not the global
|
||||
// ~8.5k). For search (with or without tab): single page, total
|
||||
// computed from the filtered items.
|
||||
const result = await pb
|
||||
.collection(QUOTES_COLLECTION)
|
||||
.getList<Quote>(page, perPage, {
|
||||
filter: clauses,
|
||||
sort,
|
||||
expand: QUOTE_LIST_EXPAND,
|
||||
fields: QUOTE_LIST_FIELDS,
|
||||
});
|
||||
|
||||
if (search) {
|
||||
return {
|
||||
items: result.items,
|
||||
totalItems: result.items.length,
|
||||
totalPages: 1,
|
||||
};
|
||||
}
|
||||
return {
|
||||
items: result.items,
|
||||
totalItems: result.totalItems,
|
||||
totalPages: result.totalPages,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts per tab. Replaces the old `getQuoteStatusCounts` (22
|
||||
* parallel `getList(1, 1, ...)` queries). Strategy:
|
||||
*
|
||||
* 1. One combined `status = "x" || status = "y" || ...` query,
|
||||
* `fields: "status"`, batch 5000. Loads ~all quotes that
|
||||
* contribute to any tab's count. ~150ms measured on the live
|
||||
* PB (vs ~2.6s for 22 separate queries without an index).
|
||||
*
|
||||
* 2. Bucket in memory. The dataset is small — only the `status`
|
||||
* field per row — so the dedupe-by-tab is cheap.
|
||||
*
|
||||
* 3. Special-case the `delivery_note` and `templates` tabs with
|
||||
* their own targeted queries (the status-based filter doesn't
|
||||
* apply to either). These two are tiny in volume so the cost
|
||||
* is negligible.
|
||||
*
|
||||
* Cached for 60s; the realtime / refetch path passes `bypassCache:
|
||||
* true` so a change the operator just made updates the badge
|
||||
* immediately.
|
||||
*/
|
||||
export async function getQuoteStatusTabCounts(
|
||||
pbArg?: Awaited<ReturnType<typeof getAdminPocketBase>>,
|
||||
options: { bypassCache?: boolean } = {},
|
||||
): Promise<Record<QuoteTabKey, number>> {
|
||||
const pb = pbArg || (await getAdminPocketBase());
|
||||
pb.autoCancellation(false);
|
||||
|
||||
if (
|
||||
!options.bypassCache &&
|
||||
cachedQuoteTabCounts &&
|
||||
cachedQuoteTabCounts.expiresAt > Date.now()
|
||||
) {
|
||||
return cachedQuoteTabCounts.value;
|
||||
}
|
||||
|
||||
// Combined status query — ONE PB call, ONE filter scan, instead
|
||||
// of 22 separate per-status scans.
|
||||
const allStatusRows = await pb
|
||||
.collection(QUOTES_COLLECTION)
|
||||
.getFullList<{ status?: string }>({
|
||||
filter: quoteCountedStatusesFilter(),
|
||||
fields: "status",
|
||||
batch: QUOTE_COUNTS_BATCH_SIZE,
|
||||
});
|
||||
|
||||
const counts: Record<QuoteTabKey, number> = {
|
||||
new: 0,
|
||||
claimed: 0,
|
||||
pending_approval: 0,
|
||||
completed: 0,
|
||||
cancelled: 0,
|
||||
delivery_note: 0,
|
||||
email_failed: 0,
|
||||
problem: 0,
|
||||
all: 0,
|
||||
templates: 0,
|
||||
};
|
||||
let allTotal = 0;
|
||||
|
||||
for (const row of allStatusRows) {
|
||||
const status = row.status;
|
||||
if (!status) continue;
|
||||
allTotal++;
|
||||
for (const tab of QUOTE_TAB_ORDER) {
|
||||
const tabStatuses = QUOTE_TAB_STATUSES[tab];
|
||||
if (tabStatuses.length === 0) continue; // special tab, handled below
|
||||
if (tabStatuses.includes(status)) {
|
||||
counts[tab]++;
|
||||
break; // each status lands in exactly one workflow tab
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Special tabs (relation-based, not status-based).
|
||||
// Templates: `is_template = true`. Excluded from the combined
|
||||
// query above so the workflow count isn't polluted.
|
||||
const templatesResp = await pb
|
||||
.collection(QUOTES_COLLECTION)
|
||||
.getList<unknown>(1, 1, { filter: "is_template = true" });
|
||||
counts.templates = templatesResp.totalItems;
|
||||
|
||||
// Delivery note: `delivery_note != ""`. Each quote with a
|
||||
// delivery note attached. Status-agnostic — DN-backed quotes
|
||||
// can be claimed, processed, emailed, etc.
|
||||
const dnResp = await pb
|
||||
.collection(QUOTES_COLLECTION)
|
||||
.getList<unknown>(1, 1, { filter: `delivery_note != ""` });
|
||||
counts.delivery_note = dnResp.totalItems;
|
||||
|
||||
// "All" is the catch-all EXCLUDING templates. This matches the
|
||||
// page's previous behaviour (templates aren't in-flight work,
|
||||
// so they don't contribute to the workflow rollup).
|
||||
counts.all = allTotal;
|
||||
|
||||
cachedQuoteTabCounts = {
|
||||
value: counts,
|
||||
expiresAt: Date.now() + QUOTE_TAB_COUNTS_CACHE_TTL_MS,
|
||||
};
|
||||
return counts;
|
||||
}
|
||||
|
||||
export async function generateQuoteNumberServer(): Promise<string> {
|
||||
const pb = await getAdminPocketBase();
|
||||
const now = new Date();
|
||||
|
|
|
|||
Loading…
Reference in a new issue