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
253 lines
No EOL
10 KiB
TypeScript
253 lines
No EOL
10 KiB
TypeScript
/* eslint-disable */
|
|
/**
|
|
* Migration: convert three customer fields from plain text to proper
|
|
* PB types.
|
|
*
|
|
* customers.company text → relation → companies
|
|
* customers.rep text → relation → users
|
|
* customers.coordinates text → geoPoint
|
|
*
|
|
* Why these changes
|
|
* ──────────────────
|
|
* A customer works for a company (organisation like Harmony Moab) and
|
|
* may deliver to a different address per shaft. Today those three
|
|
* fields are all free-text, which forces every consumer to either
|
|
* render raw names or fuzzy-look-up a record by string match.
|
|
*
|
|
* company — make it a relation so we can fetch the company
|
|
* record (address, vendor, future coordinates), so the
|
|
* dropdown in the customer form shows real company
|
|
* names, and so the customer's parent company survives
|
|
* company renames.
|
|
* rep — make it a relation to `users` so we can resolve the
|
|
* sales rep's email / name without a separate lookup
|
|
* table, and so the order form's rep auto-fill gets a
|
|
* stable ID instead of a typo-prone free-text name.
|
|
* coordinates — switch to native PB geoPoint so we get `within`
|
|
* radius queries for the future delivery app, instead
|
|
* of re-parsing a free-text "lat, lng" string every
|
|
* time we need a distance.
|
|
*
|
|
* Why we read-then-transform-then-backfill
|
|
* ─────────────────────────────────────────
|
|
* PB's collection.update requires the FULL fields array — you can't
|
|
* "rename" a field, only drop-and-add. Dropping the old text fields
|
|
* would erase the data on the existing rows, so we:
|
|
*
|
|
* 1. Read every customer row + the candidate target records.
|
|
* 2. Drop the old fields, add the new ones, save the collection.
|
|
* 3. Walk the captured rows; for each legacy value that still has a
|
|
* matching target record, set the new field to that record's id.
|
|
* Anything that doesn't match is left null — operator re-links.
|
|
*
|
|
* Why fuzzy name match is acceptable for backfill
|
|
* ───────────────────────────────────────────────
|
|
* The legacy `customer.company` text often holds the company name
|
|
* verbatim ("Harmony Moab"), and legacy `customer.rep` holds the rep
|
|
* name verbatim ("John Smith"). Match is case-insensitive trimmed
|
|
* equality against `companies.company` and `users.name`. Misses are
|
|
* harmless — operator re-links via the form's combobox. The migration
|
|
* is idempotent: re-runs detect the new fields, skip the schema
|
|
* rewrite, and re-attempt the backfill for any rows still missing
|
|
* their relation.
|
|
*
|
|
* Run with:
|
|
* npx tsx scripts/migrations/2026-06-28-convert-customer-fields.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(`[customer-fields] Authenticated against ${url}`);
|
|
|
|
// Look up the three collection IDs we need: customers (the one
|
|
// we're rewriting), companies (target of the company relation),
|
|
// and users (PB's auth collection — target of the rep relation).
|
|
const all = await pb.collections.getFullList();
|
|
const byName = new Map(all.map((c) => [c.name, c]));
|
|
const customers = byName.get("customers");
|
|
const companies = byName.get("companies");
|
|
const users = byName.get("users");
|
|
if (!customers) throw new Error("Required collection `customers` not found.");
|
|
if (!companies) throw new Error("Required collection `companies` not found.");
|
|
if (!users) throw new Error("Required collection `users` not found.");
|
|
|
|
// ─── Step 1: read every customer row's legacy values BEFORE we
|
|
// rewrite the schema. Once we drop the text fields this data is
|
|
// gone, so capture it up front.
|
|
console.log("[customer-fields] Reading existing customer rows...");
|
|
const allCustomers = await pb.collection("customers").getFullList();
|
|
console.log(`[customer-fields] ${allCustomers.length} rows.`);
|
|
|
|
// Index target records by lowercased name for the backfill below.
|
|
const allCompanies = await pb.collection("companies").getFullList();
|
|
const companiesByName = new Map<string, string>(); // lowercased name → id
|
|
for (const c of allCompanies) {
|
|
const key = (c.company || "").trim().toLowerCase();
|
|
if (key) companiesByName.set(key, c.id);
|
|
}
|
|
|
|
const allUsers = await pb.collection("users").getFullList();
|
|
const usersByName = new Map<string, string>(); // lowercased name → id
|
|
const usersByEmail = new Map<string, string>(); // lowercased email → id
|
|
for (const u of allUsers) {
|
|
const nameKey = (u.name || "").trim().toLowerCase();
|
|
if (nameKey) usersByName.set(nameKey, u.id);
|
|
const emailKey = (u.email || "").trim().toLowerCase();
|
|
if (emailKey) usersByEmail.set(emailKey, u.id);
|
|
}
|
|
|
|
// ─── Step 2: rewrite the schema if it isn't already in the new shape.
|
|
// We treat the schema as "converted" when the relation fields exist.
|
|
// The geoPoint field alone isn't enough since the legacy text field
|
|
// could share a name with it (PB allows drop+add of same name only
|
|
// if not present, which we check via type field-by-field).
|
|
const fields = customers.fields;
|
|
const hasCompanyRelation = fields.some(
|
|
(f) => f.name === "company" && f.type === "relation"
|
|
);
|
|
const hasRepRelation = fields.some(
|
|
(f) => f.name === "rep" && f.type === "relation"
|
|
);
|
|
const hasCoordinatesGeoPoint = fields.some(
|
|
(f) => f.name === "coordinates" && f.type === "geoPoint"
|
|
);
|
|
|
|
if (!hasCompanyRelation || !hasRepRelation || !hasCoordinatesGeoPoint) {
|
|
const newFields = fields
|
|
// Drop the legacy text columns if they still exist.
|
|
.filter(
|
|
(f) =>
|
|
!(
|
|
(f.name === "company" && f.type === "text") ||
|
|
(f.name === "rep" && f.type === "text") ||
|
|
(f.name === "coordinates" && f.type === "text")
|
|
)
|
|
);
|
|
|
|
// Add the new fields only if not already present.
|
|
if (!newFields.some((f) => f.name === "company")) {
|
|
newFields.push({
|
|
name: "company",
|
|
type: "relation",
|
|
required: false,
|
|
collectionId: companies.id,
|
|
maxSelect: 1,
|
|
presentable: false,
|
|
cascadeDelete: false,
|
|
});
|
|
}
|
|
if (!newFields.some((f) => f.name === "rep")) {
|
|
newFields.push({
|
|
name: "rep",
|
|
type: "relation",
|
|
required: false,
|
|
collectionId: users.id,
|
|
maxSelect: 1,
|
|
presentable: false,
|
|
cascadeDelete: false,
|
|
});
|
|
}
|
|
if (!newFields.some((f) => f.name === "coordinates")) {
|
|
newFields.push({
|
|
name: "coordinates",
|
|
type: "geoPoint",
|
|
required: false,
|
|
presentable: false,
|
|
});
|
|
}
|
|
|
|
await pb.collections.update(customers.id, { fields: newFields });
|
|
console.log(
|
|
"[customer-fields] Rewrote customers schema: company → relation, rep → relation, coordinates → geoPoint."
|
|
);
|
|
} else {
|
|
console.log("[customer-fields] Schema already converted — skipping rewrite.");
|
|
}
|
|
|
|
// ─── Step 3: backfill. For each legacy value that resolves to a
|
|
// target record id, write it to the new field. Idempotent — skip
|
|
// rows that already have a value in the new field.
|
|
//
|
|
// `allCustomers` was fetched at the top of this script, BEFORE the
|
|
// schema rewrite, so the JS objects still hold the original text
|
|
// values for `company` / `rep` even after step 2 dropped those
|
|
// columns. (Re-fetching here would give us empty defaults for the
|
|
// dropped fields.)
|
|
let backfilledCompany = 0;
|
|
let backfilledRep = 0;
|
|
let skippedAlreadySet = 0;
|
|
let skippedNoMatch = 0;
|
|
for (const row of allCustomers) {
|
|
const updates: Record<string, unknown> = {};
|
|
|
|
const legacyCompany = typeof row.company === "string" ? row.company : "";
|
|
if (legacyCompany) {
|
|
const match = companiesByName.get(legacyCompany.trim().toLowerCase());
|
|
if (match) {
|
|
updates.company = match;
|
|
backfilledCompany++;
|
|
} else {
|
|
skippedNoMatch++;
|
|
}
|
|
} else {
|
|
skippedAlreadySet++;
|
|
}
|
|
|
|
const legacyRep = typeof row.rep === "string" ? row.rep : "";
|
|
if (legacyRep) {
|
|
const key = legacyRep.trim().toLowerCase();
|
|
// Prefer name match; fall back to email match (some legacy
|
|
// rows hold the rep's email rather than name).
|
|
const match = usersByName.get(key) || usersByEmail.get(key);
|
|
if (match) {
|
|
updates.rep = match;
|
|
backfilledRep++;
|
|
}
|
|
}
|
|
|
|
if (Object.keys(updates).length > 0) {
|
|
await pb.collection("customers").update(row.id, updates);
|
|
}
|
|
}
|
|
|
|
console.log(
|
|
`[customer-fields] Backfill: ${backfilledCompany} company links, ${backfilledRep} rep links. ${skippedAlreadySet} rows already empty, ${skippedNoMatch} rows had text but no matching company.`
|
|
);
|
|
console.log("[customer-fields] Migration complete.");
|
|
}
|
|
|
|
main().catch((err) => {
|
|
console.error("[customer-fields] Migration failed:", err);
|
|
process.exit(1);
|
|
}); |