- 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>
84 lines
4.3 KiB
JavaScript
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);
|