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 */}
+
+
+
+
+
+
+ setCategory(v ?? ALL)}>
+
+
+
+
+ All categories
+ {distinctCategories.map((c) => (
+
+ {c}
+
+ ))}
+
+
+ setBrand(v ?? ALL)}>
+
+
+
+
+ All brands
+ {distinctBrands.map((b) => (
+
+ {b}
+
+ ))}
+
+
+ {(search || category !== ALL || brand !== ALL) && (
+
+
+ Clear
+
+ )}
+
+
+
+
(
+
+
+
+
+ {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";
/**