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:
parent
81c9f64158
commit
b75873a51b
6 changed files with 606 additions and 7 deletions
|
|
@ -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",
|
||||
|
|
|
|||
63
scripts/migrations/2026-06-30-add-price-list-source-file.ts
Normal file
63
scripts/migrations/2026-06-30-add-price-list-source-file.ts
Normal 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);
|
||||
});
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
433
src/components/pricelist-catalogue.tsx
Normal file
433
src/components/pricelist-catalogue.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Reference in a new issue