Improve workforce scanning, site transfers and timesheet exports. Audit plant imports, duplicate identities, rates and dashboard totals. Repair delivery PO selection, periodic rechecks and receiving guards. Align financial ledgers, supplier periods and mobile layouts. Add the site map preview, shared theme controls and audit evidence. Verify 3,455 application tests, six fuel export tests, TypeScript and an optimized production build. Clean up a leaked test timer and keep local worktrees and generated reports out of ESLint traversal. Changed-file lint passes; repository-wide lint retains 33 existing errors in unchanged files.
118 lines
4.7 KiB
TypeScript
118 lines
4.7 KiB
TypeScript
import { randomBytes } from "node:crypto";
|
|
import { dateLogged, workDate } from "@/lib/clock-day";
|
|
import { authorizeOperation } from "@/lib/auth/operation";
|
|
import { NextResponse } from "next/server";
|
|
import { pbAdmin } from "@/lib/pb";
|
|
import type { UnitType } from "@/lib/pb-types";
|
|
|
|
/**
|
|
* POST /api/vehicles
|
|
*
|
|
* Creates a new vehicle row (used by the "Add plant" modal on the
|
|
* Plant dashboard). Required: `description`, `site` (relation id) — a
|
|
* vehicle's project attribution comes entirely from `site`, never a
|
|
* denormalized copy on this row. Everything else is optional and may
|
|
* be backfilled later via PATCH /api/vehicles/[id] or in PB admin.
|
|
*/
|
|
export async function POST(request: Request) {
|
|
const denied = await authorizeOperation(request);
|
|
if (denied) return denied;
|
|
let body: Record<string, unknown>;
|
|
try {
|
|
body = await request.json();
|
|
if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error("Invalid object");
|
|
} catch {
|
|
return NextResponse.json({ error: "Body must be JSON" }, { status: 400 });
|
|
}
|
|
|
|
const description =
|
|
typeof body.description === "string" ? body.description.trim() : "";
|
|
if (!description) {
|
|
return NextResponse.json(
|
|
{ error: "description is required" },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
|
|
const site = typeof body.site === "string" ? body.site.trim() : "";
|
|
if (!site) {
|
|
return NextResponse.json(
|
|
{ error: "site is required" },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
|
|
// Optional fields — only include the key in the payload when the caller
|
|
// actually supplied a value, so PB doesn't reject "" for non-text fields
|
|
// (cost_per_hour / standard_work_hours are numeric in the schema).
|
|
const payload: Record<string, unknown> = {
|
|
description,
|
|
site,
|
|
};
|
|
|
|
if (typeof body.qr_code_id === "string") {
|
|
const qr = body.qr_code_id.trim().toUpperCase();
|
|
if (qr) payload.qr_code_id = qr;
|
|
}
|
|
|
|
if (typeof body.fuel_limits === "string") {
|
|
const fl = body.fuel_limits.trim();
|
|
if (fl) payload.fuel_limits = fl;
|
|
}
|
|
|
|
if (body.unit_type === "fuel" || body.unit_type === "plant") {
|
|
payload.unit_type = body.unit_type as UnitType;
|
|
}
|
|
|
|
const numField = (v: unknown): number | undefined => {
|
|
if (v === undefined || v === null || v === "") return undefined;
|
|
if (typeof v !== "number" && typeof v !== "string") return undefined;
|
|
const n = Number(v);
|
|
return Number.isFinite(n) ? n : undefined;
|
|
};
|
|
|
|
for (const field of ["cost_per_hour", "standard_work_hours", "initial_reading"]) {
|
|
if (body[field] !== undefined && body[field] !== null && body[field] !== "" && (numField(body[field]) === undefined || numField(body[field])! < 0)) {
|
|
return NextResponse.json({error:`${field} must be a non-negative number`},{status:400});
|
|
}
|
|
}
|
|
const standardWorkHours = numField(body.standard_work_hours);
|
|
if (standardWorkHours !== undefined) {
|
|
payload.standard_work_hours = standardWorkHours;
|
|
}
|
|
|
|
const initialReading = numField(body.initial_reading);
|
|
if (initialReading !== undefined && !payload.unit_type) return NextResponse.json({error:"Choose vehicle or equipment for an initial meter reading"},{status:400});
|
|
const costPerHour = numField(body.cost_per_hour);
|
|
if (costPerHour !== undefined) {
|
|
payload.cost_per_hour = costPerHour;
|
|
}
|
|
|
|
const pb = await pbAdmin();
|
|
try {
|
|
if (payload.qr_code_id) {
|
|
const matches = await pb.collection("vehicles").getFullList({fields:"id,qr_code_id"});
|
|
if (matches.some(v=>String(v.qr_code_id ?? "").trim().toUpperCase()===payload.qr_code_id)) return NextResponse.json({error:"This QR number is already in use. Open the existing vehicle or merge the duplicate."},{status:409});
|
|
}
|
|
let created;
|
|
if (initialReading !== undefined) {
|
|
const id=randomBytes(8).toString("hex").slice(0,15),now=new Date().toISOString();
|
|
const batch=pb.createBatch();
|
|
const vehicle={...payload,id};
|
|
batch.collection("vehicles").create(vehicle);
|
|
batch.collection("fuel_logs").create({vehicle:id,site,date_logged:dateLogged(workDate(now)),odo_reading:initialReading,litres:0,
|
|
source_system:"asset-initial",source_logged_at:now,import_key:`asset-initial:${id}`});
|
|
await batch.send();created=vehicle;
|
|
} else created=await pb.collection("vehicles").create(payload);
|
|
return NextResponse.json({ vehicle: created }, { status: 201 });
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : "Insert failed";
|
|
if (/UNIQUE|duplicate|idx_|validation_not_unique/i.test(msg + JSON.stringify((err as {data?:unknown}).data ?? {}))) {
|
|
return NextResponse.json(
|
|
{ error: msg },
|
|
{ status: 409 },
|
|
);
|
|
}
|
|
return NextResponse.json({ error: msg }, { status: 500 });
|
|
}
|
|
}
|