mega-plant/scripts/setup-job-cards-collection.mjs
David 8f3a74cf6e Job cards oil path, Fuel tab diesel-only, Mechanic role, Plant Register QR column
- Job cards scanner: oil QR books litres + vehicle (no odo/photo), bundled onto the
  vehicle's job card (open job now, or the next job started the same day)
- Fuel tab is diesel only; /api/clock/oil-out added
- Users: Full / Clock-in user / Mechanic; mechanics see only Fuel + Job cards
- Plant Register: QR code column (double-click to set), kept across re-seeds and
  used when matching scans

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 13:44:01 +02:00

84 lines
4.3 KiB
JavaScript

// Create the `job_cards` PocketBase collection (idempotent).
//
// A job card is one stretch of work on one vehicle: a mechanic scans the vehicle's QR to
// START the job, scans it again to FINISH it and says what the odometer reads, what kind
// of work it was (service / breakdown / general repair) and what was done.
//
// Not wiped by the seed: job cards are history that exists nowhere else.
//
// Usage: node scripts/setup-job-cards-collection.mjs
import PocketBase from "pocketbase";
import { readFileSync, existsSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const envPath = resolve(ROOT, ".env.local");
if (!existsSync(envPath)) { console.error("✗ .env.local not found."); process.exit(1); }
for (const line of readFileSync(envPath, "utf8").split("\n")) {
const t = line.trim();
if (!t || t.startsWith("#")) continue;
const i = t.indexOf("=");
if (i > 0) process.env[t.slice(0, i).trim()] = t.slice(i + 1).trim();
}
const { NEXT_PUBLIC_PB_URL: PB_URL, PB_ADMIN, PB_PASS } = process.env;
if (!PB_URL || !PB_ADMIN || !PB_PASS) { console.error("✗ Missing NEXT_PUBLIC_PB_URL / PB_ADMIN / PB_PASS"); process.exit(1); }
const pb = new PocketBase(PB_URL);
pb.autoCancellation(false);
await pb.collection("_superusers").authWithPassword(PB_ADMIN, PB_PASS);
const ref = await pb.collections.getOne("monthly_income");
const assetsId = (await pb.collections.getOne("assets")).id;
const fields = [
{ name: "asset", type: "relation", collectionId: assetsId, required: true, minSelect: 1, maxSelect: 1, cascadeDelete: false },
{ name: "mechanic_name", type: "text" },
{ name: "mechanic_code", type: "text" },
{ name: "started_at", type: "date", required: true },
{ name: "ended_at", type: "date" },
// "open" while the job is running, "closed" once finished.
{ name: "status", type: "text", required: true },
// service | breakdown | repair — set when the job is finished.
{ name: "job_type", type: "text" },
{ name: "odometer", type: "number" },
{ name: "no_meter", type: "bool" },
{ name: "note", type: "text" },
{ name: "duration_minutes", type: "number" },
{ name: "started_by", type: "text" },
{ name: "ended_by", type: "text" },
];
// One OPEN job per vehicle, enforced by the database so two phones scanning at once cannot both start it.
const indexes = ["CREATE UNIQUE INDEX idx_job_cards_one_open ON job_cards (asset) WHERE status = 'open'"];
const existing = await pb.collections.getFullList({ filter: 'name = "job_cards"', perPage: 1 });
if (existing.length) {
const have = new Set(existing[0].fields.map((f) => f.name));
const absent = fields.filter((f) => !have.has(f.name));
if (absent.length) {
await pb.send(`/api/collections/job_cards`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ fields: [...existing[0].fields, ...absent] }) });
console.log(` ✓ added: ${absent.map((f) => f.name).join(", ")}`);
} else console.log(" · job_cards already present");
} else {
const created = await pb.send("/api/collections", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: "job_cards", type: "base", fields, indexes,
listRule: ref.listRule ?? "", viewRule: ref.viewRule ?? "",
createRule: ref.createRule ?? "", updateRule: ref.updateRule ?? "", deleteRule: ref.deleteRule ?? "",
}),
});
console.log(` ✓ created job_cards (${created.id})`);
}
// Oil booked out against a vehicle is bundled onto that vehicle's job card.
const jobsId = (await pb.collections.getOne("job_cards")).id;
const moves = await pb.collections.getOne("oil_stock_movements").catch(() => null);
if (!moves) console.log(" ! oil_stock_movements missing — run setup-oil-collections.mjs, then re-run this");
else if (moves.fields.some((f) => f.name === "job")) console.log(" · oil_stock_movements.job already present");
else {
await pb.send("/api/collections/oil_stock_movements", { method: "PATCH", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ fields: [...moves.fields, { name: "job", type: "relation", collectionId: jobsId, minSelect: 0, maxSelect: 1, cascadeDelete: false }] }) });
console.log(" ✓ added oil_stock_movements.job");
}
process.exit(0);