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
172 lines
6.3 KiB
TypeScript
172 lines
6.3 KiB
TypeScript
/* eslint-disable */
|
|
/**
|
|
* Migration: ditch the `orders.status` lifecycle field.
|
|
*
|
|
* Why this exists:
|
|
* `orders.status` was a stored enum kept in sync with the items
|
|
* by `recomputeAndPersistOrderStatus` after every item transition.
|
|
* In practice it desynced from the items (the memo lagged, the
|
|
* UI computed its own value, etc) — the user-visible symptom was
|
|
* the order list showing "new" while the order detail page showed
|
|
* "processing" / "collection pool", and a refresh flipping them
|
|
* around.
|
|
*
|
|
* The fix is structural: stop storing a header status at all.
|
|
* Item-level `order_items.item_status` is the source of truth. The
|
|
* UI tabs on /orders filter by item status groups, and the only
|
|
* order-level indicator that remains is a derived "Complete" badge
|
|
* when every item is `delivered`.
|
|
*
|
|
* Steps (idempotent — safe to re-run):
|
|
*
|
|
* 1. Drop `orders.status` (select field) if present.
|
|
* 2. Add `orders.invoiced` (bool, default false) if absent.
|
|
* Kept separate from the dropped status so a future "Mark
|
|
* Invoiced" action has somewhere to write without re-introducing
|
|
* a lifecycle enum.
|
|
* 3. Wipe every row in `orders` and `order_items`.
|
|
* WFDASH data is currently all test data per the operator; this
|
|
* gives us a clean slate so the UI can be rebuilt without
|
|
* contorting around stale legacy rows.
|
|
*
|
|
* The migration does NOT touch `collection_pool`, `purchase_orders`,
|
|
* `delivery_notes`, `repairs`, or any other collection that points
|
|
* back to orders — those FKs stay intact. After the wipe, just create
|
|
* fresh orders via /orders/new or /api/v1/orders/from-quote.
|
|
*
|
|
* Required env (in .env / .env.local / shell):
|
|
* NEXT_PUBLIC_POCKETBASE_URL
|
|
* PB_ADMIN_EMAIL
|
|
* PB_ADMIN_PASSWORD
|
|
*
|
|
* Usage: npx tsx scripts/migrations/2026-06-27-drop-orders-status-and-wipe.ts
|
|
*/
|
|
import PocketBase from "pocketbase";
|
|
import { readFileSync } from "node:fs";
|
|
import { resolve } from "node:path";
|
|
|
|
// Load .env / .env.local if present (tsx doesn't auto-load them).
|
|
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 {
|
|
// file not present — fine, rely on shell env
|
|
}
|
|
}
|
|
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. sourced from .env.local)."
|
|
);
|
|
}
|
|
|
|
const pb = new PocketBase(url);
|
|
await pb.collection("_superusers").authWithPassword(email, password);
|
|
console.log(`[drop-orders-status] Authenticated against ${url}`);
|
|
|
|
// ---------------------------------------------------------------------
|
|
// 1. Drop orders.status
|
|
// ---------------------------------------------------------------------
|
|
const orders = await pb.collections.getOne("orders");
|
|
const statusField = orders.fields.find((f) => f.name === "status");
|
|
|
|
if (statusField) {
|
|
if (statusField.type !== "select") {
|
|
throw new Error(
|
|
`orders.status exists but is type "${statusField.type}", not "select" — refusing to drop.`
|
|
);
|
|
}
|
|
const nextFields = orders.fields
|
|
.filter((f) => f.name !== "status")
|
|
// Drop any legacy options.values too, in case a prior migration
|
|
// wrote to the wrong location (matches the 2026-06-26 fix).
|
|
.map((f) => {
|
|
if (!f.options || typeof f.options !== "object") return f;
|
|
const opts = { ...(f.options as Record<string, unknown>) };
|
|
delete opts.values;
|
|
return { ...f, options: opts };
|
|
});
|
|
await pb.collections.update(orders.id, { fields: nextFields });
|
|
console.log("[drop-orders-status] Dropped orders.status (select field).");
|
|
} else {
|
|
console.log("[drop-orders-status] orders.status already absent — skipping.");
|
|
}
|
|
|
|
// Re-fetch the orders schema so step 2 sees the post-drop state.
|
|
const ordersFresh = await pb.collections.getOne("orders");
|
|
|
|
// ---------------------------------------------------------------------
|
|
// 2. Add orders.invoiced (bool, default false)
|
|
// ---------------------------------------------------------------------
|
|
const hasInvoiced = ordersFresh.fields.some((f) => f.name === "invoiced");
|
|
if (!hasInvoiced) {
|
|
await pb.collections.update(ordersFresh.id, {
|
|
fields: [
|
|
...ordersFresh.fields,
|
|
{
|
|
name: "invoiced",
|
|
type: "bool",
|
|
required: false,
|
|
presentable: false,
|
|
},
|
|
],
|
|
});
|
|
console.log("[drop-orders-status] Added orders.invoiced (bool, default false).");
|
|
} else {
|
|
console.log("[drop-orders-status] orders.invoiced already present — skipping.");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// 3. Wipe orders + order_items
|
|
// ---------------------------------------------------------------------
|
|
for (const collectionName of ["order_items", "orders"]) {
|
|
let page = 1;
|
|
let scanned = 0;
|
|
let deleted = 0;
|
|
while (true) {
|
|
const batch = await pb.collection(collectionName).getList(page, 500, {
|
|
fields: "id",
|
|
});
|
|
if (batch.items.length === 0) break;
|
|
for (const row of batch.items) {
|
|
scanned++;
|
|
try {
|
|
await pb.collection(collectionName).delete(row.id);
|
|
deleted++;
|
|
} catch (err: unknown) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
console.error(
|
|
`[drop-orders-status] Failed to delete ${collectionName}/${row.id}: ${msg}`
|
|
);
|
|
throw err;
|
|
}
|
|
}
|
|
if (batch.page >= batch.totalPages) break;
|
|
page++;
|
|
}
|
|
console.log(
|
|
`[drop-orders-status] ${collectionName}: scanned ${scanned}, deleted ${deleted}.`
|
|
);
|
|
}
|
|
|
|
console.log("[drop-orders-status] Migration complete.");
|
|
}
|
|
|
|
main().catch((err) => {
|
|
console.error("[drop-orders-status] Migration failed:", err);
|
|
process.exit(1);
|
|
});
|