From b75873a51b10f8cf64e0a0ca767af823611fe25d Mon Sep 17 00:00:00 2001 From: SJCoetz Date: Tue, 30 Jun 2026 11:49:48 +0200 Subject: [PATCH] feat(pricelist): Catalogue tab with search + category/brand filters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a second tab on /pricelist that shows the structured `price_list` collection (AI-extracted line items) instead of the raw PDF uploads. Operators can now: - Search across SKU, description, and brand - Filter by category (twist_drill, hss_cutter, countersink, ...) - Filter by brand (JEI, TurboSteel, TurboTough, ...) - See cost vs selling price side-by-side in ZAR - Click a row's 'Source' link to jump back to the raw PDF The distinct filter values are pulled from a one-shot getList(1, 500) on mount, with hardcoded fallbacks (FALLBACK_CATEGORIES / FALLBACK_BRANDS) so the dropdowns are useful even before any extractions have run. Layout: Tabs (Raw Uploads | Catalogue) wrap both views; the header + page chrome stay shared. The DeleteDialog stays outside the Tabs since it's a modal. Also ships an idempotent migration to add `source_file` (text) to `price_list` — used by the n8n workflow to track which raw PDF each extracted row came from, and by the verify-after-POST step the workflow uses to decide whether to flip `raw_price_list.status` to Completed vs Failed. Price type is in PriceListRow (shared subset) and PriceList (full record). Supplier names are resolved client-side via getAllSuppliers() since the live schema has supplier as a text field rather than a relation. Tests: 229/230 (same pre-existing failure, unrelated). TypeScript clean. --- package.json | 1 + .../2026-06-30-add-price-list-source-file.ts | 63 +++ src/app/(app)/pricelist/page.tsx | 30 +- src/components/pricelist-catalogue.tsx | 433 ++++++++++++++++++ src/lib/pb-helpers.ts | 41 +- src/lib/types.ts | 45 ++ 6 files changed, 606 insertions(+), 7 deletions(-) create mode 100644 scripts/migrations/2026-06-30-add-price-list-source-file.ts create mode 100644 src/components/pricelist-catalogue.tsx diff --git a/package.json b/package.json index 9951dd2..f3cfbc0 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "pb:migrate:convert-customer-fields": "npx tsx scripts/migrations/2026-06-28-convert-customer-fields.ts", "pb:migrate:raw-price-list": "npx tsx scripts/migrations/2026-06-30-add-raw-price-list.ts", "pb:migrate:pricelist-notes": "npx tsx scripts/migrations/2026-06-30-add-pricelist-notes.ts", + "pb:migrate:price-list-source-file": "npx tsx scripts/migrations/2026-06-30-add-price-list-source-file.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", diff --git a/scripts/migrations/2026-06-30-add-price-list-source-file.ts b/scripts/migrations/2026-06-30-add-price-list-source-file.ts new file mode 100644 index 0000000..58bfb50 --- /dev/null +++ b/scripts/migrations/2026-06-30-add-price-list-source-file.ts @@ -0,0 +1,63 @@ +/** + * Migration — add `source_file` (text) to `price_list`. + * + * Stores the `raw_price_list.id` that an extracted line item came + * from. Used by the n8n workflow's verification step: + * + * // After the POST loop + * const actual = await pb.collection('price_list').getList(1, expected, { + * filter: `source_file = "${rawId}"`, + * }); + * if (actual.items.length === expected) { + * // safe to PATCH raw_price_list.status = "Completed" + * } else { + * // PATCH Failed with "X/Y items created" + * } + * + * Also useful for the agent at quote time: "give me the full price + * list for the file this row was sourced from." + * + * Idempotent: re-runs are safe. + * + * Run with: + * npm run pb:migrate:price-list-source-file + */ +import PocketBase from "pocketbase"; + +const PB_URL = process.env.NEXT_PUBLIC_POCKETBASE_URL || "http://127.0.0.1:8090"; +const ADMIN_EMAIL = process.env.PB_ADMIN_EMAIL; +const ADMIN_PASSWORD = process.env.PB_ADMIN_PASSWORD; + +if (!ADMIN_EMAIL || !ADMIN_PASSWORD) { + throw new Error("PB_ADMIN_EMAIL and PB_ADMIN_PASSWORD are required."); +} + +async function main() { + const pb = new PocketBase(PB_URL); + await pb.collection("_superusers").authWithPassword(ADMIN_EMAIL, ADMIN_PASSWORD); + + const cols = await pb.collections.getFullList(); + const priceList = cols.find((c) => c.name === "price_list"); + if (!priceList) { + throw new Error("price_list collection not found. Run the setup script first."); + } + + if (priceList.fields.some((f) => f.name === "source_file")) { + console.log("price_list.source_file already exists, skipping."); + return; + } + + await pb.collections.update(priceList.id, { + fields: [ + ...priceList.fields, + { name: "source_file", type: "text", required: false, options: { max: 0 } }, + ], + }); + console.log("Added price_list.source_file"); + console.log("source_file migration complete."); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/src/app/(app)/pricelist/page.tsx b/src/app/(app)/pricelist/page.tsx index 65d7f17..3414659 100644 --- a/src/app/(app)/pricelist/page.tsx +++ b/src/app/(app)/pricelist/page.tsx @@ -1,21 +1,24 @@ "use client"; /** - * Pricelist — upload raw supplier price-list files (PDF / CSV / text / image) - * and review the queue of what's been uploaded. + * Pricelist — two-tab page on the /pricelist route. * - * UI layout: + * Tab 1 ("Raw Uploads") — this file: * 1. Header * 2. Upload card — drag-drop zone (the "bounding box") plus an explicit * "Choose file" button, optional supplier relation picker + status + - * log_summary inputs, Upload button. Accepts the same mime set as the - * PB field: PDF, CSV, plain text, common images, plus a generic + * notes inputs, Upload button. Accepts the same mime set as the PB + * field: PDF, CSV, plain text, common images, plus a generic * octet-stream fallback so odd attachments don't get rejected * client-side before reaching the server-side allowlist. * 3. DataTable — filename (with file-type icon + size), supplier name - * (via expand), status badge, log summary preview, uploaded_at, and a + * (via expand), status badge, notes preview, uploaded_at, and a * delete action per row. * + * Tab 2 ("Catalogue") — `src/components/pricelist-catalogue.tsx`: + * The structured `price_list` collection — AI-extracted line items + * with search, category + brand filters, and a DataTable. + * * All data lives in the `raw_price_list` PocketBase collection. Live schema * (discovered via admin API on 2026-06-30): * - supplier_file: the file @@ -49,7 +52,9 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; import { SupplierSearch } from "@/components/ui/supplier-search"; +import { PricelistCatalogue } from "@/components/pricelist-catalogue"; import { UploadCloud, FileText, @@ -441,6 +446,13 @@ export default function PricelistPage() {

+ + + Raw Uploads + Catalogue + + + {/* ── Upload card ───────────────────────────────────────── */} @@ -643,6 +655,12 @@ export default function PricelistPage() { ); }} /> + + + + + + s.id === supplierField); + if (byId) return byId.suppliername; + // Otherwise treat it as a denormalized name and return as-is. + return supplierField; +} + +export function PricelistCatalogue() { + // ── Filter / paging state ───────────────────────────────────── + const [search, setSearch] = useState(""); + const [debouncedSearch, setDebouncedSearch] = useState(""); + const [category, setCategory] = useState(ALL); + const [brand, setBrand] = useState(ALL); + const [page, setPage] = useState(1); + const [rows, setRows] = useState([]); + const [totalPages, setTotalPages] = useState(1); + const [totalItems, setTotalItems] = useState(0); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + + // ── Supplier lookup table (for rendering names) ─────────────── + const [suppliers, setSuppliers] = useState([]); + + // ── Distinct values for the filter dropdowns ─────────────────── + const [distinctCategories, setDistinctCategories] = useState([]); + const [distinctBrands, setDistinctBrands] = useState([]); + + // Debounce search input — same pattern DataTable uses internally + useEffect(() => { + const t = setTimeout(() => setDebouncedSearch(search), 300); + return () => clearTimeout(t); + }, [search]); + + // Load suppliers + discover the distinct filter values on mount. + // We do a single big getList (first 500 rows) and extract. For the + // WFDASH fleet scale (hundreds to low thousands) this is fine. If + // it ever gets slow, swap to a per-column `getList` with no expand + // and a perPage=1 — totalItems gives us the count. + useEffect(() => { + let cancelled = false; + (async () => { + try { + const [supList, all] = await Promise.all([ + getAllSuppliers(), + getPriceLists(1, 500, "", {}, "-updated"), + ]); + if (cancelled) return; + setSuppliers(supList); + const cats = new Set(); + const brands = new Set(); + for (const r of all.items) { + if (r.category) cats.add(r.category); + if (r.brand) brands.add(r.brand); + } + setDistinctCategories( + cats.size > 0 ? [...cats].sort() : FALLBACK_CATEGORIES, + ); + setDistinctBrands( + brands.size > 0 ? [...brands].sort() : FALLBACK_BRANDS, + ); + } catch { + // Non-fatal: fall back to the hardcoded lists so the filter + // dropdowns are still useful. + setDistinctCategories(FALLBACK_CATEGORIES); + setDistinctBrands(FALLBACK_BRANDS); + } + })(); + return () => { + cancelled = true; + }; + }, []); + + const fetchRows = useCallback(async () => { + setLoading(true); + setError(""); + try { + const result = await getPriceLists(page, 50, debouncedSearch, { + category: category === ALL ? undefined : category, + brand: brand === ALL ? undefined : brand, + }); + setRows(result.items); + setTotalPages(result.totalPages); + setTotalItems(result.totalItems); + } catch (err: unknown) { + const message = + err instanceof Error ? err.message : "Failed to load price_list"; + setError(message); + } finally { + setLoading(false); + } + }, [page, debouncedSearch, category, brand]); + + useEffect(() => { + fetchRows(); + }, [fetchRows]); + + // Reset to page 1 when any filter changes (avoid landing on an + // empty page because the previous page no longer exists). + useEffect(() => { + setPage(1); + }, [debouncedSearch, category, brand]); + + // Live updates: re-fetch when a new row lands in price_list (another + // tab, a finished n8n workflow, etc). + useEffect(() => { + const pb = getPocketBase(); + pb.collection("price_list").subscribe("*", () => { + fetchRows(); + }); + return () => { + pb.collection("price_list").unsubscribe("*"); + }; + }, [fetchRows]); + + function clearFilters() { + setSearch(""); + setCategory(ALL); + setBrand(ALL); + } + + // ── Table columns ───────────────────────────────────────────── + const columns = useMemo( + () => [ + { + key: "sku", + label: "SKU", + render: (item: PriceList) => ( + {item.sku || "—"} + ), + }, + { + key: "description", + label: "Description", + render: (item: PriceList) => ( +

