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
296 lines
No EOL
10 KiB
TypeScript
296 lines
No EOL
10 KiB
TypeScript
/**
|
|
* Migration — consolidate PO documents onto `purchase_orders`.
|
|
*
|
|
* Adds two fields to the `purchase_orders` collection:
|
|
* - `documents` (file, multi) — the latest PO PDF + history.
|
|
* - `documents_history` (json) — read-only audit log of
|
|
* every regen (version,
|
|
* generatedAt, generatedBy,
|
|
* reason).
|
|
*
|
|
* Why consolidate:
|
|
* Today the PO PDF lives in a separate `purchase_order_pdfs` collection
|
|
* (one row per regen, status="current" flag for the latest). Every
|
|
* Send PO flow does THREE PB round-trips to fetch the attachment:
|
|
* 1. fetch PO
|
|
* 2. fetch latest row from `purchase_order_pdfs` for that PO
|
|
* 3. fetch the file bytes via the proxy
|
|
* Consolidating onto `purchase_orders.documents` (file, multi) shrinks
|
|
* this to TWO round-trips — and lets the webhook payload include the
|
|
* filename directly without a separate query. `documents_history`
|
|
* keeps the per-regen audit trail without a separate collection.
|
|
*
|
|
* Backfill:
|
|
* For every PO that already has `purchase_order_pdfs` rows, copy:
|
|
* - `documents` ← the file from the row with status="current"
|
|
* (or the highest-version row if no "current")
|
|
* - `documents_history` ← array of `{version, file, generatedAt,
|
|
* generatedBy, reason}` for ALL rows
|
|
* (ordered by version ASC), so future regens
|
|
* preserve the audit trail.
|
|
*
|
|
* Idempotent — skips the field-add when fields already exist, and the
|
|
* backfill is a no-op when documents is already populated.
|
|
*
|
|
* Run with:
|
|
* npx tsx scripts/migrations/2026-07-06-purchase-orders-documents-field.ts
|
|
*
|
|
* Archive step (separate, run after this is verified):
|
|
* After the dashboard reads from `documents` everywhere, mark the
|
|
* `purchase_order_pdfs` collection `is_active=false` in PB admin so
|
|
* new writes are blocked. The collection can stay around for audit
|
|
* queries (the file proxy still whitelists it).
|
|
*/
|
|
/* eslint-disable */
|
|
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 */
|
|
}
|
|
}
|
|
loadEnvFile(".env");
|
|
loadEnvFile(".env.local");
|
|
|
|
const PO_COLLECTION = "purchase_orders";
|
|
const PDF_COLLECTION = "purchase_order_pdfs";
|
|
|
|
interface PbField {
|
|
name: string;
|
|
type: string;
|
|
required?: boolean;
|
|
options?: Record<string, unknown>;
|
|
maxSelect?: number | null;
|
|
}
|
|
|
|
interface PdfRow {
|
|
id: string;
|
|
purchase_order: string;
|
|
file: string;
|
|
version: number;
|
|
status: string;
|
|
generated_at: string;
|
|
generated_by: string;
|
|
notes: string;
|
|
}
|
|
|
|
interface PoRow {
|
|
id: string;
|
|
documents?: string[];
|
|
documents_history?: Array<{
|
|
version: number;
|
|
file: string;
|
|
generatedAt: string;
|
|
generatedBy: string;
|
|
reason: string;
|
|
status: string;
|
|
}>;
|
|
}
|
|
|
|
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.",
|
|
);
|
|
}
|
|
|
|
const pb = new PocketBase(url);
|
|
await pb.collection("_superusers").authWithPassword(email, password);
|
|
console.log(`[po-documents] Authenticated against ${url}`);
|
|
|
|
// ─── 1. Add fields to purchase_orders ─────────────────────────────
|
|
const poCol = await pb.collections.getOne(PO_COLLECTION);
|
|
const poFields = (poCol.fields as PbField[]).slice();
|
|
|
|
const additions: PbField[] = [];
|
|
if (!poFields.some((f) => f.name === "documents")) {
|
|
additions.push({
|
|
name: "documents",
|
|
type: "file",
|
|
required: false,
|
|
options: {
|
|
maxSelect: 20,
|
|
maxSize: 10485760, // 10 MB per file — most PO PDFs are <500KB
|
|
mimeTypes: ["application/pdf", "image/png", "image/jpeg"],
|
|
// PB v0.22 needs these for file fields even though it's
|
|
// conceptually `[]` (no thumbs) for headless server usage.
|
|
thumbs: [],
|
|
},
|
|
});
|
|
console.log("[po-documents] documents field: ADD");
|
|
} else {
|
|
console.log("[po-documents] documents field: skip (already exists)");
|
|
}
|
|
if (!poFields.some((f) => f.name === "documents_history")) {
|
|
additions.push({
|
|
name: "documents_history",
|
|
type: "json",
|
|
required: false,
|
|
options: { maxSize: 50000 },
|
|
});
|
|
console.log("[po-documents] documents_history field: ADD");
|
|
} else {
|
|
console.log("[po-documents] documents_history field: skip (already exists)");
|
|
}
|
|
|
|
if (additions.length > 0) {
|
|
poFields.push(...additions);
|
|
await pb.collections.update(poCol.id, { fields: poFields });
|
|
console.log(
|
|
`[po-documents] purchase_orders — added ${additions.length} field(s)`,
|
|
);
|
|
}
|
|
|
|
// ─── 2. Backfill documents + documents_history from purchase_order_pdfs ─
|
|
// PB file fields don't accept plain filename strings — they need an
|
|
// actual file upload via FormData. So for each PO with PDF rows we
|
|
// need to:
|
|
// (a) download each PDF's bytes from PB (admin client URL)
|
|
// (b) re-upload to purchase_orders.documents via FormData
|
|
// (c) populate documents_history with the metadata (no file, just JSON)
|
|
const pdfList = await pb.collection(PDF_COLLECTION).getFullList<PdfRow>({
|
|
sort: "purchase_order,version",
|
|
});
|
|
console.log(`[po-documents] Found ${pdfList.length} purchase_order_pdfs rows`);
|
|
|
|
// Group by purchase_order
|
|
const byPo = new Map<string, PdfRow[]>();
|
|
for (const row of pdfList) {
|
|
const arr = byPo.get(row.purchase_order) ?? [];
|
|
arr.push(row);
|
|
byPo.set(row.purchase_order, arr);
|
|
}
|
|
console.log(`[po-documents] Grouped into ${byPo.size} PO(s)`);
|
|
|
|
let migrated = 0;
|
|
let skipped = 0;
|
|
let failed = 0;
|
|
|
|
for (const [poId, rows] of byPo) {
|
|
try {
|
|
// Skip if PO already has documents (re-run safety).
|
|
const current = await pb
|
|
.collection(PO_COLLECTION)
|
|
.getOne<PoRow>(poId, { fields: "id,documents,documents_history" });
|
|
|
|
if (
|
|
Array.isArray(current.documents) &&
|
|
current.documents.length > 0
|
|
) {
|
|
skipped++;
|
|
continue;
|
|
}
|
|
|
|
// Latest = status="current" first, else highest version.
|
|
const sorted = rows.slice().sort((a, b) => {
|
|
if (a.status === "current" && b.status !== "current") return -1;
|
|
if (b.status === "current" && a.status !== "current") return 1;
|
|
return b.version - a.version;
|
|
});
|
|
const latest = sorted[0];
|
|
|
|
// Build the history array (asc by version — newest at the END).
|
|
const history = rows
|
|
.slice()
|
|
.sort((a, b) => a.version - b.version)
|
|
.map((r) => ({
|
|
version: r.version,
|
|
file: r.file,
|
|
generatedAt: r.generated_at || "",
|
|
generatedBy: r.generated_by || "",
|
|
reason: r.notes || "",
|
|
status: r.status,
|
|
}));
|
|
|
|
// Download the latest PDF bytes via admin PB file URL.
|
|
const downloadUrl = pb.files.getURL(
|
|
{ collectionId: PDF_COLLECTION, id: latest.id },
|
|
latest.file,
|
|
);
|
|
const fileRes = await fetch(downloadUrl, {
|
|
headers: { Authorization: pb.authStore.token ?? "" },
|
|
});
|
|
if (!fileRes.ok) {
|
|
throw new Error(
|
|
`PDF download failed: HTTP ${fileRes.status} on ${downloadUrl}`,
|
|
);
|
|
}
|
|
const fileBlob = await fileRes.blob();
|
|
// PB expects a File object, not a Blob — wrap with the original name.
|
|
const file = new File([fileBlob], latest.file, {
|
|
type: fileRes.headers.get("content-type") || "application/pdf",
|
|
});
|
|
|
|
// Re-upload to purchase_orders.documents (file, multi — array of File).
|
|
// We only migrate the LATEST file (history captures the rest as
|
|
// metadata; if the operator ever wants to download an older
|
|
// version, they still can via the legacy `purchase_order_pdfs`
|
|
// collection until we archive it).
|
|
const formData = new FormData();
|
|
formData.append("documents", file);
|
|
const updated = await pb.collection(PO_COLLECTION).update<PurchaseOrder>(
|
|
poId,
|
|
formData,
|
|
);
|
|
|
|
// PB appends a random suffix to file uploads to prevent
|
|
// collisions (e.g. `foo.pdf` → `foo_uc56p4ttjl.pdf`). Read back
|
|
// the actual stored filename from the response and update the
|
|
// history entry to match — otherwise the download URL won't
|
|
// resolve. Same applies to every entry's `file` field.
|
|
const actualStoredFiles: string[] = Array.isArray(updated.documents)
|
|
? updated.documents
|
|
: updated.documents
|
|
? [updated.documents]
|
|
: [];
|
|
const lastStoredFile = actualStoredFiles[actualStoredFiles.length - 1];
|
|
|
|
const syncedHistory = history.map((h, idx) =>
|
|
// The LAST history entry corresponds to the LAST stored file.
|
|
// For pre-migration data we can only sync that one reliably —
|
|
// older versions stay archived in the legacy collection.
|
|
idx === history.length - 1 && lastStoredFile
|
|
? { ...h, file: lastStoredFile }
|
|
: h,
|
|
);
|
|
|
|
// Then write documents_history separately (it's JSON, not a file).
|
|
await pb.collection(PO_COLLECTION).update(poId, {
|
|
documents_history: syncedHistory,
|
|
});
|
|
|
|
migrated++;
|
|
console.log(
|
|
`[po-documents] PO ${poId}: ${rows.length} PDF row(s) → documents=[${latest.file}] (${fileBlob.size}B), history[${history.length}]`,
|
|
);
|
|
} catch (err) {
|
|
failed++;
|
|
console.warn(`[po-documents] PO ${poId}: failed to migrate:`, err);
|
|
}
|
|
}
|
|
|
|
console.log(
|
|
`[po-documents] Backfill complete — migrated=${migrated}, skipped=${skipped}, failed=${failed}`,
|
|
);
|
|
console.log("[po-documents] Migration complete.");
|
|
}
|
|
|
|
main().catch((err) => {
|
|
console.error("[po-documents] Migration failed:", err);
|
|
process.exit(1);
|
|
}); |