This commit overhauls the order management system to use item-level status tracking instead of a single order-level status: - Remove deprecated `orders.status` field and add `invoiced` and quote match audit fields - Add `isOrderComplete` utility with tests to validate fully delivered orders - Add purchase order PDF rendering support and tooling - Add geo coordinate fields for companies/customers with map link UI - Add REST management APIs for customers, companies, and suppliers - Update customer/company services to use relation fields instead of free text - Add status pill and tab filter components for item status visualization - Add quote-to-order matching logic and unit tests - Update all pages, components, and tests to match the new schema - Include all required PocketBase migrations and schema updates
224 lines
7.5 KiB
TypeScript
224 lines
7.5 KiB
TypeScript
/* eslint-disable */
|
|
/**
|
|
* Migration: add `purchase_order_pdfs` collection for PO PDF versioning.
|
|
*
|
|
* Why this exists
|
|
* ────────────────
|
|
* When we send a PO we now generate a real PDF and attach it to the
|
|
* order in PocketBase. If the operator later edits a price / supplier /
|
|
* quantity on a PO line, they need to regenerate the PDF — but the old
|
|
* PDF must stay (audit trail + "what did we originally send the
|
|
* supplier?"). So one PO → many PDF versions, each stored as its own
|
|
* row in this collection.
|
|
*
|
|
* Schema
|
|
* ──────
|
|
* purchase_order relation → purchase_orders (required)
|
|
* version number (required, ≥ 1)
|
|
* file file (PDF, max 1) (required)
|
|
* generated_at date (auto)
|
|
* generated_by relation → users (optional, set by API)
|
|
* notes text (optional, ≤ 500) ("re-priced after discount")
|
|
* total_amount number (snapshot) (locked at gen time)
|
|
* status select: current | superseded (only one "current" per PO)
|
|
*
|
|
* Version numbering is computed by the API route (count of existing
|
|
* PDFs for this PO + 1) — not via a PB index — so two concurrent
|
|
* regenerates can't collide on a unique constraint we don't have. The
|
|
* server picks `status: current` for the new version and marks the
|
|
* previous current as `superseded` in the same call.
|
|
*
|
|
* Idempotent — safe to re-run.
|
|
*
|
|
* Run with:
|
|
* npx tsx scripts/migrations/2026-06-28-add-purchase-order-pdfs.ts
|
|
*/
|
|
import PocketBase from "pocketbase";
|
|
import { readFileSync } from "node:fs";
|
|
import { resolve } from "node:path";
|
|
|
|
function loadEnvFile(name: string) {
|
|
try {
|
|
const raw = readFileSync(resolve(process.cwd(), name), "utf8");
|
|
for (const line of raw.split("\n")) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
const m = trimmed.match(/^([A-Z0-9_]+)\s*=\s*(.+)$/i);
|
|
if (m && !process.env[m[1]]) {
|
|
process.env[m[1]] = m[2].replace(/^["']|["']$/g, "").trim();
|
|
}
|
|
}
|
|
} catch {
|
|
// missing file — fine
|
|
}
|
|
}
|
|
loadEnvFile(".env");
|
|
loadEnvFile(".env.local");
|
|
|
|
async function main() {
|
|
const url = process.env.NEXT_PUBLIC_POCKETBASE_URL;
|
|
const email = process.env.PB_ADMIN_EMAIL;
|
|
const password = process.env.PB_ADMIN_PASSWORD;
|
|
if (!url || !email || !password) {
|
|
throw new Error(
|
|
"Set NEXT_PUBLIC_POCKETBASE_URL, PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD (e.g. from .env.local)."
|
|
);
|
|
}
|
|
|
|
const pb = new PocketBase(url);
|
|
await pb.collection("_superusers").authWithPassword(email, password);
|
|
console.log(`[purchase-order-pdfs] Authenticated against ${url}`);
|
|
|
|
const existing = await pb.collections.getFullList({ filter: "name = 'purchase_order_pdfs'" });
|
|
if (existing.length > 0) {
|
|
console.log("[purchase-order-pdfs] purchase_order_pdfs already exists — skipping create.");
|
|
return;
|
|
}
|
|
|
|
// Look up related collection IDs by name so we don't hard-code the
|
|
// 15-char ids from the schema JSON (those can drift if the schema
|
|
// is rebuilt on a fresh DB).
|
|
const all = await pb.collections.getFullList();
|
|
const byName = new Map(all.map((c) => [c.name, c]));
|
|
const purchaseOrders = byName.get("purchase_orders");
|
|
if (!purchaseOrders) {
|
|
throw new Error("Required collection `purchase_orders` not found. Run prior migrations first.");
|
|
}
|
|
|
|
const created = await pb.collections.create({
|
|
name: "purchase_order_pdfs",
|
|
type: "base",
|
|
// Auth gate identical to peer collections: anyone signed in can
|
|
// read/write. Tighten later if we expose PO PDFs externally.
|
|
listRule: "@request.auth.id != ''",
|
|
viewRule: "@request.auth.id != ''",
|
|
createRule: "@request.auth.id != ''",
|
|
updateRule: "@request.auth.id != ''",
|
|
deleteRule: "@request.auth.id != ''",
|
|
fields: [
|
|
{
|
|
// FK to the PO this PDF version belongs to.
|
|
cascadeDelete: true,
|
|
collectionId: purchaseOrders.id,
|
|
hidden: false,
|
|
maxSelect: 1,
|
|
minSelect: 1,
|
|
name: "purchase_order",
|
|
presentable: false,
|
|
required: true,
|
|
system: false,
|
|
type: "relation",
|
|
},
|
|
{
|
|
// Auto-incremented at API write time. Stored as a number so
|
|
// we can sort `version DESC` to find the latest cheaply.
|
|
hidden: false,
|
|
max: null,
|
|
min: 1,
|
|
name: "version",
|
|
onlyInt: true,
|
|
presentable: false,
|
|
required: true,
|
|
system: false,
|
|
type: "number",
|
|
},
|
|
{
|
|
// The actual PDF binary. PB file fields support maxSelect: 1.
|
|
// We use a generous max size — typical PO PDF is < 1 MB.
|
|
hidden: false,
|
|
maxSelect: 1,
|
|
maxSize: 10485760, // 10 MB
|
|
minSelect: 1,
|
|
name: "file",
|
|
presentable: false,
|
|
required: true,
|
|
system: false,
|
|
type: "file",
|
|
},
|
|
{
|
|
// Generation timestamp. Drives default sort + UI display.
|
|
hidden: false,
|
|
max: "",
|
|
min: "",
|
|
name: "generated_at",
|
|
presentable: false,
|
|
required: false,
|
|
system: false,
|
|
type: "date",
|
|
},
|
|
{
|
|
// Optional FK to the user who triggered generation/regen.
|
|
// Optional because some generations may be triggered by an
|
|
// automated job (n8n workflow, cron) where the user is null.
|
|
cascadeDelete: false,
|
|
collectionId: "_pb_users_auth_",
|
|
hidden: false,
|
|
maxSelect: 1,
|
|
minSelect: 0,
|
|
name: "generated_by",
|
|
presentable: false,
|
|
required: false,
|
|
system: false,
|
|
type: "relation",
|
|
},
|
|
{
|
|
// Reason for regeneration. Optional on v1 (first send rarely
|
|
// needs a note), common on v2+ ("re-priced after discount
|
|
// discussion with supplier"). Capped at 500 chars.
|
|
hidden: false,
|
|
max: 500,
|
|
min: 0,
|
|
name: "notes",
|
|
presentable: false,
|
|
required: false,
|
|
system: false,
|
|
type: "text",
|
|
},
|
|
{
|
|
// Locked total at time of generation. Lets the UI show "this
|
|
// PO was sent for R 4 250, v2 for R 4 100" without recomputing
|
|
// from items (which may themselves have been edited later).
|
|
hidden: false,
|
|
max: null,
|
|
min: null,
|
|
name: "total_amount",
|
|
onlyInt: false,
|
|
presentable: false,
|
|
required: false,
|
|
system: false,
|
|
type: "number",
|
|
},
|
|
{
|
|
// Only one "current" version per PO at any time. Old current
|
|
// rows are flipped to "superseded" in the same API call that
|
|
// creates the new current row.
|
|
hidden: false,
|
|
maxSelect: 1,
|
|
name: "status",
|
|
presentable: false,
|
|
required: false,
|
|
system: false,
|
|
type: "select",
|
|
values: ["current", "superseded"],
|
|
},
|
|
],
|
|
indexes: [
|
|
// Fast "latest version for PO X" lookup:
|
|
// filter: purchase_order = X
|
|
// sort: -version
|
|
"CREATE INDEX idx_popop_purchase_order_version ON purchase_order_pdfs (purchase_order, version DESC)",
|
|
// Fast "what's the current PDF for PO X?" lookup:
|
|
"CREATE INDEX idx_popop_status ON purchase_order_pdfs (status)",
|
|
],
|
|
});
|
|
|
|
console.log(
|
|
`[purchase-order-pdfs] Created collection ${created.name} (${created.id})`
|
|
);
|
|
console.log("[purchase-order-pdfs] Migration complete.");
|
|
}
|
|
|
|
main().catch((err) => {
|
|
console.error("[purchase-order-pdfs] Migration failed:", err);
|
|
process.exit(1);
|
|
});
|