WFDASH-WEBAPP/scripts/migrations/2026-07-06-order-items-nullable-numbers.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

134 lines
5 KiB
TypeScript

/* eslint-disable */
/**
* Migration — make `order_items.quantity` and `order_items.selling_price`
* nullable.
*
* Why:
* The /orders/new page and the /orders/[id]/process page bind these
* fields to <Input type="number"> elements. Number inputs have two
* operator-hostile behaviours:
*
* 1. They can't be left empty by the user — clearing them snaps
* the value back to the previous number, or 0.
* 2. They commit a value on focus→blur even when the operator
* didn't type anything.
*
* The fix is to bind the fields to <input type="text" inputMode="decimal">
* elements (so the field is genuinely empty until the operator types),
* parse to number|null on blur, and ship null to PB when the field is
* blank. For null to round-trip, the PB columns must be nullable.
*
* Scope:
* - `order_items.quantity` → number, not required, no min.
* - `order_items.selling_price` → number, not required, no min.
*
* `cost_price` is intentionally left NON-nullable. It has a different
* default (0) and is the operator's authoritative number — leaving it
* nullable introduces a "what does a null cost mean?" ambiguity that
* the matcher / PO generator cannot resolve without an extra decision.
* If that ever changes, get a sign-off first.
*
* Idempotent — re-running after a partial success is safe (each field
* is rewritten with the target shape; PB's collections.update is
* idempotent when the resulting array matches the target).
*
* Run with:
* npx tsx scripts/migrations/2026-07-06-order-items-nullable-numbers.ts
*/
import PocketBase from "pocketbase";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
// ─── env loading (matches sibling migrations) ───────────────────────────
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");
const TARGET_NULLABLE_FIELDS = ["quantity", "selling_price"] as const;
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-nullable] 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>;
}>;
// Walk every field we want to be nullable, rewrite its `required`
// flag, and reassemble. We also tighten `options.min` if any leak
// remains (PB will keep prior min from earlier schema changes).
let touched = 0;
const updatedFields = fields.map((f) => {
if (
f.type === "number" &&
TARGET_NULLABLE_FIELDS.includes(f.name as (typeof TARGET_NULLABLE_FIELDS)[number])
) {
const prevRequired = f.required !== false;
if (!prevRequired) {
console.log(`[order-items-nullable] ${f.name} already nullable — skip`);
return f;
}
touched++;
const opts = { ...(f.options ?? {}) };
// Drop any `min` constraint — null must round-trip cleanly. The
// canSubmit gate on the form keeps zero-blank invariants honest.
delete (opts as Record<string, unknown>).min;
return { ...f, required: false, options: opts };
}
return f;
});
if (touched === 0) {
console.log(
"[order-items-nullable] No fields needed updating — schema is already nullable.",
);
return;
}
await pb.collections.update(col.id, { fields: updatedFields });
console.log(
`[order-items-nullable] Made nullable: ${TARGET_NULLABLE_FIELDS.join(", ")}. Verify in UI by saving an order item with quantity = null.`,
);
// Re-fetch so we can print the post-update shape for the operator.
const fresh = await pb.collections.getOne("order_items");
const verify = (fresh.fields as Array<{ name: string; required?: boolean }>)
.filter((f) => TARGET_NULLABLE_FIELDS.includes(f.name as (typeof TARGET_NULLABLE_FIELDS)[number]))
.map((f) => `${f.name}:required=${f.required !== false ? "true" : "false"}`)
.join(", ");
console.log(`[order-items-nullable] Post-update → ${verify}`);
console.log("[order-items-nullable] Migration complete.");
}
main().catch((err) => {
console.error("[order-items-nullable] Migration failed:", err);
process.exit(1);
});