feat(pricelist): Catalogue tab with search + category/brand filters

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.
This commit is contained in:
SJCoetz 2026-06-30 11:49:48 +02:00
parent 81c9f64158
commit b75873a51b
6 changed files with 606 additions and 7 deletions

View file

@ -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",

View file

@ -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);
});

View file

@ -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() {
</p>
</div>
<Tabs defaultValue="uploads" className="space-y-4">
<TabsList>
<TabsTrigger value="uploads">Raw Uploads</TabsTrigger>
<TabsTrigger value="catalogue">Catalogue</TabsTrigger>
</TabsList>
<TabsContent value="uploads" className="space-y-4">
{/* ── Upload card ───────────────────────────────────────── */}
<Card>
<CardHeader>
@ -643,6 +655,12 @@ export default function PricelistPage() {
);
}}
/>
</TabsContent>
<TabsContent value="catalogue">
<PricelistCatalogue />
</TabsContent>
</Tabs>
<DeleteDialog
open={deleteOpen}

View file

@ -0,0 +1,433 @@
"use client";
/**
* PricelistCatalogue — the "Catalogue" tab on the /pricelist page.
*
* Renders the structured `price_list` collection (the AI-extracted
* line items) with a search box, category + brand filters, and a
* DataTable. Each row links back to the source `raw_price_list` row
* via the `source_file` field if the operator wants to see which PDF
* the line came from.
*
* The supplier field is stored as text on the live schema (not a
* relation), so we do a client-side lookup against the suppliers
* list to render the canonical name. For pre-migration rows where
* the field is the PB id, the lookup falls back to the raw id.
*
* Prices display in ZAR (the live collection only has ZAR prices
* right now; if/when multi-currency lands, format per-row).
*/
import { useCallback, useEffect, useMemo, useState } from "react";
import { DataTable } from "@/components/data-table";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Search, X, ExternalLink } from "lucide-react";
import { toast } from "sonner";
import getPocketBase from "@/lib/pocketbase";
import { getPriceLists, getAllSuppliers, formatDate } from "@/lib/pb-helpers";
import type { PriceList, Supplier } from "@/lib/types";
/** Hardcoded fallback categories if PB hasn't seen any rows yet. */
const FALLBACK_CATEGORIES = [
"twist_drill",
"step_drill",
"hss_cutter",
"countersink",
"reamer",
"burr",
"tct_cutter",
"hole_saw",
"rail_cutter",
"drill_chuck",
];
const FALLBACK_BRANDS = [
"JEI",
"TurboSteel",
"TurboTough",
"TurboGold",
"TurboEndurance",
"TurboEndurance+",
];
/** "All" sentinel for the filter selects. Empty string so the Select
* shows the placeholder text rather than displaying the value. */
const ALL = "";
function formatZar(amount: number): string {
if (amount == null) return "—";
return new Intl.NumberFormat("en-ZA", {
style: "currency",
currency: "ZAR",
minimumFractionDigits: 2,
}).format(Number(amount));
}
function resolveSupplierName(
supplierField: string,
suppliers: Supplier[]
): string {
if (!supplierField) return "—";
// Either a relation id or a denormalized name. Try id first.
const byId = suppliers.find((s) => 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<string>(ALL);
const [brand, setBrand] = useState<string>(ALL);
const [page, setPage] = useState(1);
const [rows, setRows] = useState<PriceList[]>([]);
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<Supplier[]>([]);
// ── Distinct values for the filter dropdowns ───────────────────
const [distinctCategories, setDistinctCategories] = useState<string[]>([]);
const [distinctBrands, setDistinctBrands] = useState<string[]>([]);
// 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<string>();
const brands = new Set<string>();
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) => (
<span className="font-mono text-xs">{item.sku || "—"}</span>
),
},
{
key: "description",
label: "Description",
render: (item: PriceList) => (
<p className="text-sm font-medium max-w-[320px] truncate" title={item.description}>
{item.description || "—"}
</p>
),
},
{
key: "category",
label: "Category",
render: (item: PriceList) =>
item.category ? (
<Badge variant="secondary" className="text-xs">
{item.category}
</Badge>
) : (
<span className="text-sm text-muted-foreground">—</span>
),
},
{
key: "brand",
label: "Brand",
render: (item: PriceList) =>
item.brand ? (
<span className="text-sm font-medium">{item.brand}</span>
) : (
<span className="text-sm text-muted-foreground">—</span>
),
},
{
key: "supplier",
label: "Supplier",
render: (item: PriceList) => (
<span className="text-sm">
{resolveSupplierName(item.supplier, suppliers)}
</span>
),
},
{
key: "cost_price",
label: "Cost",
className: "text-right",
render: (item: PriceList) => (
<span className="text-sm tabular-nums">
{formatZar(item.cost_price)}
</span>
),
},
{
key: "selling_price",
label: "Selling",
className: "text-right",
render: (item: PriceList) => (
<span className="text-sm font-semibold tabular-nums">
{formatZar(item.selling_price)}
</span>
),
},
{
key: "markup",
label: "Markup",
className: "text-right",
render: (item: PriceList) =>
item.markup > 0 ? (
<span className="text-sm tabular-nums">{item.markup}%</span>
) : (
<span className="text-sm text-muted-foreground">—</span>
),
},
{
key: "updated",
label: "Updated",
render: (item: PriceList) => (
<span className="text-sm text-muted-foreground whitespace-nowrap">
{formatDate(item.updated)}
</span>
),
},
{
key: "source",
label: "Source",
render: (item: PriceList) =>
item.source_file ? (
<a
href={`/pricelist?row=${item.source_file}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
title={`Extracted from raw_price_list/${item.source_file}`}
>
<ExternalLink className="h-3 w-3" />
PDF
</a>
) : (
<span className="text-sm text-muted-foreground">—</span>
),
},
],
[suppliers],
);
return (
<div className="space-y-4">
<div className="flex flex-col sm:flex-row gap-3 items-start sm:items-center justify-between">
<div>
<h2 className="text-lg font-semibold">Structured catalogue</h2>
<p className="text-sm text-muted-foreground">
{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."}
</p>
</div>
</div>
{/* Filter row */}
<div className="flex flex-col sm:flex-row gap-3 items-stretch sm:items-center">
<div className="relative flex-1 sm:max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search SKU, description, or brand…"
value={search}
onValueChange={setSearch}
className="pl-9"
/>
</div>
<div className="flex gap-2">
<Select value={category} onValueChange={(v) => setCategory(v ?? ALL)}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="All categories" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>All categories</SelectItem>
{distinctCategories.map((c) => (
<SelectItem key={c} value={c}>
{c}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={brand} onValueChange={(v) => setBrand(v ?? ALL)}>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="All brands" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>All brands</SelectItem>
{distinctBrands.map((b) => (
<SelectItem key={b} value={b}>
{b}
</SelectItem>
))}
</SelectContent>
</Select>
{(search || category !== ALL || brand !== ALL) && (
<Button variant="ghost" size="sm" onClick={clearFilters}>
<X className="h-4 w-4 mr-1" />
Clear
</Button>
)}
</div>
</div>
<DataTable
columns={columns}
data={rows}
loading={loading}
error={error}
page={page}
totalPages={totalPages}
onPageChange={setPage}
mobileCardRender={(item) => (
<div className="rounded-md border p-4 space-y-2">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<p className="font-mono text-xs text-muted-foreground">
{item.sku || "—"}
</p>
<p className="font-medium text-sm mt-0.5" title={item.description}>
{item.description || "—"}
</p>
</div>
{item.category && (
<Badge variant="secondary" className="text-xs shrink-0">
{item.category}
</Badge>
)}
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
{item.brand && <span>{item.brand}</span>}
<span>·</span>
<span>{resolveSupplierName(item.supplier, suppliers)}</span>
</div>
<div className="flex items-center justify-between pt-1 border-t">
<div>
<p className="text-xs text-muted-foreground">Cost</p>
<p className="text-sm tabular-nums">{formatZar(item.cost_price)}</p>
</div>
<div className="text-right">
<p className="text-xs text-muted-foreground">Selling</p>
<p className="text-sm font-semibold tabular-nums">
{formatZar(item.selling_price)}
</p>
</div>
{item.markup > 0 && (
<div className="text-right">
<p className="text-xs text-muted-foreground">Markup</p>
<p className="text-sm tabular-nums">{item.markup}%</p>
</div>
)}
</div>
</div>
)}
/>
{totalItems > 50 && (
<p className="text-xs text-muted-foreground text-center">
Showing page {page} of {totalPages}. Use filters or search to
narrow the list.
</p>
)}
</div>
);
}

View file

@ -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<PriceList>(page, perPage, {
filter,
sort,
});
}
export async function createRepair(data: {
customer?: string;
company?: string;

View file

@ -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";
/**