feat(management): new Pricelist page with drag-drop upload + queue table

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)
This commit is contained in:
SJCoetz 2026-06-30 09:14:36 +02:00
parent a0332c4159
commit 7f70258037
7 changed files with 907 additions and 1 deletions

View file

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

View file

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

View file

@ -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<string, unknown> = {};
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);
});

View file

@ -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<string>([
"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 <FileText className="h-4 w-4 text-red-500" />;
if (t.startsWith("image/") || ["png", "jpg", "jpeg", "webp", "gif"].includes(ext))
return <ImageIcon className="h-4 w-4 text-purple-500" />;
if (
t.includes("spreadsheet") ||
t.includes("csv") ||
t.includes("excel") ||
["csv", "xls", "xlsx"].includes(ext)
)
return <FileSpreadsheet className="h-4 w-4 text-emerald-600" />;
if (t.startsWith("text/") || ext === "txt")
return <FileType className="h-4 w-4 text-sky-600" />;
return <FileText className="h-4 w-4 text-muted-foreground" />;
}
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 (
<Badge variant="outline" className={className}>
{s}
</Badge>
);
}
export default function PricelistPage() {
const fileInputRef = useRef<HTMLInputElement | null>(null);
// ── Table state ────────────────────────────────────────────────
const [rows, setRows] = useState<RawPriceList[]>([]);
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<RawPriceList | null>(null);
// ── Upload form state ──────────────────────────────────────────
const [file, setFile] = useState<File | null>(null);
const [supplierId, setSupplierId] = useState("");
const [logSummary, setLogSummary] = useState("");
const [initialStatus, setInitialStatus] = useState<RawPriceListStatus>("Pending");
const [uploading, setUploading] = useState(false);
const [dragActive, setDragActive] = useState(false);
// ── Supplier list for the picker ───────────────────────────────
const [suppliers, setSuppliers] = useState<Supplier[]>([]);
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<string, unknown> = {
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 (
<div className="flex items-center gap-2 min-w-0">
{fileKindIcon("", filename)}
<div className="min-w-0">
{url ? (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="font-medium text-sm hover:underline truncate block max-w-[260px]"
title={filename}
>
{filename}
</a>
) : (
<p className="font-medium text-sm truncate max-w-[260px]" title={filename}>
{filename}
</p>
)}
</div>
</div>
);
},
},
{
key: "supplier_name",
label: "Supplier",
render: (item: RawPriceList) =>
item.expand?.supplier_name?.suppliername ? (
<p className="text-sm font-medium">
{item.expand.supplier_name.suppliername}
</p>
) : (
<span className="text-sm text-muted-foreground">—</span>
),
},
{
key: "status",
label: "Status",
render: (item: RawPriceList) => <StatusBadge status={item.status} />,
},
{
key: "log_summary",
label: "Log Summary",
render: (item: RawPriceList) =>
item.log_summary ? (
<p
className="text-sm text-muted-foreground truncate max-w-[300px]"
title={item.log_summary}
>
{item.log_summary}
</p>
) : (
<span className="text-sm text-muted-foreground">—</span>
),
},
{
key: "created",
label: "Uploaded",
render: (item: RawPriceList) => (
<span className="text-sm text-muted-foreground whitespace-nowrap">
{formatDate(item.created)}
</span>
),
},
{
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 (
<div className="flex gap-1" onClick={(e) => e.stopPropagation()}>
{url && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
title="Download"
onClick={() => window.open(url, "_blank", "noopener,noreferrer")}
>
<Download className="h-4 w-4" />
</Button>
)}
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive"
title="Delete"
onClick={() => {
setDeleteTarget(item);
setDeleteOpen(true);
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
);
},
},
];
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">Pricelist</h1>
<p className="text-muted-foreground">
Upload raw supplier price-list files. They land in PocketBase as
attachments; downstream processing turns them into structured pricing.
</p>
</div>
{/* ── Upload card ───────────────────────────────────────── */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Upload a price list</CardTitle>
<CardDescription>
Drag a file into the box, or use the button. PDF, CSV, plain text,
and common image formats are accepted. Max 50 MB.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleUpload} className="space-y-4">
<div
role="button"
tabIndex={0}
onClick={() => 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"
>
<UploadCloud
className={[
"h-10 w-10",
dragActive ? "text-primary" : "text-muted-foreground",
].join(" ")}
/>
{file ? (
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 rounded-md bg-muted px-3 py-1.5 text-sm">
{fileKindIcon(file.type, file.name)}
<span className="font-medium max-w-[260px] truncate">
{file.name}
</span>
<span className="text-muted-foreground">
· {formatBytes(file.size)}
</span>
<button
type="button"
className="ml-1 rounded p-0.5 hover:bg-background"
onClick={(e) => {
e.stopPropagation();
setFile(null);
if (fileInputRef.current) fileInputRef.current.value = "";
}}
aria-label="Remove file"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
</div>
) : (
<>
<p className="text-sm font-medium">
Drop a file here, or click to browse
</p>
<p className="text-xs text-muted-foreground">
PDF · CSV · TXT · PNG · JPEG · WEBP · GIF
</p>
</>
)}
<input
ref={fileInputRef}
type="file"
accept={ACCEPT_ATTR}
className="hidden"
onChange={(e) => pickFile(e.target.files?.[0] ?? null)}
data-testid="pricelist-file-input"
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="pricelist-supplier">Supplier (optional)</Label>
<SupplierSearch
value={supplierId}
onValueChange={setSupplierId}
suppliers={suppliers}
placeholder="Search suppliers…"
/>
</div>
<div className="space-y-2">
<Label htmlFor="pricelist-status">Status</Label>
<Select
value={initialStatus}
onValueChange={(v) => setInitialStatus(v as RawPriceListStatus)}
>
<SelectTrigger id="pricelist-status" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Pending">Pending</SelectItem>
<SelectItem value="Processing">Processing</SelectItem>
<SelectItem value="Completed">Completed</SelectItem>
<SelectItem value="Failed">Failed</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="pricelist-log">Log summary (optional)</Label>
<Input
id="pricelist-log"
value={logSummary}
onValueChange={(v) => setLogSummary(v)}
placeholder="Notes about this file…"
/>
</div>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
>
Choose file
</Button>
<Button type="submit" disabled={!file || uploading}>
{uploading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Upload
</Button>
{file && (
<span className="text-xs text-muted-foreground">
{file.name} ({formatBytes(file.size)})
</span>
)}
</div>
</form>
</CardContent>
</Card>
<DataTable
columns={columns}
data={rows}
loading={loading}
error={error}
searchPlaceholder="Search filename, supplier, or notes…"
onSearch={(q) => {
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 (
<div className="rounded-md border p-4 space-y-2">
<div className="flex items-center gap-2">
{fileKindIcon("", filename)}
<p className="font-medium truncate flex-1">{filename}</p>
<StatusBadge status={item.status} />
</div>
{item.expand?.supplier_name?.suppliername && (
<p className="text-sm text-muted-foreground">
<span className="font-medium">Supplier:</span>{" "}
{item.expand.supplier_name.suppliername}
</p>
)}
{item.log_summary && (
<p className="text-sm text-muted-foreground line-clamp-2">
{item.log_summary}
</p>
)}
<div className="flex items-center justify-end text-xs text-muted-foreground pt-1">
<span>{formatDate(item.created)}</span>
</div>
</div>
);
}}
/>
<DeleteDialog
open={deleteOpen}
onOpenChange={setDeleteOpen}
title="Delete price list"
description={`Are you sure you want to delete this row? The file and its record will be removed from PocketBase.`}
onConfirm={handleDelete}
/>
</div>
);
}

View file

@ -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[] = [

View file

@ -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<string> {
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<RawPriceList>(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;

View file

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