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:
parent
a0332c4159
commit
7f70258037
7 changed files with 907 additions and 1 deletions
118
docs/pocketbase/raw_price_list.collection.json
Normal file
118
docs/pocketbase/raw_price_list.collection.json
Normal 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
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
81
scripts/migrations/2026-06-30-add-raw-price-list.ts
Normal file
81
scripts/migrations/2026-06-30-add-raw-price-list.ts
Normal 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);
|
||||
});
|
||||
626
src/app/(app)/pricelist/page.tsx
Normal file
626
src/app/(app)/pricelist/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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[] = [
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue