WFDASH-WEBAPP/scripts/migrations/2026-07-06-order-items-pricing-fields.ts
SJCoetz 7f924ccc45 refactor: switch hardcoded UI colors to semantic theme tokens
Overhaul the app's color system by replacing all hardcoded Tailwind color classes with semantic theme variables to ensure consistent theming across all components, pages, and tests.

Additional changes:
- Add assigned date support for order item status updates: add `assignedDate` field to the order item status PATCH API, implement server-side normalization/validation, and add corresponding tests
- Refactor purchase order PDF workflows to use the consolidated `purchase_orders.documents` field instead of the legacy separate `purchase_order_pdfs` collection
- Fix quote number parsing and formatting to support both dashed (Q-YYYY-MM-NNN) and dashless (QYYYYMMNNN) formats
- Rename `driver_pool` item status to `collection_pool` across the codebase
- Add null handling for order item quantities and pricing to display "—" for missing values
- Improve filter tab styling: make mobile tabs scrollable, prevent text wrapping, hide scrollbar
- Add new utility scripts, API routes, migrations, and polish small UI/UX fixes including tsconfig updates and theme hook improvements
2026-07-07 17:22:03 +02:00

198 lines
No EOL
7 KiB
TypeScript

/* eslint-disable */
/**
* Migration — add pricing-source fields to `order_items` and
* repopulate `item_status` select options.
*
* Why:
* The order process page needs to show the buyer which matched
* quote line a cost came from, and to round-trip those values when
* a price is copied from the quote (or re-priced from the catalogue).
* Today those fields only live on `quote_items`. The four we're
* adding to `order_items`:
*
* - sku text the supplier's SKU code (e.g.
* "M20-HEX-40-G88")
* - pricing_source text "quote" | "catalogue" |
* "manual" — drives the
* audit-trail chip on the
* process page.
* - matched_price_list_id text catalogue row id when sourced
* from `price_list`.
* - pricing_notes text free text from the operator
* ("cost confirmed via
* phone call 2026-07-06")
*
* Plus a fix-up: `item_status` was migrated to a select with empty
* `values: []` at some point in the past, which means PB will
* accept arbitrary string writes but the operator can no longer
* see/filter the canonical stages. Repopulating the values list
* restores both the dropdown UI and the canonical set.
*
* Idempotent — each step checks before mutating, so partial-success
* recovery is safe.
*
* Run with:
* npx tsx scripts/migrations/2026-07-06-order-items-pricing-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 {
/* fine — rely on shell env */
}
}
loadEnvFile(".env");
loadEnvFile(".env.local");
// Fields to add (text fields, all optional, no defaults).
const NEW_TEXT_FIELDS = [
{ name: "sku", type: "text", required: false, options: { max: 100 } },
{ name: "pricing_source", type: "text", required: false, options: { max: 50 } },
{ name: "matched_price_list_id", type: "text", required: false, options: { max: 50 } },
{ name: "pricing_notes", type: "text", required: false, options: { max: 2000 } },
] as const;
// Canonical item_status options (PB Select requires non-empty values).
// Mirrors what's in src/lib/types.ts union.
const ITEM_STATUS_VALUES = [
"pending",
"processed",
"po_generated",
"driver_pool",
"collected",
"assigned",
"delivered",
"cancelled",
];
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(`[order-items-pricing-fields] Authenticated against ${url}`);
const col = await pb.collections.getOne("order_items");
const fields = (col.fields as Array<{
name: string;
type: string;
required?: boolean;
options?: Record<string, unknown>;
}>).slice();
// ─── Step 1: add the four new text fields ─────────────────────────
let added = 0;
for (const target of NEW_TEXT_FIELDS) {
if (fields.some((f) => f.name === target.name)) {
console.log(
`[order-items-pricing-fields] ${target.name} already exists — skip`,
);
continue;
}
fields.push({ ...target });
added++;
}
console.log(
`[order-items-pricing-fields] New text fields added: ${added} of ${NEW_TEXT_FIELDS.length}`,
);
// ─── Step 2: repopulate item_status select values ─────────────────
let itemStatusTouched = false;
const itemStatusIdx = fields.findIndex((f) => f.name === "item_status");
if (itemStatusIdx === -1) {
console.warn(
"[order-items-pricing-fields] No `item_status` field found — skipping select-values fixup (manual check needed).",
);
} else {
const f = fields[itemStatusIdx];
const opts = (f.options ?? {}) as Record<string, unknown>;
const values = Array.isArray(opts.values) ? (opts.values as unknown[]) : [];
// Normalise: PB can accept either string[] or {value,label}[]; we
// pick the simplest representation the admin UI renders cleanly.
const existingStrings = values
.map((v) => (typeof v === "string" ? v : (v as { value?: string })?.value ?? ""))
.filter(Boolean);
const missing = ITEM_STATUS_VALUES.filter(
(v) => !existingStrings.includes(v),
);
if (missing.length === 0) {
console.log(
`[order-items-pricing-fields] item_status already has all ${ITEM_STATUS_VALUES.length} canonical values — skip`,
);
} else {
fields[itemStatusIdx] = {
...f,
required: true,
options: {
...opts,
values: ITEM_STATUS_VALUES,
},
};
itemStatusTouched = true;
console.log(
`[order-items-pricing-fields] item_status: added ${missing.length} missing values (${missing.join(", ")})`,
);
}
}
if (added === 0 && !itemStatusTouched) {
console.log(
"[order-items-pricing-fields] No fields needed updating — schema is already current.",
);
return;
}
await pb.collections.update(col.id, { fields });
console.log("[order-items-pricing-fields] Schema updated.");
// Re-fetch + verify
const fresh = await pb.collections.getOne("order_items");
const verifyFields = (fresh.fields as Array<{
name: string;
type: string;
required?: boolean;
options?: { values?: unknown[] };
}>)
.filter((f) =>
NEW_TEXT_FIELDS.some((t) => t.name === f.name) ||
f.name === "item_status",
)
.map((f) => {
if (f.name === "item_status") {
const vals = Array.isArray(f.options?.values)
? f.options!.values
.map((v) => (typeof v === "string" ? v : (v as { value?: string })?.value ?? ""))
.filter(Boolean)
: [];
return `${f.name}:${vals.length} values`;
}
return `${f.name}:${f.type}`;
})
.join(", ");
console.log(`[order-items-pricing-fields] Post-update → ${verifyFields}`);
console.log("[order-items-pricing-fields] Migration complete.");
}
main().catch((err) => {
console.error("[order-items-pricing-fields] Migration failed:", err);
process.exit(1);
});