From 7f7025803715d890d4c18af2e448fc66cd50daa2 Mon Sep 17 00:00:00 2001 From: SJCoetz Date: Tue, 30 Jun 2026 09:14:36 +0200 Subject: [PATCH] feat(management): new Pricelist page with drag-drop upload + queue table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds /pricelist under the Management section. Operators drag a supplier price-list file (PDF / CSV / text / image) into the upload zone — or use the explicit Choose file button — and it lands in PocketBase as an attachment on the raw_price_list collection. The table below shows the queue: file, supplier (relation to suppliers), status badge, log summary preview, upload date, and a delete action per row. Layout matches the rest of the app: Card for the upload zone, DataTable for the queue, DeleteDialog for the destructive action. Live updates via the PB subscribe channel so other tabs / the n8n ingest workflow show new rows without a manual refresh. PB schema is sourced from docs/pocketbase/raw_price_list.collection.json. The migration script is idempotent: it creates the collection from the schema file on a fresh deploy, and on a pre-existing one (the live PB already had it, with PB's default null/superuser-only rules) it relaxes the auth rules to @request.auth.id != '' so any logged-in user can CRUD — matching every other collection in the app. Field names match the live PB collection (discovered via admin API): - supplier_file (the file) - supplier_name (relation to suppliers) - status (select: Pending / Processing / Completed / Failed) - log_summary (free text) --- .../pocketbase/raw_price_list.collection.json | 118 ++++ package.json | 1 + .../2026-06-30-add-raw-price-list.ts | 81 +++ src/app/(app)/pricelist/page.tsx | 626 ++++++++++++++++++ src/components/sidebar.tsx | 2 + src/lib/pb-helpers.ts | 46 +- src/lib/types.ts | 34 + 7 files changed, 907 insertions(+), 1 deletion(-) create mode 100644 docs/pocketbase/raw_price_list.collection.json create mode 100644 scripts/migrations/2026-06-30-add-raw-price-list.ts create mode 100644 src/app/(app)/pricelist/page.tsx diff --git a/docs/pocketbase/raw_price_list.collection.json b/docs/pocketbase/raw_price_list.collection.json new file mode 100644 index 0000000..d3d5c88 --- /dev/null +++ b/docs/pocketbase/raw_price_list.collection.json @@ -0,0 +1,118 @@ +{ + "_comment": "WFDASH — PocketBase collection for the Pricelist page. Holds raw supplier price-list files (PDF / CSV / text / image) before they're parsed into structured supplier pricing. Import via Settings → Import collections in the PB admin UI, or run scripts/migrations/2026-06-30-add-raw-price-list.ts to create it idempotently. The migration script also relaxes the auth rules to match the rest of the app (any authenticated user can CRUD) — the default for a fresh collection is superuser-only, which is too strict for the Pricelist page.", + "id": "pbc_raw_price_list_NEW", + "name": "raw_price_list", + "type": "base", + "listRule": "@request.auth.id != ''", + "viewRule": "@request.auth.id != ''", + "createRule": "@request.auth.id != ''", + "updateRule": "@request.auth.id != ''", + "deleteRule": "@request.auth.id != ''", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text_rpl_id", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "hidden": false, + "id": "autodate_rpl_created", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": true, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate_rpl_updated", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": true, + "type": "autodate" + }, + { + "_comment": "The actual file. Allowed types are whatever supplier pricelists come in as — PDFs, CSVs, plain text, and the common image formats. 50 MB cap matches quote_attachments.", + "hidden": false, + "id": "file_rpl_supplier_file", + "maxSelect": 1, + "maxSize": 52428800, + "mimeTypes": [ + "application/pdf", + "text/csv", + "text/plain", + "image/png", + "image/jpeg", + "image/webp", + "image/gif", + "application/vnd.ms-excel", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/octet-stream" + ], + "name": "supplier_file", + "presentable": false, + "protected": false, + "required": false, + "system": false, + "thumbs": null, + "type": "file" + }, + { + "_comment": "Relation to the suppliers collection. The live collection was created with this as a relation (not free text) so the supplier name is auto-rendered in the page's expand query — no client-side join needed. The picker on the upload form uses the existing SupplierSearch combobox.", + "cascadeDelete": false, + "collectionId": "pbc_3355664324", + "hidden": false, + "id": "relation_rpl_supplier_name", + "maxSelect": 1, + "minSelect": 0, + "name": "supplier_name", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "_comment": "Workflow status. Uploaded rows start as pending; the parser/AI bumps them through processing → completed, or → failed if it can't make sense of the file. Operator can override manually.", + "hidden": false, + "id": "select_rpl_status", + "maxSelect": 0, + "name": "status", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": ["Pending", "Processing", "Completed", "Failed"] + }, + { + "_comment": "Free-text log / summary. Whatever the operator wants to remember about the row — parser output, manual notes, \"called Acme, prices valid until Aug\", etc. The UI shows a truncated preview in the table and the full text in the detail dialog.", + "autogeneratePattern": "", + "hidden": false, + "id": "text_rpl_log_summary", + "max": 0, + "min": 0, + "name": "log_summary", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + } + ], + "indexes": [ + "CREATE INDEX `idx_rpl_supplier_name` ON `raw_price_list` (`supplier_name`)" + ], + "system": false +} diff --git a/package.json b/package.json index 440e3c9..c086e2e 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "pb:migrate:add-purchase-order-pdfs": "npx tsx scripts/migrations/2026-06-28-add-purchase-order-pdfs.ts", "pb:migrate:add-companies-coordinates": "npx tsx scripts/migrations/2026-06-28-add-companies-coordinates.ts", "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", "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-raw-price-list.ts b/scripts/migrations/2026-06-30-add-raw-price-list.ts new file mode 100644 index 0000000..32c5e69 --- /dev/null +++ b/scripts/migrations/2026-06-30-add-raw-price-list.ts @@ -0,0 +1,81 @@ +/** + * Migration — create / repair the `raw_price_list` PocketBase collection. + * + * Two responsibilities: + * + * 1. Create the collection from `docs/pocketbase/raw_price_list.collection.json` + * if it doesn't exist. Schema is the single source of truth; this script + * strips the leading `_comment` keys before sending to PB. + * + * 2. On a pre-existing collection (the user's case — the live PB had the + * collection already, but with PB's default `null` superuser-only rules), + * relax the rules to `@request.auth.id != ''` so any logged-in user can + * CRUD. We DON'T touch the schema on a pre-existing collection — the + * field names there are the source of truth (the live one uses + * `supplier_file` and `supplier_name` as a relation). + * + * Idempotent — safe to re-run. + * + * Run with: + * npm run pb:migrate:raw-price-list + */ +import fs from "node:fs"; +import path from "node:path"; +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."); +} + +const ROOT = path.resolve(process.cwd(), "docs", "pocketbase"); + +const AUTH_RULE = "@request.auth.id != ''"; + +async function main() { + const pb = new PocketBase(PB_URL); + await pb.collection("_superusers").authWithPassword(ADMIN_EMAIL, ADMIN_PASSWORD); + + const existing = await pb.collections.getList(1, 1, { filter: `name = "raw_price_list"` }); + + if (existing.items.length === 0) { + // ── Create from schema file ───────────────────────────────── + const def = JSON.parse( + fs.readFileSync(path.join(ROOT, "raw_price_list.collection.json"), "utf8"), + ); + const sanitized = JSON.parse( + JSON.stringify(def, (key, value) => (key.startsWith("_") ? undefined : value)), + ); + delete sanitized.id; // let PB assign its own 15-char id + + const created = await pb.collections.create(sanitized); + console.log(`Created raw_price_list (${created.id}) with auth rules = @request.auth.id != ''`); + return; + } + + // ── Pre-existing collection: relax the rules ───────────────── + const col = existing.items[0]; + const updates: Record = {}; + if (col.listRule !== AUTH_RULE) updates.listRule = AUTH_RULE; + if (col.viewRule !== AUTH_RULE) updates.viewRule = AUTH_RULE; + if (col.createRule !== AUTH_RULE) updates.createRule = AUTH_RULE; + if (col.updateRule !== AUTH_RULE) updates.updateRule = AUTH_RULE; + if (col.deleteRule !== AUTH_RULE) updates.deleteRule = AUTH_RULE; + + if (Object.keys(updates).length === 0) { + console.log(`raw_price_list (${col.id}) already has correct auth rules, nothing to do.`); + return; + } + + await pb.collections.update(col.id, updates); + console.log(`Relaxed raw_price_list (${col.id}) auth rules → ${AUTH_RULE}`); + console.log("Pricelist 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 new file mode 100644 index 0000000..5fb69e6 --- /dev/null +++ b/src/app/(app)/pricelist/page.tsx @@ -0,0 +1,626 @@ +"use client"; + +/** + * Pricelist — upload raw supplier price-list files (PDF / CSV / text / image) + * and review the queue of what's been uploaded. + * + * UI layout: + * 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 + * 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 + * delete action per row. + * + * All data lives in the `raw_price_list` PocketBase collection. Live schema + * (discovered via admin API on 2026-06-30): + * - supplier_file: the file + * - supplier_name: relation → suppliers + * - status: select (pending / processing / completed / failed) + * - log_summary: free-text + * The page subscribes to the collection so external writes (or another + * tab uploading) show up immediately without a manual refresh. + */ + +import { useCallback, useEffect, useRef, useState } from "react"; +import { DataTable } from "@/components/data-table"; +import { DeleteDialog } from "@/components/delete-dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { SupplierSearch } from "@/components/ui/supplier-search"; +import { + UploadCloud, + FileText, + Image as ImageIcon, + FileSpreadsheet, + FileType, + Trash2, + Download, + Loader2, + X, +} from "lucide-react"; +import { toast } from "sonner"; +import getPocketBase from "@/lib/pocketbase"; +import { + getRawPriceLists, + deleteRawPriceList, + getAllSuppliers, + formatDate, +} from "@/lib/pb-helpers"; +import type { RawPriceList, RawPriceListStatus, Supplier } from "@/lib/types"; + +/** + * Mime types we accept client-side. Mirrors the PB field's allowlist — + * PB enforces the authoritative check, but rejecting up front gives a + * clearer error before the upload starts. + */ +const ACCEPTED_MIME_TYPES = new Set([ + "application/pdf", + "text/csv", + "text/plain", + "image/png", + "image/jpeg", + "image/webp", + "image/gif", + "application/vnd.ms-excel", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/octet-stream", +]); + +const ACCEPT_ATTR = Array.from(ACCEPTED_MIME_TYPES).join(","); + +const MAX_BYTES = 50 * 1024 * 1024; // 50 MB — matches the PB field's maxSize + +/** Visual cue for the file type. Pure presentation — PB owns the truth. */ +function fileKindIcon(contentType: string, filename: string) { + const t = (contentType || "").toLowerCase(); + const ext = (filename || "").toLowerCase().split(".").pop() || ""; + if (t === "application/pdf" || ext === "pdf") + return ; + if (t.startsWith("image/") || ["png", "jpg", "jpeg", "webp", "gif"].includes(ext)) + return ; + if ( + t.includes("spreadsheet") || + t.includes("csv") || + t.includes("excel") || + ["csv", "xls", "xlsx"].includes(ext) + ) + return ; + if (t.startsWith("text/") || ext === "txt") + return ; + return ; +} + +function formatBytes(bytes: number): string { + if (!bytes || bytes <= 0) return "—"; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +/** Human-readable label for the "Pending / Processing / ..." badge. */ +function StatusBadge({ status }: { status: RawPriceListStatus | string }) { + const s = (status || "Pending") as RawPriceListStatus; + const className = + s === "Completed" + ? "bg-emerald-100 text-emerald-700 border-emerald-200" + : s === "Processing" + ? "bg-sky-100 text-sky-700 border-sky-200" + : s === "Failed" + ? "bg-red-100 text-red-700 border-red-200" + : "bg-amber-100 text-amber-800 border-amber-200"; + return ( + + {s} + + ); +} + +export default function PricelistPage() { + const fileInputRef = useRef(null); + + // ── Table state ──────────────────────────────────────────────── + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [search, setSearch] = useState(""); + const [page, setPage] = useState(1); + const [totalPages, setTotalPages] = useState(1); + const [deleteOpen, setDeleteOpen] = useState(false); + const [deleteTarget, setDeleteTarget] = useState(null); + + // ── Upload form state ────────────────────────────────────────── + const [file, setFile] = useState(null); + const [supplierId, setSupplierId] = useState(""); + const [logSummary, setLogSummary] = useState(""); + const [initialStatus, setInitialStatus] = useState("Pending"); + const [uploading, setUploading] = useState(false); + const [dragActive, setDragActive] = useState(false); + + // ── Supplier list for the picker ─────────────────────────────── + const [suppliers, setSuppliers] = useState([]); + + const fetchRows = useCallback(async () => { + setLoading(true); + setError(""); + try { + const result = await getRawPriceLists(page, 20, search); + setRows(result.items); + setTotalPages(result.totalPages); + } catch (err: unknown) { + const message = + err instanceof Error + ? err.message + : "Failed to load pricelist. Check the raw_price_list collection's auth rules."; + setError(message); + } finally { + setLoading(false); + } + }, [page, search]); + + useEffect(() => { + fetchRows(); + }, [fetchRows]); + + // One-time load of suppliers for the upload picker. The WFDASH fleet + // has hundreds — full list, no pagination. If this gets slow we'll + // switch to a search-as-you-type server query. + useEffect(() => { + let cancelled = false; + (async () => { + try { + const all = await getAllSuppliers(); + if (!cancelled) setSuppliers(all); + } catch { + // Non-fatal: the supplier picker just stays empty. Operator + // can still upload without picking one. + } + })(); + return () => { + cancelled = true; + }; + }, []); + + // Live updates — anything that creates / updates / deletes a row in + // `raw_price_list` (another tab, the n8n ingest workflow, etc.) + // refreshes the table without a manual reload. + useEffect(() => { + const pb = getPocketBase(); + pb.collection("raw_price_list").subscribe("*", () => { + fetchRows(); + }); + return () => { + pb.collection("raw_price_list").unsubscribe("*"); + }; + }, [fetchRows]); + + // ── File selection (drop or click-to-browse) ─────────────────── + function pickFile(picked: File | null) { + if (!picked) return; + if (picked.size > MAX_BYTES) { + toast.error(`File is too large (${formatBytes(picked.size)}). Max is 50 MB.`); + return; + } + const mime = (picked.type || "").toLowerCase(); + if (mime && !ACCEPTED_MIME_TYPES.has(mime)) { + toast.error( + `Unsupported file type: ${mime || "unknown"}. ` + + `Accepted: PDF, CSV, text, PNG/JPEG/WEBP/GIF.`, + ); + return; + } + setFile(picked); + } + + function onDragOver(e: React.DragEvent) { + e.preventDefault(); + e.stopPropagation(); + if (!dragActive) setDragActive(true); + } + function onDragLeave(e: React.DragEvent) { + e.preventDefault(); + e.stopPropagation(); + setDragActive(false); + } + function onDrop(e: React.DragEvent) { + e.preventDefault(); + e.stopPropagation(); + setDragActive(false); + const dropped = e.dataTransfer.files?.[0]; + if (dropped) pickFile(dropped); + } + + async function handleUpload(e: React.FormEvent) { + e.preventDefault(); + if (!file) { + toast.error("Pick a file first."); + return; + } + setUploading(true); + try { + const pb = getPocketBase(); + // PB expects a `File` for the file field. The browser `File` is + // the right type — PocketBase JS SDK inspects the property and + // builds a multipart FormData under the hood. + const formData: Record = { + supplier_file: file, + supplier_name: supplierId || null, + status: initialStatus, + log_summary: logSummary.trim(), + }; + await pb.collection("raw_price_list").create(formData); + toast.success(`Uploaded ${file.name}`); + setFile(null); + setSupplierId(""); + setLogSummary(""); + setInitialStatus("Pending"); + if (fileInputRef.current) fileInputRef.current.value = ""; + // fetchRows will fire from the PB subscription, but call it too + // so the row appears immediately without waiting for the socket. + fetchRows(); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "Upload failed"; + toast.error(message); + } finally { + setUploading(false); + } + } + + async function handleDelete() { + if (!deleteTarget) return; + try { + await deleteRawPriceList(deleteTarget.id); + toast.success(`Deleted ${deleteTarget.supplier_file?.[0] || "row"}`); + fetchRows(); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "Failed to delete"; + toast.error(message); + } + } + + // ── Table columns ────────────────────────────────────────────── + const columns = [ + { + key: "supplier_file", + label: "File", + render: (item: RawPriceList) => { + const token = item.supplier_file?.[0]; + const url = token + ? getPocketBase().files.getUrl(item, token) + : null; + // Filename isn't stored separately on the live schema — derive + // it from the PB file token (PB slugifies the original name). + const filename = token ? token.split("/").pop() || "Untitled" : "Untitled"; + return ( +
+ {fileKindIcon("", filename)} +
+ {url ? ( + + {filename} + + ) : ( +

+ {filename} +

+ )} +
+
+ ); + }, + }, + { + key: "supplier_name", + label: "Supplier", + render: (item: RawPriceList) => + item.expand?.supplier_name?.suppliername ? ( +

+ {item.expand.supplier_name.suppliername} +

+ ) : ( + — + ), + }, + { + key: "status", + label: "Status", + render: (item: RawPriceList) => , + }, + { + key: "log_summary", + label: "Log Summary", + render: (item: RawPriceList) => + item.log_summary ? ( +

+ {item.log_summary} +

+ ) : ( + — + ), + }, + { + key: "created", + label: "Uploaded", + render: (item: RawPriceList) => ( + + {formatDate(item.created)} + + ), + }, + { + key: "actions", + label: "", + className: "w-[80px]", + render: (item: RawPriceList) => { + const token = item.supplier_file?.[0]; + const url = token + ? getPocketBase().files.getUrl(item, token) + : null; + return ( +
e.stopPropagation()}> + {url && ( + + )} + +
+ ); + }, + }, + ]; + + return ( +
+
+

Pricelist

+

+ Upload raw supplier price-list files. They land in PocketBase as + attachments; downstream processing turns them into structured pricing. +

+
+ + {/* ── Upload card ───────────────────────────────────────── */} + + + Upload a price list + + Drag a file into the box, or use the button. PDF, CSV, plain text, + and common image formats are accepted. Max 50 MB. + + + +
+
fileInputRef.current?.click()} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + fileInputRef.current?.click(); + } + }} + onDragOver={onDragOver} + onDragEnter={onDragOver} + onDragLeave={onDragLeave} + onDrop={onDrop} + className={[ + "relative flex flex-col items-center justify-center gap-2", + "rounded-xl border-2 border-dashed p-8 text-center", + "transition-colors cursor-pointer select-none", + dragActive + ? "border-primary bg-primary/5" + : "border-muted-foreground/30 hover:border-primary/60 hover:bg-muted/30", + ].join(" ")} + data-testid="pricelist-dropzone" + > + + {file ? ( +
+
+ {fileKindIcon(file.type, file.name)} + + {file.name} + + + · {formatBytes(file.size)} + + +
+
+ ) : ( + <> +

+ Drop a file here, or click to browse +

+

+ PDF · CSV · TXT · PNG · JPEG · WEBP · GIF +

+ + )} + pickFile(e.target.files?.[0] ?? null)} + data-testid="pricelist-file-input" + /> +
+ +
+
+ + +
+
+ + +
+
+ + setLogSummary(v)} + placeholder="Notes about this file…" + /> +
+
+ +
+ + + {file && ( + + {file.name} ({formatBytes(file.size)}) + + )} +
+
+
+
+ + { + setSearch(q); + setPage(1); + }} + page={page} + totalPages={totalPages} + onPageChange={setPage} + mobileCardRender={(item) => { + const token = item.supplier_file?.[0]; + const filename = token ? token.split("/").pop() || "Untitled" : "Untitled"; + return ( +
+
+ {fileKindIcon("", filename)} +

{filename}

+ +
+ {item.expand?.supplier_name?.suppliername && ( +

+ Supplier:{" "} + {item.expand.supplier_name.suppliername} +

+ )} + {item.log_summary && ( +

+ {item.log_summary} +

+ )} +
+ {formatDate(item.created)} +
+
+ ); + }} + /> + + +
+ ); +} diff --git a/src/components/sidebar.tsx b/src/components/sidebar.tsx index 9592b8e..dba9d4b 100644 --- a/src/components/sidebar.tsx +++ b/src/components/sidebar.tsx @@ -25,6 +25,7 @@ import { MapPinned, Plus, Settings as SettingsIcon, + Tag, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; @@ -87,6 +88,7 @@ const managementItems: NavEntry[] = [ { href: "/customers", label: "Customers", icon: Users, category: "management" }, { href: "/suppliers", label: "Suppliers", icon: Truck, category: "management" }, { href: "/supplier-map", label: "Supplier Map", icon: MapPinned, category: "management" }, + { href: "/pricelist", label: "Pricelist", icon: Tag, category: "management" }, ]; const quoteItems: NavEntry[] = [ diff --git a/src/lib/pb-helpers.ts b/src/lib/pb-helpers.ts index faaf10e..6a6f84f 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 } from "./types"; +import type { Customer, Company, Supplier, Quote, QuoteItem, QuoteAttachment, Order, OrderItem, PurchaseOrder, CollectionPool, DeliveryNote, Repair, RawPriceList } from "./types"; import { TAB_GROUP_ITEM_STATUSES, itemStatusToTabGroup, type TabGroup } from "./item-status-ui"; import { scoreCandidate, @@ -1305,6 +1305,50 @@ export async function generateRepairNumber(): Promise { return `${prefix}${String(seq).padStart(3, "0")}`; } +/** + * Pricelist — fetch a page of rows from the `raw_price_list` collection. + * + * Backs the `/pricelist` page. The live collection has these fields + * (discovered via the admin API): + * - supplier_file: the file + * - supplier_name: relation → suppliers + * - status: select + * - log_summary: free-text + * + * Search hits `log_summary` and the expanded supplier's `suppliername`. + * (PB can't substring-match file tokens, so the filename isn't in the + * search filter — but PB's URL token is the filename in practice, so + * the user can find rows by scrolling.) + * + * Newest first — the table is a feed of recent uploads, not an + * alphabetised directory. + */ +export async function getRawPriceLists( + page = 1, + perPage = 50, + search = "", + sort = "-created" +) { + const pb = getPocketBase(); + // `~` is PB's case-insensitive contains operator. We match against + // log_summary directly; for supplier we OR against the expanded + // supplier's suppliername. PB 0.26 doesn't accept the `IN` operator + // (see MEMORY.md), so we do a simple OR-chain. + const filter = search + ? `log_summary ~ "${search}" || supplier_name.suppliername ~ "${search}"` + : ""; + return pb.collection("raw_price_list").getList(page, perPage, { + filter, + sort, + expand: "supplier_name", + }); +} + +export async function deleteRawPriceList(id: string) { + const pb = getPocketBase(); + return pb.collection("raw_price_list").delete(id); +} + export async function createRepair(data: { customer?: string; company?: string; diff --git a/src/lib/types.ts b/src/lib/types.ts index 416ca1c..6f14983 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -481,6 +481,40 @@ export interface DeliveryNoteItem extends BaseRecord { }; } +export type RawPriceListStatus = "Pending" | "Processing" | "Completed" | "Failed"; + +/** + * One row in the Pricelist page table. Holds a raw supplier price-list + * file (PDF / CSV / text / image) before any parsing/AI runs over it. + * + * Backed by the `raw_price_list` PocketBase collection. See + * `docs/pocketbase/raw_price_list.collection.json` and + * `scripts/migrations/2026-06-30-add-raw-price-list.ts` for the schema + * source of truth. + * + * The live PB collection's fields (discovered via the admin API): + * - supplier_file: the uploaded file (PDF/CSV/text/image) + * - supplier_name: relation → suppliers (free text → relation + * migration was the v1 design decision, kept for back-compat) + * - log_summary: free-text parser output / operator notes + * - status: select (pending / processing / completed / failed) + * + * If the collection is missing in a fresh deploy, the migration script + * creates it with this same shape. + */ +export interface RawPriceList extends BaseRecord { + /** PB file tokens. Single file per row — first element is the URL token. */ + supplier_file: string[]; + /** PB relation → suppliers. Empty string when unset. */ + supplier_name: string; + status: RawPriceListStatus; + /** Free-text log / summary — parser output, manual notes, etc. */ + log_summary: string; + expand?: { + supplier_name?: Supplier; + }; +} + export interface Repair extends BaseRecord { repair_number: string; customer?: string;