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) => (
+
+ {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."}
+