+ {item.description || "—"} +

+ ), + }, + { + key: "category", + label: "Category", + render: (item: PriceList) => + item.category ? ( + + {item.category} + + ) : ( + — + ), + }, + { + key: "brand", + label: "Brand", + render: (item: PriceList) => + item.brand ? ( + {item.brand} + ) : ( + — + ), + }, + { + key: "supplier", + label: "Supplier", + render: (item: PriceList) => ( + + {resolveSupplierName(item.supplier, suppliers)} + + ), + }, + { + key: "cost_price", + label: "Cost", + className: "text-right", + render: (item: PriceList) => ( + + {formatZar(item.cost_price)} + + ), + }, + { + key: "selling_price", + label: "Selling", + className: "text-right", + render: (item: PriceList) => ( + + {formatZar(item.selling_price)} + + ), + }, + { + key: "markup", + label: "Markup", + className: "text-right", + render: (item: PriceList) => + item.markup > 0 ? ( + {item.markup}% + ) : ( + — + ), + }, + { + key: "updated", + label: "Updated", + render: (item: PriceList) => ( + + {formatDate(item.updated)} + + ), + }, + { + key: "source", + label: "Source", + render: (item: PriceList) => + item.source_file ? ( + + + PDF + + ) : ( + — + ), + }, + ], + [suppliers], + ); + + return ( +
+
+
+

Structured catalogue

+

+ {totalItems > 0 + ? `${totalItems} line item${totalItems === 1 ? "" : "s"} from AI-extracted price lists` + : "No extracted items yet. Upload a raw price list in the other tab to get started."} +

+
+
+ + {/* Filter row */} +
+
+ + +
+
+ + + {(search || category !== ALL || brand !== ALL) && ( + + )} +
+
+ + ( +
+
+
+

+ {item.sku || "—"} +

+

+ {item.description || "—"} +

+
+ {item.category && ( + + {item.category} + + )} +
+
+ {item.brand && {item.brand}} + · + {resolveSupplierName(item.supplier, suppliers)} +
+
+
+

Cost

+

{formatZar(item.cost_price)}

+
+
+

Selling

+

+ {formatZar(item.selling_price)} +

+
+ {item.markup > 0 && ( +
+

Markup

+

{item.markup}%

+
+ )} +
+
+ )} + /> + + {totalItems > 50 && ( +

+ Showing page {page} of {totalPages}. Use filters or search to + narrow the list. +

+ )} +
+ ); +} diff --git a/src/lib/pb-helpers.ts b/src/lib/pb-helpers.ts index 6a6f84f..d9ea784 100644 --- a/src/lib/pb-helpers.ts +++ b/src/lib/pb-helpers.ts @@ -1,5 +1,5 @@ import getPocketBase from "./pocketbase"; -import type { Customer, Company, Supplier, Quote, QuoteItem, QuoteAttachment, Order, OrderItem, PurchaseOrder, CollectionPool, DeliveryNote, Repair, RawPriceList } from "./types"; +import type { Customer, Company, Supplier, Quote, QuoteItem, QuoteAttachment, Order, OrderItem, PurchaseOrder, CollectionPool, DeliveryNote, Repair, RawPriceList, PriceList } from "./types"; import { TAB_GROUP_ITEM_STATUSES, itemStatusToTabGroup, type TabGroup } from "./item-status-ui"; import { scoreCandidate, @@ -1349,6 +1349,45 @@ export async function deleteRawPriceList(id: string) { 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 + * `sku`, `description`, and `brand` (case-insensitive `~`). Optional + * `category` and `brand` filters narrow further. PB can't substring- + * match the `specifications` JSON (it's stored as a blob) so synonym + * search isn't supported here — that goes through the LLM agent at + * quote time. + * + * 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[] = []; + if (search) { + const q = search.replace(/"/g, '\\"'); + clauses.push(`(sku ~ "${q}" || description ~ "${q}" || brand ~ "${q}")`); + } + 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(page, perPage, { + filter, + sort, + }); +} + export async function createRepair(data: { customer?: string; company?: string; diff --git a/src/lib/types.ts b/src/lib/types.ts index c8b7c5f..3e19472 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -481,6 +481,51 @@ export interface DeliveryNoteItem extends BaseRecord { }; } +export type PriceListRow = { + sku: string; + description: string; + category: string; + subcategory: string; + brand: string; + /** + * Free-text supplier name as stored on the price_list row. The + * live schema has this as a text field rather than a relation + * to `suppliers`, so it's the human-readable name (or PB id, for + * pre-migration rows). The Catalogue tab does a client-side + * lookup against `suppliers` to render the canonical name. + */ + supplier: string; + cost_price: number; + selling_price: number; + /** Markup percentage as stored (e.g. 25 = 25%). 0 if unset. */ + markup: number; + unit: string; + stock_qty: number; + /** Rich JSON: per-category attributes + synonyms for fuzzy match. */ + specifications: { + diameter_mm?: number; + shank_mm?: number; + length_mm?: number; + material?: string; + synonyms?: string[]; + [key: string]: unknown; + }; + /** `raw_price_list.id` this row was extracted from. Empty for manually-created rows. */ + source_file: string; +}; + +/** + * One row in the `price_list` collection. The structured, + * post-AI-extraction catalogue that the quote-filler agent searches + * when filling prices. + * + * Schema (live): sku, description, category, subcategory, brand, + * supplier (text), cost_price, selling_price, markup, unit, + * stock_qty, specifications (json), source_file (text → raw_price_list.id). + * Indexed on sku, category, subcategory, brand, supplier. + */ +export interface PriceList extends BaseRecord, PriceListRow {} + export type RawPriceListStatus = "Pending" | "Processing" | "Completed" | "Failed"; /**