Add Job Cards (vehicle-QR start/finish) and retire Operators/Mechanics pages

- Plant Clock "Job cards" tab: scan a vehicle QR to start a job, scan again to finish
  with odometer (or no meter), kind of work and a note
- /running/job-cards table with period, work, vehicle and mechanic filters
- Remove /running/operators and /running/mechanics; mechanic badge PDF moves to Employees

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
David 2026-10-22 12:54:59 +02:00
parent 29a035a4f2
commit c269cd00b4
19 changed files with 546 additions and 883 deletions

View file

@ -23,6 +23,7 @@ If a route fails any of the above, it is not done.
- `node scripts/setup-insurance-collections.mjs` → create the `insurance_rates` collection (idempotent).
- `node scripts/setup-financing-collections.mjs` → create the `financing_terms` collection (idempotent).
- `node scripts/setup-operators-collections.mjs` → create the `operators` collection (idempotent).
- `node scripts/setup-job-cards-collection.mjs` → create the `job_cards` collection (idempotent).
- `node scripts/setup-mechanics-collections.mjs` → create the `mechanics` collection (idempotent).
- `node scripts/setup-purchase-collections.mjs` → create the `purchases` / `purchase_lines` collections (idempotent).
- `node scripts/setup-oil-collections.mjs` → create the `oil_items` / `oil_stock_movements` collections (idempotent).
@ -810,10 +811,7 @@ request, 2026-10-22): **Employees** (`/employees`), **Attendance** (`/attendance
- `lib/workforce-store.ts` loads everything in one go. Any API that changes a person or an
allocation must `revalidatePath("/employees", "layout")`.
**`/running/operators` is now redundant** — everything on it (machine, rate, empty machines,
add operator, re-allocate) lives on the Employees pages. It stays until the client confirms
they are happy, then remove the page, its nav leaf and `OperatorsView`. The operators
**collection and API stay**: the Employees pages read and write through them.
**`/running/operators` has been removed** (client confirmed, 2026-10-22): its page, nav leaf and `OperatorsView` are gone. Everything it did lives on the Employees pages. The operators **collection and API stay** — Employees reads and writes through them.
## Color palette — never change
@ -911,3 +909,19 @@ This version has breaking changes — APIs, conventions, and file structure may
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
<!-- END:nextjs-agent-rules -->
## Job cards — two scans, one job
`/running/job-cards` (replaced the old `/running/mechanics` badge page, 2026-10-22) lists work
logged from the Plant Clock app's **Job cards** tab (`/clock/job-cards`).
- **First scan of a vehicle's QR starts a job** (who is working is picked from `mechanics`,
remembered per device). **Second scan finishes it**: odometer (or "no meter"), kind of work
(service / breakdown / general repairs) and a note. Time on the job = first scan → second.
- Collection `job_cards`; `status` is `open` or `closed`. A **partial unique index** allows only
one open job per vehicle, so two phones scanning at once cannot both start it.
- The vehicle QR is the register code (`TLB04`); matching is `assetFromQr` in `lib/stock-out.ts`.
- Not wiped by the seed. `node scripts/setup-job-cards-collection.mjs` creates it.
- The mechanic **badge register** now lives on `/employees` ("Download mechanic badges"); the
mechanics collection, API and badge QR rules above are unchanged.

View file

@ -1,17 +1,20 @@
import { ClipboardList } from 'lucide-react';
import { requireClockUser } from '@/lib/auth';
import { clockPB } from '@/lib/clock-store';
import { JobScanner } from '@/components/JobScanner';
export const dynamic = 'force-dynamic';
export const metadata = { title: 'Job cards · Mega Plant' };
export default async function JobCardsPage() {
await requireClockUser();
return <section className="plant-clock-page p-4 sm:p-6 max-w-3xl mx-auto">
<p className="plant-clock-eyebrow">MEGA PLANT</p>
<h1 className="text-2xl font-semibold tracking-tight mt-1">Job cards</h1>
<div className="plant-clock-panel mt-5 p-6 text-center">
<ClipboardList size={36} className="mx-auto text-text-muted"/>
<p className="text-base font-semibold mt-3">Coming soon</p>
<p className="text-sm text-text-muted mt-1">Job cards will be built next.</p>
</div>
</section>;
const pb = await clockPB();
const [assets, mechanics, open] = await Promise.all([
pb.collection('assets').getFullList({ sort: 'code', filter: 'is_active = true' }),
pb.collection('mechanics').getFullList({ sort: 'name' }),
pb.collection('job_cards').getFullList({ filter: 'status = "open"', sort: 'started_at' }),
]);
return <JobScanner
assets={assets.map(a => ({ id: a.id, code: a.code, name: a.name }))}
mechanics={mechanics.map(m => ({ id: m.id, name: m.name, code: m.employee_code || '' }))}
open={open.map(j => ({ id: j.id, asset: j.asset, mechanic: j.mechanic_name || '', startedAt: j.started_at }))}
/>;
}

View file

@ -67,8 +67,7 @@ const navItems: NavItem[] = [
{ href: "/running/financing", label: "Financing" },
{ href: "/running/purchases", label: "Purchases" },
{ href: "/running/oils", label: "Oil & Diesel Stock" },
{ href: "/running/operators", label: "Operators" },
{ href: "/running/mechanics", label: "Mechanics" },
{ href: "/running/job-cards", label: "Job Cards" },
],
},
{

View file

@ -0,0 +1,47 @@
import Link from "next/link";
import { ArrowLeft, ClipboardList } from "lucide-react";
import { serverAuth } from "@/lib/pb";
import { JobCardsTable, type JobRow } from "@/components/JobCardsTable";
export const dynamic = "force-dynamic";
/**
* Job cards: the work mechanics log from the Plant Clock app. A mechanic scans a vehicle's
* QR to start a job and scans it again to finish, giving the odometer, the kind of work
* and a note. Time on the job is first scan to second scan.
*/
export default async function JobCardsPage() {
const pb = await serverAuth();
const cards = await pb.collection("job_cards").getFullList({ expand: "asset", sort: "-started_at" });
const rows: JobRow[] = cards.map((j) => ({
id: j.id,
startedAt: String(j.started_at || ""),
endedAt: String(j.ended_at || ""),
open: j.status === "open",
asset: String(j.expand?.asset?.code || ""),
assetName: String(j.expand?.asset?.name || ""),
mechanic: String(j.mechanic_name || ""),
type: String(j.job_type || ""),
minutes: Number(j.duration_minutes || 0),
odometer: j.status === "closed" && !j.no_meter ? Number(j.odometer || 0) : null,
note: String(j.note || ""),
}));
return (
<div className="p-4 sm:p-8 max-w-[1400px]">
<Link href="/running" className="inline-flex min-h-[44px] items-center gap-1 text-xs text-gray-500 dark:text-gray-400 hover:text-[#30638E] mb-3">
<ArrowLeft className="w-3 h-3" /> Back to Running Costs
</Link>
<div className="mb-6">
<div className="flex items-center gap-3 mb-1">
<div className="w-10 h-10 shrink-0 rounded-xl bg-gradient-to-br from-[#30638E]/15 to-[#003D5B]/15 grid place-items-center text-[#30638E]">
<ClipboardList className="w-5 h-5" />
</div>
<h1 className="text-2xl font-semibold text-[#003D5B] dark:text-white">Job Cards</h1>
</div>
<p className="text-gray-500 dark:text-gray-400 text-sm">Work done on each vehicle, logged by mechanics in the Plant Clock app</p>
</div>
<JobCardsTable rows={rows} />
</div>
);
}

View file

@ -1,82 +0,0 @@
import Link from "next/link";
import { getMechanics } from "@/lib/server-data";
import { Wrench, ArrowLeft } from "lucide-react";
import { MechanicsBoard } from "@/components/MechanicsBoard";
// Reads PocketBase on every request. Without this Next tries to prerender the
// page at build time, which makes `docker build` depend on the database being
// reachable and then serves a frozen build-time snapshot to users.
export const dynamic = "force-dynamic";
/**
* The mechanic badge register.
*
* Separate from `/running/operators`: an operator is allocated TO a machine and is a
* cost; a mechanic is a person with an employee number whose badge is scanned on the
* yard. Nothing on this page touches `cost_lines` — it issues identities, it does not
* cost money.
*/
export default async function MechanicsPage() {
const mechanics = await getMechanics();
const ready = mechanics.filter((m) => (m.employee_code || "").trim()).length;
return (
<div className="p-4 sm:p-8 max-w-[1400px]">
<Link
href="/running"
className="inline-flex min-h-[44px] items-center gap-1 text-xs text-gray-500 dark:text-gray-400 hover:text-[#30638E] dark:hover:text-[#00798C] mb-3"
>
<ArrowLeft className="w-3 h-3" /> Back to Running Costs
</Link>
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between mb-6">
<div className="min-w-0">
<div className="flex items-center gap-3 mb-1">
<div className="w-10 h-10 shrink-0 rounded-xl bg-gradient-to-br from-[#30638E]/15 to-[#003D5B]/15 grid place-items-center text-[#30638E]">
<Wrench className="w-5 h-5" />
</div>
<h1 className="text-2xl font-semibold text-[#003D5B] dark:text-white">Mechanics</h1>
</div>
<p className="text-gray-500 dark:text-gray-400 text-sm">
Badge QR codes, linked to each mechanic&apos;s employee number
</p>
<p className="text-xs text-gray-400 dark:text-text-soft mt-1">
Each QR carries the name <span className="font-medium">and</span> the employee
number — no URL, so it scans anywhere without needing the app open.
</p>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6">
<StatCard label="Mechanics" value={`${mechanics.length}`} color="#30638E" />
<StatCard label="Badges ready" value={`${ready}`} color="#00798C" />
<StatCard label="Cannot print yet" value={`${mechanics.length - ready}`} color="#EDAE49" />
</div>
{mechanics.length === 0 ? (
<section className="bg-white dark:bg-surface rounded-2xl border border-gray-100 dark:border-border p-12 shadow-sm text-center">
<div className="text-sm text-gray-500 dark:text-gray-400">
No <strong className="text-gray-700 dark:text-gray-200">mechanics</strong> in PocketBase.
</div>
<div className="text-xs text-gray-400 dark:text-text-soft mt-1">
Add them to <code className="font-mono">data/source/mechanics.json</code> and re-run{" "}
<code className="font-mono">npm run seed</code>.
</div>
</section>
) : (
<MechanicsBoard mechanics={mechanics} />
)}
</div>
);
}
function StatCard({ label, value, color }: { label: string; value: string; color: string }) {
return (
<div className="bg-white dark:bg-surface rounded-2xl border border-gray-100 dark:border-border p-5 shadow-sm">
<div className="text-xs uppercase tracking-wider text-gray-500 dark:text-gray-400">{label}</div>
<div className="text-2xl font-semibold mt-2 tabular-nums" style={{ color }}>
{value}
</div>
</div>
);
}

View file

@ -1,97 +0,0 @@
import Link from "next/link";
import { getAssets, getOperators } from "@/lib/server-data";
import { ZAR2 } from "@/lib/utils";
import { Users, ArrowLeft } from "lucide-react";
import { OperatorsView } from "@/components/OperatorsView";
import { buildOperatorRows } from "@/lib/rows";
// Reads PocketBase on every request. Without this Next tries to prerender the
// page at build time, which makes `docker build` depend on the database being
// reachable and then serves a frozen build-time snapshot to users.
export const dynamic = "force-dynamic";
/**
* The operator register — TERM 3 of the fixed-expense formula, the wage that varies
* per machine per month. It was the last unknown in the workbook's cost model.
*
* Independent of Insurance and Financing: three separate commitments with separate
* collections. Nothing here touches `monthly_running_costs` or `cost_lines`, so this
* total is NOT part of the /running Overview figure.
*
* The row list comes from the PLANT REGISTER, not from the operator sheet, so a
* machine with nobody on it is visible. A machine with no operator carries blank
* cells — that is a gap to fill, not a zero.
*
* Rates are printed exactly as the sheet gives them. No VAT label: unlike the
* insurance premium and the finance instalment, nobody has stated a basis for this
* figure, and wages are not a standard-rated supply — so it is left unqualified
* rather than labelled with a guess.
*/
export default async function OperatorsPage() {
const [assets, operators] = await Promise.all([getAssets(), getOperators()]);
const rows = buildOperatorRows(assets, operators);
const staffed = rows.filter((r) => !r.isUnassignedOperator && r.operatorId).length;
const machines = rows.filter((r) => !r.isUnassignedOperator).length;
const unallocated = rows.filter((r) => r.isUnassignedOperator).length;
const hourlyTotal = operators.reduce((s, o) => s + (o.rate || 0), 0);
return (
<div className="p-4 sm:p-8 max-w-[1400px]">
<Link
href="/running"
className="inline-flex min-h-[44px] items-center gap-1 text-xs text-gray-500 dark:text-gray-400 hover:text-[#30638E] dark:hover:text-[#00798C] mb-3"
>
<ArrowLeft className="w-3 h-3" /> Back to Running Costs
</Link>
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between mb-6">
<div className="min-w-0">
<div className="flex items-center gap-3 mb-1">
<div className="w-10 h-10 shrink-0 rounded-xl bg-gradient-to-br from-[#7A9E06]/15 to-[#00638E]/15 grid place-items-center text-[#7A9E06]">
<Users className="w-5 h-5" />
</div>
<h1 className="text-2xl font-semibold text-[#003D5B] dark:text-white">Operators</h1>
</div>
<p className="text-gray-500 dark:text-gray-400 text-sm">
Who runs which machine, and their rate · {operators.length} operators
</p>
</div>
<div className="bg-gradient-to-br from-[#7A9E06] to-[#00638E] rounded-2xl px-5 py-3 text-white shadow-lg self-start sm:self-auto">
<div className="text-xs text-white/80">Combined rate / hour</div>
<div className="text-2xl font-semibold tabular-nums">{ZAR2(hourlyTotal)}</div>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6">
<StatCard label="Machines staffed" value={`${staffed} / ${machines}`} color="#30638E" />
<StatCard label="Machines empty" value={`${machines - staffed}`} color="#EDAE49" />
<StatCard label="Operators unallocated" value={`${unallocated}`} color="#7A9E06" />
</div>
{unallocated > 0 && (
<div className="mb-5 rounded-2xl border border-[#EDAE49]/30 bg-[#EDAE49]/[0.06] p-4 text-xs text-gray-600 dark:text-gray-400">
<span className="font-semibold text-[#003D5B] dark:text-white">
{unallocated} operator{unallocated !== 1 && "s"} on the payroll with no machine
</span>{" "}
— they are on the sheet without an allocation. Allocate them from the bottom of
the table.
</div>
)}
<OperatorsView rows={rows} />
</div>
);
}
function StatCard({ label, value, color }: { label: string; value: string; color: string }) {
return (
<div className="bg-white dark:bg-surface rounded-2xl border border-gray-100 dark:border-border p-5 shadow-sm">
<div className="text-xs uppercase tracking-wider text-gray-500 dark:text-gray-400">{label}</div>
<div className="text-2xl font-semibold mt-2 tabular-nums" style={{ color }}>
{value}
</div>
</div>
);
}

View file

@ -0,0 +1,41 @@
import { revalidatePath } from 'next/cache';
import { clockAccess, clockFailure, clockJson } from '@/lib/clock-api';
import { clockPB } from '@/lib/clock-store';
import { isJobType, MAX_NOTE, minutesBetween } from '@/lib/job-cards';
// POST /api/clock/job-finish { job_id, job_type, note, odometer?, no_meter? }
// The second scan: close the job with the odometer, the kind of work and what was done.
export async function POST(request: Request) {
try {
const user = await clockAccess(request);
if (user instanceof Response) return user;
let body: { job_id?: string; job_type?: unknown; note?: unknown; odometer?: unknown; no_meter?: unknown };
try { body = await request.json(); } catch { return Response.json({ error: 'Supply valid JSON.' }, { status: 400 }); }
const note = String(body.note ?? '').trim();
const noMeter = body.no_meter === true;
const odoRaw = String(body.odometer ?? '').trim();
const odometer = odoRaw === '' ? null : Number(odoRaw);
if (!body.job_id) return Response.json({ error: 'Missing job.' }, { status: 400 });
if (!isJobType(body.job_type)) return Response.json({ error: 'Choose service, breakdown or general repairs.' }, { status: 400 });
if (!note) return Response.json({ error: 'Write a note about what was done.' }, { status: 400 });
if (note.length > MAX_NOTE) return Response.json({ error: `Keep the note under ${MAX_NOTE} characters.` }, { status: 400 });
if (!noMeter && odometer === null) return Response.json({ error: 'Enter the odometer reading, or tick "no meter".' }, { status: 400 });
if (odometer !== null && (!Number.isFinite(odometer) || odometer < 0 || odometer > 99999999)) return Response.json({ error: 'The odometer reading must be a number.' }, { status: 400 });
const pb = await clockPB();
const job = await pb.collection('job_cards').getOne(body.job_id, { expand: 'asset' }).catch(() => null);
if (!job) return Response.json({ error: 'That job no longer exists.' }, { status: 404 });
if (job.status !== 'open') return Response.json({ error: 'That job is already finished.' }, { status: 409 });
const endedAt = new Date().toISOString();
const minutes = minutesBetween(job.started_at, endedAt);
await pb.collection('job_cards').update(job.id, {
status: 'closed', ended_at: endedAt, duration_minutes: minutes,
job_type: body.job_type, note, no_meter: noMeter, odometer: noMeter ? 0 : odometer,
ended_by: String(user.user.name || user.user.email || user.user.id),
});
revalidatePath('/running/job-cards');
return clockJson({ id: job.id, asset: String(job.expand?.asset?.code || ''), minutes });
} catch (e) { return clockFailure(e); }
}

View file

@ -0,0 +1,40 @@
import { revalidatePath } from 'next/cache';
import { clockAccess, clockFailure, clockJson } from '@/lib/clock-api';
import { clockPB } from '@/lib/clock-store';
// POST /api/clock/job-start { asset_id, mechanic_id }
// A mechanic scanned a vehicle's QR and no job was open on it: open one, clock started now.
export async function POST(request: Request) {
try {
const user = await clockAccess(request);
if (user instanceof Response) return user;
let body: { asset_id?: string; mechanic_id?: string };
try { body = await request.json(); } catch { return Response.json({ error: 'Supply valid JSON.' }, { status: 400 }); }
if (!body.asset_id || !body.mechanic_id) return Response.json({ error: 'Choose the vehicle and who is working on it.' }, { status: 400 });
const pb = await clockPB();
const [asset, mechanic] = await Promise.all([
pb.collection('assets').getOne(body.asset_id).catch(() => null),
pb.collection('mechanics').getOne(body.mechanic_id).catch(() => null),
]);
if (!asset) return Response.json({ error: 'That vehicle is not in the plant register.' }, { status: 404 });
if (!mechanic) return Response.json({ error: 'That mechanic is not on the register.' }, { status: 404 });
const open = await pb.collection('job_cards').getFullList({ filter: pb.filter('asset = {:a} && status = "open"', { a: asset.id }) });
if (open.length) return Response.json({ error: `${asset.code} already has a job open (started by ${open[0].mechanic_name}). Scan it again to finish that job.` }, { status: 409 });
let job;
try {
job = await pb.collection('job_cards').create({
asset: asset.id, mechanic_name: mechanic.name, mechanic_code: mechanic.employee_code || '',
started_at: new Date().toISOString(), status: 'open',
started_by: String(user.user.name || user.user.email || user.user.id),
});
} catch {
// The unique "one open job per vehicle" index: someone else started it a moment ago.
return Response.json({ error: `${asset.code} was just started by someone else. Scan it again to finish that job.` }, { status: 409 });
}
revalidatePath('/running/job-cards');
return clockJson({ id: job.id, asset: asset.code, mechanic: mechanic.name, at: job.started_at });
} catch (e) { return clockFailure(e); }
}

View file

@ -53,7 +53,7 @@ export async function POST(req: Request) {
source: "Added in app",
});
revalidatePath("/running/mechanics");
revalidatePath("/employees", "layout");
revalidatePath("/running");
revalidatePath("/employees", "layout");
return Response.json({

View file

@ -44,7 +44,6 @@ export async function PATCH(
if (rate !== null) {
const rounded = Math.round(rate * 100) / 100;
await pb.collection("operators").update(id, { rate: rounded, rate_set_in_app: true });
revalidatePath("/running/operators");
revalidatePath("/employees", "layout");
if (!("assetId" in b)) return Response.json({ ok: true, operator: { id, name: operator.name, rate: rounded } });
}
@ -53,7 +52,6 @@ export async function PATCH(
if (!wanted) {
// Unallocate. Clearing a relation with "" is PocketBase's own convention.
await pb.collection("operators").update(id, { asset: "" });
revalidatePath("/running/operators");
revalidatePath("/running");
revalidatePath("/employees", "layout");
return Response.json({ ok: true, operator: { id, name: operator.name, asset: null } });
@ -77,7 +75,6 @@ export async function PATCH(
const alreadyOn = all.find((o) => o.asset === asset!.id && o.id !== id);
await pb.collection("operators").update(id, { asset: wanted });
revalidatePath("/running/operators");
revalidatePath("/running");
revalidatePath("/employees", "layout");

View file

@ -69,7 +69,6 @@ export async function POST(req: Request) {
source: "Added in app",
});
revalidatePath("/running/operators");
revalidatePath("/running");
revalidatePath("/employees", "layout");
return Response.json({

View file

@ -0,0 +1,118 @@
"use client";
import { useMemo, useState } from "react";
import { inBounds, presetBounds, saDay, type LogPreset } from "@/lib/oil-log";
import { formatMinutes, JOB_TYPES, jobTypeLabel } from "@/lib/job-cards";
export type JobRow = {
id: string; startedAt: string; endedAt: string; open: boolean; asset: string; assetName: string;
mechanic: string; type: string; minutes: number; odometer: number | null; note: string;
};
const PRESETS: { id: LogPreset; label: string }[] = [
{ id: "today", label: "Today" }, { id: "week", label: "This week" }, { id: "month", label: "This month" },
{ id: "all", label: "Everything" }, { id: "range", label: "Date range" },
];
const fmt = (n: number) => n.toLocaleString("en-ZA", { maximumFractionDigits: 1 });
const when = (s: string) => (s ? new Date(s.replace(" ", "T")).toLocaleString("en-ZA", {
timeZone: "Africa/Johannesburg", day: "numeric", month: "short", hour: "2-digit", minute: "2-digit", hourCycle: "h23" }) : "—");
export function JobCardsTable({ rows }: { rows: JobRow[] }) {
const [preset, setPreset] = useState<LogPreset>("month");
const [from, setFrom] = useState("");
const [to, setTo] = useState("");
const [type, setType] = useState("");
const [asset, setAsset] = useState("");
const [mechanic, setMechanic] = useState("");
const assets = useMemo(() => [...new Set(rows.map((r) => r.asset))].filter(Boolean).sort(), [rows]);
const mechanics = useMemo(() => [...new Set(rows.map((r) => r.mechanic))].filter(Boolean).sort(), [rows]);
const shown = useMemo(() => {
const bounds = presetBounds(preset, saDay(new Date().toISOString()), from, to);
return rows.filter((r) => inBounds(saDay(r.startedAt), bounds) && (!type || r.type === type)
&& (!asset || r.asset === asset) && (!mechanic || r.mechanic === mechanic));
}, [rows, preset, from, to, type, asset, mechanic]);
const done = shown.filter((r) => !r.open);
const totalMinutes = done.reduce((s, r) => s + r.minutes, 0);
const field = "min-h-[44px] rounded-lg border border-gray-200 dark:border-border bg-white dark:bg-surface px-3 text-sm";
return (
<div className="bg-white dark:bg-surface border border-gray-100 dark:border-border rounded-2xl p-4 sm:p-5 shadow-sm">
<div className="flex flex-col lg:flex-row lg:items-center gap-3 mb-3">
<div className="hidden lg:flex gap-1.5" role="group" aria-label="Period">
{PRESETS.map((p) => (
<button key={p.id} onClick={() => setPreset(p.id)} aria-pressed={preset === p.id}
className={`min-h-[44px] px-4 rounded-lg text-xs font-medium border ${preset === p.id ? "bg-[#30638E] text-white border-[#30638E]" : "border-gray-200 dark:border-border text-gray-600 dark:text-gray-300"}`}>
{p.label}
</button>
))}
</div>
<select className={`${field} lg:hidden`} value={preset} onChange={(e) => setPreset(e.target.value as LogPreset)} aria-label="Period">
{PRESETS.map((p) => <option key={p.id} value={p.id}>{p.label}</option>)}
</select>
{preset === "range" && (
<div className="flex items-center gap-2">
<input type="date" className={field} value={from} max={to || undefined} onChange={(e) => setFrom(e.target.value)} aria-label="From" />
<span className="text-xs text-gray-500">to</span>
<input type="date" className={field} value={to} min={from || undefined} onChange={(e) => setTo(e.target.value)} aria-label="To" />
</div>
)}
<div className="flex flex-wrap gap-2 lg:ml-auto">
<select className={field} value={type} onChange={(e) => setType(e.target.value)} aria-label="Kind of work">
<option value="">All work</option>{JOB_TYPES.map((t) => <option key={t.id} value={t.id}>{t.label}</option>)}
</select>
<select className={field} value={asset} onChange={(e) => setAsset(e.target.value)} aria-label="Vehicle">
<option value="">All vehicles</option>{assets.map((a) => <option key={a} value={a}>{a}</option>)}
</select>
<select className={field} value={mechanic} onChange={(e) => setMechanic(e.target.value)} aria-label="Mechanic">
<option value="">All mechanics</option>{mechanics.map((m) => <option key={m} value={m}>{m}</option>)}
</select>
</div>
</div>
<div className="flex flex-wrap gap-2 mb-3 text-xs font-medium">
<span className="px-3 py-1.5 rounded-lg bg-[#30638E]/10 text-[#30638E]">{shown.length} job{shown.length !== 1 && "s"}</span>
<span className="px-3 py-1.5 rounded-lg bg-[#00798C]/10 text-[#00798C]">{formatMinutes(totalMinutes)} on finished jobs</span>
{shown.some((r) => r.open) && <span className="px-3 py-1.5 rounded-lg bg-[#EDAE49]/20 text-[#8a5d00]">{shown.filter((r) => r.open).length} in progress</span>}
</div>
{shown.length === 0 ? (
<p className="text-xs text-gray-500 dark:text-gray-400 py-4">
{rows.length === 0 ? "No job cards yet. Mechanics start one by scanning a vehicle's QR in the Plant Clock app." : "No jobs match these filters."}
</p>
) : (
<div className="overflow-x-auto [-webkit-overflow-scrolling:touch]">
<table className="w-full text-sm min-w-[900px]">
<thead>
<tr className="text-left text-xs text-gray-500 dark:text-gray-400">
<th className="sticky left-0 bg-white dark:bg-surface py-2 pr-3 font-medium">Vehicle</th>
<th className="py-2 pr-3 font-medium">Mechanic</th>
<th className="py-2 pr-3 font-medium">Started</th>
<th className="py-2 pr-3 font-medium">Finished</th>
<th className="py-2 pr-3 font-medium text-right">Time</th>
<th className="py-2 pr-3 font-medium">Work</th>
<th className="py-2 pr-3 font-medium text-right">Odometer</th>
<th className="py-2 font-medium">Note</th>
</tr>
</thead>
<tbody>
{shown.map((r) => (
<tr key={r.id} className="border-t border-gray-100 dark:border-border align-top">
<td className="sticky left-0 bg-white dark:bg-surface py-2 pr-3 whitespace-nowrap"><span className="font-mono font-semibold">{r.asset || "—"}</span><span className="block text-xs text-gray-500">{r.assetName}</span></td>
<td className="py-2 pr-3">{r.mechanic || "—"}</td>
<td className="py-2 pr-3 whitespace-nowrap tabular-nums">{when(r.startedAt)}</td>
<td className="py-2 pr-3 whitespace-nowrap tabular-nums">{r.open ? <span className="px-2 py-0.5 rounded bg-[#EDAE49]/20 text-[#8a5d00] text-xs font-medium">In progress</span> : when(r.endedAt)}</td>
<td className="py-2 pr-3 text-right tabular-nums whitespace-nowrap">{r.open ? "—" : formatMinutes(r.minutes)}</td>
<td className="py-2 pr-3 whitespace-nowrap">{jobTypeLabel(r.type) || "—"}</td>
<td className="py-2 pr-3 text-right tabular-nums">{r.odometer != null ? fmt(r.odometer) : "—"}</td>
<td className="py-2 min-w-[240px] whitespace-pre-wrap break-words">{r.note || "—"}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}

132
components/JobScanner.tsx Normal file
View file

@ -0,0 +1,132 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { useRouter } from 'next/navigation';
import { AlertTriangle, CheckCircle2, Play, Square, Wrench, X } from 'lucide-react';
import { BadgeCamera } from './BadgeCamera';
import { SearchPicker } from './SearchPicker';
import { clockTime } from '@/lib/clock-display';
import { assetFromQr } from '@/lib/stock-out';
import { formatMinutes, JOB_TYPES, minutesBetween, type JobType } from '@/lib/job-cards';
type Asset = { id: string; code: string; name: string };
type Mechanic = { id: string; name: string; code: string };
type OpenJob = { id: string; asset: string; mechanic: string; startedAt: string };
const LAST_MECHANIC = 'mp:lastMechanic';
/**
* Scan a vehicle's QR. No open job on it → START one (pick who is working). An open job →
* FINISH it: odometer, kind of work and a note. The clock runs from the first scan.
*/
export function JobScanner({ assets, mechanics, open }: { assets: Asset[]; mechanics: Mechanic[]; open: OpenJob[] }) {
const router = useRouter();
const [asset, setAsset] = useState<Asset | null>(null);
const [mechanicId, setMechanicId] = useState('');
const [odometer, setOdometer] = useState('');
const [noMeter, setNoMeter] = useState(false);
const [jobType, setJobType] = useState<JobType | ''>('');
const [note, setNote] = useState('');
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null);
const [unknownScan, setUnknownScan] = useState('');
const [now, setNow] = useState(() => new Date().toISOString());
const lastScan = useRef({ value: '', until: 0 });
useEffect(() => { const t = setInterval(() => setNow(new Date().toISOString()), 30000); return () => clearInterval(t); }, []);
useEffect(() => { try { const m = localStorage.getItem(LAST_MECHANIC); if (m) setMechanicId(m); } catch { /* private mode */ } }, []);
const job = asset ? open.find(j => j.asset === asset.id) ?? null : null;
const assetById = (id: string) => assets.find(a => a.id === id);
function choose(next: Asset | null) {
setAsset(next); setOdometer(''); setNoMeter(false); setJobType(''); setNote(''); setUnknownScan('');
}
function onDecode(raw: string) {
if (asset || busy) return;
if (lastScan.current.value === raw && Date.now() < lastScan.current.until) return;
lastScan.current = { value: raw, until: Date.now() + 4000 };
const found = assetFromQr(raw, assets);
if (found) { setMessage(null); choose(found); navigator.vibrate?.(120); }
else setUnknownScan(raw.slice(0, 60));
}
async function call(url: string, body: object) {
setBusy(true); setMessage(null);
try {
const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || 'Could not save.');
return data;
} catch (e) {
setMessage({ ok: false, text: e instanceof Error ? e.message : 'Could not save.' });
return null;
} finally { setBusy(false); }
}
async function start(e: React.FormEvent) {
e.preventDefault();
if (!asset || !mechanicId) return;
const data = await call('/api/clock/job-start', { asset_id: asset.id, mechanic_id: mechanicId });
if (!data) return;
try { localStorage.setItem(LAST_MECHANIC, mechanicId); } catch { /* ignore */ }
setMessage({ ok: true, text: `Job started on ${data.asset} for ${data.mechanic} at ${clockTime(data.at)}.` });
choose(null); router.refresh(); navigator.vibrate?.(120);
}
async function finish(e: React.FormEvent) {
e.preventDefault();
if (!job || !jobType) return;
const data = await call('/api/clock/job-finish', { job_id: job.id, job_type: jobType, note, odometer, no_meter: noMeter });
if (!data) return;
setMessage({ ok: true, text: `Job on ${data.asset} finished — ${formatMinutes(data.minutes)} on the job.` });
choose(null); router.refresh(); navigator.vibrate?.(120);
}
return <section className="plant-clock-page p-4 sm:p-6 max-w-3xl mx-auto">
<p className="plant-clock-eyebrow">MEGA PLANT</p>
<h1 className="text-2xl font-semibold tracking-tight mt-1">Job cards</h1>
{message && <div role={message.ok ? 'status' : 'alert'} className={`plant-clock-banner mt-4 text-white ${message.ok ? 'bg-[#00798C]' : 'bg-[#D1495B]'}`}>
{message.ok ? <CheckCircle2 size={26} className="shrink-0"/> : <AlertTriangle size={26} className="shrink-0"/>}
<p className="text-base font-semibold break-words">{message.text}</p></div>}
{!asset ? <>
<div className="mt-4"><BadgeCamera onDecode={onDecode} paused={busy} hint="Scan the vehicle QR to start or finish a job"/></div>
{unknownScan && <p role="alert" className="plant-clock-warning mt-3">That QR is not a vehicle in the plant register ({unknownScan}).</p>}
<div className="plant-clock-panel mt-5 p-4 sm:p-5">
<h2 className="text-base font-semibold flex items-center gap-2"><Wrench size={16}/>Jobs open now</h2>
{open.length === 0 ? <p className="text-sm text-text-muted mt-3">No jobs running. Scan a vehicle to start one.</p>
: <ul className="divide-y divide-border mt-2">{open.map(j => {
const a = assetById(j.asset);
return <li key={j.id}><button type="button" disabled={!a} onClick={() => a && choose(a)} className="w-full min-h-[56px] py-3 flex items-center gap-3 text-left">
<span className="min-w-0 flex-1"><span className="block text-sm font-semibold">{a?.code ?? 'Unknown vehicle'} <span className="font-normal text-text-muted">· {a?.name}</span></span>
<span className="block text-xs text-text-muted">{j.mechanic} · started {clockTime(j.startedAt.replace(' ', 'T'))} · {formatMinutes(minutesBetween(j.startedAt, now))}</span></span>
<span className="plant-clock-status shrink-0">Finish</span></button></li>;
})}</ul>}
</div>
</> : !job ? <form onSubmit={start} className="plant-clock-panel mt-4 p-4 sm:p-5">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0"><h2 className="text-lg font-semibold break-words">{asset.code} <span className="font-normal text-text-muted">· {asset.name}</span></h2>
<p className="text-sm text-text-muted mt-1">No job open — this starts one, clock running from now.</p></div>
<button type="button" onClick={() => choose(null)} className="plant-clock-button plant-clock-secondary shrink-0"><X size={16}/>Cancel</button>
</div>
<label htmlFor="mechanic" className="block text-sm font-medium mt-4">Who is working on it?</label>
<SearchPicker id="mechanic" placeholder="Search by name or number…" value={mechanicId} onChange={setMechanicId}
options={mechanics.map(m => ({ id: m.id, label: m.name, sub: m.code }))}/>
<button className="plant-clock-button plant-clock-primary w-full mt-5" type="submit" disabled={busy || !mechanicId}><Play size={16}/>{busy ? 'Starting…' : 'Start job'}</button>
</form> : <form onSubmit={finish} className="plant-clock-panel mt-4 p-4 sm:p-5">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0"><h2 className="text-lg font-semibold break-words">{asset.code} <span className="font-normal text-text-muted">· {asset.name}</span></h2>
<p className="text-sm text-text-muted mt-1">{job.mechanic} · started {clockTime(job.startedAt.replace(' ', 'T'))} · {formatMinutes(minutesBetween(job.startedAt, now))} so far</p></div>
<button type="button" onClick={() => choose(null)} className="plant-clock-button plant-clock-secondary shrink-0"><X size={16}/>Cancel</button>
</div>
<label htmlFor="odo" className="block text-sm font-medium mt-4">Odometer / hour meter</label>
<input id="odo" className="plant-clock-input mt-1 text-2xl tabular-nums" type="number" inputMode="decimal" min="0" step="any" disabled={noMeter} value={odometer} onChange={e => setOdometer(e.target.value)} placeholder="Current reading"/>
<label className="flex items-center gap-3 min-h-[44px] text-sm mt-1"><input type="checkbox" className="h-5 w-5" checked={noMeter} onChange={e => setNoMeter(e.target.checked)}/>This machine has no meter</label>
<p className="block text-sm font-medium mt-3">What kind of work was it?</p>
<div className="grid grid-cols-3 gap-2 mt-1" role="radiogroup" aria-label="Kind of work">{JOB_TYPES.map(t =>
<button key={t.id} type="button" role="radio" aria-checked={jobType === t.id} onClick={() => setJobType(t.id)}
className={`plant-clock-button min-h-[56px] ${jobType === t.id ? 'plant-clock-primary' : 'plant-clock-secondary'}`}>{t.label}</button>)}</div>
<label htmlFor="note" className="block text-sm font-medium mt-4">What was done?</label>
<textarea id="note" className="plant-clock-input mt-1 min-h-[110px]" maxLength={1000} value={note} onChange={e => setNote(e.target.value)} placeholder="e.g. Changed engine oil and filters, greased all points"/>
<button className="plant-clock-button plant-clock-primary w-full mt-5" type="submit" disabled={busy || !jobType || !note.trim() || (!noMeter && !odometer.trim())}><Square size={16}/>{busy ? 'Saving…' : 'Finish job'}</button>
</form>}
</section>;
}

View file

@ -1,288 +0,0 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Download, Wrench, X, TriangleAlert, Loader2, UserPlus } from "lucide-react";
import type { Mechanic } from "@/lib/data";
import { mechanicQrPayload, isBadgeReady } from "@/lib/mechanics";
import { QrCode } from "@/components/QrCode";
import { buildBadgesPdf, downloadPdf } from "@/lib/qr-pdf";
import { cn } from "@/lib/utils";
const ACCENT = "#30638E";
/**
* A mechanic's badge. The same component renders the small inline QR on the list, the
* large one in the popup and the one on the print sheet — they CANNOT drift, because
* there is only one implementation and one payload builder.
*
* The QR comes from `QrCode`, which draws SVG rects from the shared matrix in
* `lib/qr.ts` — NOT a canvas. A printed badge is the whole point, and vector modules
* stay sharp at any printer resolution while a canvas rasterises at its drawn size.
*/
export function MechanicBadge({
mechanic,
size = 96,
className,
}: {
mechanic: Mechanic;
size?: number;
className?: string;
}) {
const ready = isBadgeReady(mechanic);
return (
<div className={cn("bg-white p-2 rounded-xl border border-gray-200", className)}>
{ready ? (
// Drawn from the SAME matrix the PDF uses (see lib/qr.ts) — one encoder, two
// surfaces, so a printed badge can never carry a different code than the screen.
<QrCode value={mechanicQrPayload(mechanic)} size={size} label={`${mechanic.name} badge code`} />
) : (
<div
style={{ width: size, height: size }}
className="grid place-items-center text-center text-[10px] text-gray-400 border border-dashed border-gray-300"
>
no employee
<br />
number
</div>
)}
</div>
);
}
export function MechanicsBoard({ mechanics }: { mechanics: Mechanic[] }) {
const router = useRouter();
const [openId, setOpenId] = useState<string | null>(null);
const [building, setBuilding] = useState(false);
const [adding, setAdding] = useState(false);
const [addingBusy, setAddingBusy] = useState(false);
async function addMechanic(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const fd = new FormData(e.currentTarget);
setAddingBusy(true);
try {
const res = await fetch("/api/mechanics", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: fd.get("name"),
employeeCode: fd.get("employeeCode"),
role: fd.get("role"),
}),
});
const body = await res.json();
if (body.ok) {
setAdding(false);
router.refresh();
} else {
alert(body.error || "Could not add the mechanic");
}
} catch {
alert("Could not save — check your connection");
} finally {
setAddingBusy(false);
}
}
// A REAL PDF file, generated in the browser. Not a print dialog, not the HTML page:
// the client asked for a file they can send to a printer, so that is what they get.
async function downloadAll() {
setBuilding(true);
try {
const bytes = await buildBadgesPdf(mechanics);
downloadPdf(bytes, "mega-plant-mechanic-badges.pdf");
} catch {
alert("Could not build the PDF. Please try again.");
} finally {
setBuilding(false);
}
}
async function downloadOne(m: Mechanic) {
setBuilding(true);
try {
const bytes = await buildBadgesPdf([m]);
downloadPdf(bytes, `badge-${m.employee_code}.pdf`);
} catch {
alert("Could not build the PDF. Please try again.");
} finally {
setBuilding(false);
}
}
const open = mechanics.find((m) => m.id === openId) ?? null;
const notReady = mechanics.filter((m) => !isBadgeReady(m));
return (
<>
<div className="flex flex-col sm:flex-row sm:flex-wrap sm:items-center sm:justify-between gap-3 mb-5">
<div className="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400 flex-wrap">
<span className="px-2.5 py-1 rounded-md bg-[#30638E]/10 text-[#30638E] font-medium">
{mechanics.length} badge{mechanics.length !== 1 && "s"}
</span>
<span className="hidden md:inline">· tap a badge to enlarge it</span>
</div>
<button
onClick={downloadAll}
disabled={building}
className="min-h-[44px] px-4 rounded-lg text-xs font-medium inline-flex items-center gap-2 text-white transition disabled:opacity-60"
style={{ background: ACCENT }}
>
{building ? <Loader2 className="w-4 h-4 animate-spin" /> : <Download className="w-4 h-4" />}
{building ? "Building PDF…" : "Download QR codes"}
</button>
</div>
{!adding && (
<button
onClick={() => setAdding(true)}
className="mb-5 min-h-[44px] px-4 rounded-lg text-xs font-medium inline-flex items-center gap-2 border transition"
style={{ borderColor: `${ACCENT}55`, color: ACCENT }}
>
<UserPlus className="w-4 h-4" /> Add mechanic
</button>
)}
{adding && (
<section className="mb-5 rounded-2xl border p-4" style={{ borderColor: `${ACCENT}55`, background: `${ACCENT}0d` }}>
<div className="flex items-center justify-between gap-3 mb-3">
<span className="text-sm font-medium text-[#003D5B] dark:text-white">Add a mechanic</span>
<button
onClick={() => setAdding(false)}
aria-label="Close the add-mechanic panel"
className="min-h-[44px] min-w-[44px] grid place-items-center rounded-lg text-gray-500 hover:bg-white dark:hover:bg-white/5"
>
<X className="w-4 h-4" />
</button>
</div>
<form onSubmit={addMechanic} className="flex flex-col sm:flex-row sm:items-end gap-3">
<div className="flex-1 min-w-[150px]">
<label htmlFor="mech-name" className="block text-xs text-gray-500 dark:text-gray-400 mb-1.5">Name</label>
<input id="mech-name" name="name" required placeholder="e.g. THABO MOTSEPE"
className="min-h-[44px] w-full px-3 text-xs rounded-lg border border-gray-200 dark:border-white/10 bg-white dark:bg-white/5 text-gray-700 dark:text-gray-200" />
</div>
<div className="flex-1 min-w-[130px]">
<label htmlFor="mech-code" className="block text-xs text-gray-500 dark:text-gray-400 mb-1.5">Employee number</label>
<input id="mech-code" name="employeeCode" required placeholder="MCW###"
className="min-h-[44px] w-full px-3 text-xs rounded-lg border border-gray-200 dark:border-white/10 bg-white dark:bg-white/5 text-gray-700 dark:text-gray-200 font-mono" />
</div>
<div className="flex-1 min-w-[120px]">
<label htmlFor="mech-role" className="block text-xs text-gray-500 dark:text-gray-400 mb-1.5">Role</label>
<input id="mech-role" name="role" defaultValue="Mechanic"
className="min-h-[44px] w-full px-3 text-xs rounded-lg border border-gray-200 dark:border-white/10 bg-white dark:bg-white/5 text-gray-700 dark:text-gray-200" />
</div>
<button
type="submit" disabled={addingBusy}
className="min-h-[44px] px-4 rounded-lg text-xs font-semibold text-white transition disabled:opacity-60 inline-flex items-center gap-2"
style={{ background: ACCENT }}
>
{addingBusy ? <Loader2 className="w-4 h-4 animate-spin" /> : <UserPlus className="w-4 h-4" />}
Add
</button>
</form>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-3">
The employee number goes straight into the badge QR and must be unique. Added here,
this person is <span className="font-medium">not</span> on the mechanics sheet — they
will be kept when the seed runs again.
</p>
</section>
)}
{notReady.length > 0 && (
<div className="mb-5 flex items-start gap-2 rounded-2xl border border-[#D1495B]/30 bg-[#D1495B]/[0.06] p-4 text-xs text-gray-600 dark:text-gray-400">
<TriangleAlert className="w-4 h-4 mt-0.5 shrink-0 text-[#D1495B] dark:text-[#D94F60]" />
<span>
<span className="font-semibold text-[#003D5B] dark:text-white">
{notReady.length} mechanic{notReady.length !== 1 && "s"} without an employee number
</span>{" "}
— {notReady.map((m) => m.name).join(", ")}. A badge QR encodes the employee
number, so these cannot be printed until one is entered.
</span>
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{mechanics.map((m) => (
<button
key={m.id}
onClick={() => setOpenId(m.id)}
className="text-left bg-white dark:bg-surface rounded-2xl border border-gray-100 dark:border-border p-5 shadow-sm hover:shadow-md hover:border-[#30638E]/40 transition"
>
<div className="flex items-start gap-4">
<MechanicBadge mechanic={m} size={84} className="shrink-0" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 text-sm font-semibold text-[#003D5B] dark:text-white">
<Wrench className="w-3.5 h-3.5 shrink-0" style={{ color: ACCENT }} />
<span className="truncate">{m.name}</span>
</div>
<div className="mt-1.5 font-mono text-xs px-2 py-1 rounded-md bg-[#00798C]/10 text-[#00798C] inline-block">
{m.employee_code}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1.5">
{m.role || "Mechanic"}
</div>
</div>
</div>
<div className="mt-4 pt-3 border-t border-gray-100 dark:border-border text-xs text-gray-400 dark:text-text-soft">
Tap to enlarge · scans to {m.name} + {m.employee_code}
</div>
</button>
))}
</div>
{open && (
<div
className="fixed inset-0 z-[1400] bg-black/50 grid place-items-center p-4"
onClick={() => setOpenId(null)}
role="dialog"
aria-modal="true"
aria-label={`${open.name} badge`}
>
<div
onClick={(e) => e.stopPropagation()}
className="bg-white dark:bg-surface rounded-2xl border border-gray-200 dark:border-border shadow-2xl p-6 w-full max-w-sm relative"
>
<button
onClick={() => setOpenId(null)}
aria-label="Close"
className="absolute top-3 right-3 min-h-[44px] min-w-[44px] grid place-items-center rounded-lg text-gray-500 hover:bg-gray-100 dark:hover:bg-white/5"
>
<X className="w-4 h-4" />
</button>
<div className="text-center">
<div className="text-xs uppercase tracking-wider text-gray-400 dark:text-text-soft mb-3">
{open.role || "Mechanic"}
</div>
<MechanicBadge mechanic={open} size={220} className="inline-block" />
<div className="mt-4 text-lg font-semibold text-[#003D5B] dark:text-white">{open.name}</div>
<div className="mt-1 font-mono text-sm text-[#00798C]">{open.employee_code}</div>
<div className="mt-3 text-xs text-gray-500 dark:text-gray-400 break-all">
Encodes: {mechanicQrPayload(open)}
</div>
<div className="mt-5 flex gap-2 justify-center flex-wrap">
<button
onClick={() => downloadOne(open)}
disabled={building}
className="min-h-[44px] px-4 rounded-lg text-xs font-medium inline-flex items-center gap-2 text-white transition disabled:opacity-60"
style={{ background: ACCENT }}
>
{building ? <Loader2 className="w-4 h-4 animate-spin" /> : <Download className="w-4 h-4" />}
Download this badge
</button>
<button
onClick={downloadAll}
disabled={building}
className="min-h-[44px] px-4 rounded-lg text-xs font-medium inline-flex items-center gap-2 border border-gray-200 dark:border-white/10 text-gray-700 dark:text-gray-300 transition disabled:opacity-60"
>
<Download className="w-4 h-4" /> All badges
</button>
</div>
</div>
</div>
</div>
)}
</>
);
}

View file

@ -1,394 +0,0 @@
"use client";
import { useState, useRef, useEffect, useTransition } from "react";
import { useRouter } from "next/navigation";
import { UserRound, UserPlus, X, Check, TriangleAlert, Loader2 } from "lucide-react";
import { ScrollFade } from "@/components/ScrollFade";
import type { OperatorRow } from "@/lib/rows";
import { ZAR2 } from "@/lib/utils";
const ACCENT = "#7A9E06"; // the profit-line green — operators are people, not machines
export function OperatorsView({ rows }: { rows: OperatorRow[] }) {
const router = useRouter();
const [pending, startTransition] = useTransition();
const [savedId, setSavedId] = useState<string | null>(null);
const [assigningId, setAssigningId] = useState<string | null>(null);
const [adding, setAdding] = useState(false);
const [addingBusy, setAddingBusy] = useState(false);
const scroller = useRef<HTMLDivElement>(null);
const [overflows, setOverflows] = useState(false);
useEffect(() => {
const el = scroller.current;
if (!el) return;
const check = () => setOverflows(el.scrollWidth > el.clientWidth + 1);
check();
const ro = new ResizeObserver(check);
ro.observe(el);
return () => ro.disconnect();
}, [rows.length]);
async function allocate(operatorId: string, machineId: string | null) {
startTransition(async () => {
try {
const res = await fetch(`/api/operators/${operatorId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ assetId: machineId }),
});
const body = await res.json();
if (body.ok) {
setSavedId(operatorId);
setTimeout(() => setSavedId((s) => (s === operatorId ? null : s)), 1600);
if (body.sharedWith) {
alert(
`Heads up — ${body.operator.name} now shares ${body.operator.asset.code} with ${body.sharedWith.name}.`,
);
}
router.refresh();
} else {
alert(body.error || "Could not save");
}
} catch {
alert("Could not save — check your connection");
}
});
}
async function addOperator(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const fd = new FormData(e.currentTarget);
setAddingBusy(true);
try {
const res = await fetch("/api/operators", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: fd.get("name"),
employeeCode: fd.get("employeeCode"),
rate: fd.get("rate"),
assetId: fd.get("assetId") || "",
}),
});
const body = await res.json();
if (body.ok) {
setAdding(false);
router.refresh();
} else {
alert(body.error || "Could not add the operator");
}
} catch {
alert("Could not save — check your connection");
} finally {
setAddingBusy(false);
}
}
const machineRows = rows.filter((r) => !r.isUnassignedOperator);
const unassignedRows = rows.filter((r) => r.isUnassignedOperator);
const assigningRow = assigningId ? rows.find((r) => r.operatorId === assigningId) ?? null : null;
// EVERY machine is offered, not just the staffed ones. Filtering to staffed rows
// here meant the picker could not offer an EMPTY machine — so you could move an
// operator around but never staff one, which is the opposite of the point.
const options = machineRows;
return (
<div>
<div className="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400 flex-wrap mb-5">
<span className="px-2.5 py-1 rounded-md font-medium bg-[#7A9E06]/10 text-[#7A9E06]">
{machineRows.length} machines
</span>
<span className="px-2.5 py-1 rounded-md font-medium bg-gray-100 text-gray-500 dark:bg-white/5 dark:text-gray-400">
{machineRows.filter((r) => !r.operatorId).length} with no operator
</span>
{unassignedRows.length > 0 && (
<span className="px-2.5 py-1 rounded-md font-medium bg-[#EDAE49]/10 text-[#EDAE49]">
{unassignedRows.length} operator{unassignedRows.length !== 1 && "s"} unallocated
</span>
)}
</div>
{!adding && (
<button
onClick={() => setAdding(true)}
className="mb-4 min-h-[44px] px-4 rounded-lg text-xs font-medium inline-flex items-center gap-2 border transition"
style={{ borderColor: `${ACCENT}55`, color: ACCENT }}
>
<UserPlus className="w-4 h-4" /> Add operator
</button>
)}
{adding && (
<section className="mb-4 rounded-2xl border p-4" style={{ borderColor: `${ACCENT}55`, background: `${ACCENT}0d` }}>
<div className="flex items-center justify-between gap-3 mb-3">
<span className="text-sm font-medium text-[#003D5B] dark:text-white">Add an operator</span>
<button
onClick={() => setAdding(false)}
aria-label="Close the add-operator panel"
className="min-h-[44px] min-w-[44px] grid place-items-center rounded-lg text-gray-500 hover:bg-white dark:hover:bg-white/5"
>
<X className="w-4 h-4" />
</button>
</div>
<form onSubmit={addOperator} className="flex flex-col sm:flex-row sm:items-end gap-3 flex-wrap">
<div className="flex-1 min-w-[150px]">
<label htmlFor="op-name" className="block text-xs text-gray-500 dark:text-gray-400 mb-1.5">Name</label>
<input id="op-name" name="name" required placeholder="e.g. SIPHO NKOSI"
className="min-h-[44px] w-full px-3 text-xs rounded-lg border border-gray-200 dark:border-white/10 bg-white dark:bg-white/5 text-gray-700 dark:text-gray-200" />
</div>
<div className="flex-1 min-w-[130px]">
<label htmlFor="op-code" className="block text-xs text-gray-500 dark:text-gray-400 mb-1.5">Employee number</label>
<input id="op-code" name="employeeCode" required placeholder="MCW###"
className="min-h-[44px] w-full px-3 text-xs rounded-lg border border-gray-200 dark:border-white/10 bg-white dark:bg-white/5 text-gray-700 dark:text-gray-200 font-mono" />
</div>
<div className="flex-1 min-w-[110px]">
<label htmlFor="op-rate" className="block text-xs text-gray-500 dark:text-gray-400 mb-1.5">Rate / hour</label>
<input id="op-rate" name="rate" type="number" step="0.01" min="0" required placeholder="68.15"
className="min-h-[44px] w-full px-3 text-xs rounded-lg border border-gray-200 dark:border-white/10 bg-white dark:bg-white/5 text-gray-700 dark:text-gray-200 tabular-nums" />
</div>
<div className="flex-1 min-w-[150px]">
<label htmlFor="op-asset" className="block text-xs text-gray-500 dark:text-gray-400 mb-1.5">Machine (optional)</label>
<select id="op-asset" name="assetId" defaultValue=""
className="min-h-[44px] w-full px-3 text-xs rounded-lg border border-gray-200 dark:border-white/10 bg-white dark:bg-white/5 text-gray-700 dark:text-gray-200">
<option value="">— leave unallocated —</option>
{rows.filter((r) => !r.isUnassignedOperator && r.machineId).map((r) => (
<option key={r.machineId} value={r.machineId ?? ""}>{r.machineCode} · {r.machineName}</option>
))}
</select>
</div>
<button
type="submit" disabled={addingBusy}
className="min-h-[44px] px-4 rounded-lg text-xs font-semibold text-white transition disabled:opacity-60 inline-flex items-center gap-2"
style={{ background: ACCENT }}
>
{addingBusy ? <Loader2 className="w-4 h-4 animate-spin" /> : <UserPlus className="w-4 h-4" />}
Add
</button>
</form>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-3">
Added here, this person is <span className="font-medium">not</span> on the operator sheet — they
will be kept when the seed runs again.
</p>
</section>
)}
{/* Outside the table on purpose: inside a colSpan row it inherits the table's
full width and puts the controls ~600px off-screen on a 375px phone. */}
{assigningRow && (
<section className="mb-4 rounded-2xl border border-[#7A9E06]/30 bg-[#7A9E06]/[0.06] p-4">
<div className="flex items-center justify-between gap-3 mb-3">
<div className="flex items-center gap-2 min-w-0">
<span className="text-sm font-medium text-[#003D5B] dark:text-white truncate">
{assigningRow.operatorName}
</span>
<span className="font-mono text-xs text-gray-500 dark:text-gray-400">
{assigningRow.employeeCode}
</span>
<span className="text-xs text-gray-500 dark:text-gray-400 truncate hidden sm:inline">
· {ZAR2(assigningRow.rate ?? 0)} / hour
</span>
</div>
<button
onClick={() => setAssigningId(null)}
aria-label="Close the allocation panel"
className="min-h-[44px] min-w-[44px] grid place-items-center rounded-lg text-gray-500 dark:text-gray-400 hover:bg-white dark:hover:bg-white/5"
>
<X className="w-4 h-4" />
</button>
</div>
<AssignPicker
row={assigningRow}
options={options}
pending={pending}
onAllocate={(machineId) => {
allocate(assigningRow.operatorId!, machineId);
setAssigningId(null);
}}
/>
</section>
)}
<div className="bg-white dark:bg-surface rounded-2xl border border-gray-100 dark:border-border shadow-sm overflow-hidden">
<div className="relative">
<div ref={scroller} className="overflow-x-auto" style={{ WebkitOverflowScrolling: "touch" }}>
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider border-y border-gray-100 dark:border-border bg-gray-50 dark:bg-surface-muted">
<th className="text-left py-3 px-6 font-medium sticky left-0 z-10 bg-gray-50 dark:bg-surface-muted">Code</th>
<th className="text-left py-3 px-3 font-medium">Machine</th>
<th className="text-left py-3 px-3 font-medium">Operator</th>
<th className="text-left py-3 px-3 font-medium whitespace-nowrap">Employee code</th>
<th className="text-right py-3 px-3 font-medium whitespace-nowrap">
Rate
<span className="block normal-case tracking-normal text-[12px] text-gray-400 dark:text-text-soft">per hour</span>
</th>
<th className="text-right py-3 px-6 font-medium whitespace-nowrap">
<span className="sr-only">Allocate an operator</span>Allocate
</th>
</tr>
</thead>
<tbody>
{machineRows.map((r) => (
<tr key={r.machineId} className="border-b border-gray-50 dark:border-border hover:bg-gray-50/40 dark:hover:bg-white/[0.02]">
<td className="py-2.5 px-6 sticky left-0 z-10 bg-white dark:bg-surface">
<span className="font-mono text-xs px-2 py-1 rounded-md bg-[#00798C]/10 text-[#00798C] font-semibold">
{r.machineCode}
</span>
</td>
<td className="py-2.5 px-3 font-medium text-[#003D5B] dark:text-white whitespace-nowrap">
{r.machineName}
</td>
<td className="py-2.5 px-3 whitespace-nowrap">
{r.operatorId ? (
<span className="flex items-center gap-2 text-gray-700 dark:text-gray-300">
<UserRound className="w-3 h-3 shrink-0" style={{ color: ACCENT }} />
{r.operatorName}
</span>
) : (
<Blank />
)}
</td>
<td className="py-2.5 px-3 whitespace-nowrap text-gray-500 dark:text-gray-400">
{r.employeeCode || <Blank />}
</td>
<td className="py-2.5 px-3 text-right tabular-nums font-semibold whitespace-nowrap" style={{ color: ACCENT }}>
{r.rate !== null ? ZAR2(r.rate) : <Blank />}
</td>
<td className="py-2 px-6 text-right">
{r.operatorId ? (
<RowButton
busy={savedId === r.operatorId}
label={assigningId === r.operatorId ? "Close" : "Move"}
onClick={() => setAssigningId(assigningId === r.operatorId ? null : r.operatorId)}
/>
) : (
/* No operator to move from this row. You staff an empty
machine by allocating one of the unallocated operators at
the bottom of the table — saying "pick someone" here would
point at the machine column, which holds no names. */
<span className="text-[12px] text-gray-300 dark:text-text-soft">—</span>
)}
</td>
</tr>
))}
{unassignedRows.length > 0 && (
<tr>
<td colSpan={6} className="py-2 px-6 text-xs uppercase tracking-wider text-gray-400 dark:text-text-soft bg-gray-50 dark:bg-surface-muted">
Operators with no machine
</td>
</tr>
)}
{unassignedRows.map((r) => (
<tr key={r.operatorId} className="border-b border-gray-50 dark:border-border bg-[#EDAE49]/[0.04] hover:bg-[#EDAE49]/[0.08]">
<td className="py-2.5 px-6 sticky left-0 z-10 bg-[#FDF9F2] dark:bg-[#292934]">
<Blank />
</td>
<td className="py-2.5 px-3 whitespace-nowrap">
<Blank />
</td>
<td className="py-2.5 px-3 whitespace-nowrap">
<span className="flex items-center gap-2 font-medium text-[#003D5B] dark:text-white">
<UserRound className="w-3 h-3 shrink-0" style={{ color: ACCENT }} />
{r.operatorName}
</span>
</td>
<td className="py-2.5 px-3 whitespace-nowrap text-gray-500 dark:text-gray-400">
{r.employeeCode}
</td>
<td className="py-2.5 px-3 text-right tabular-nums font-semibold whitespace-nowrap" style={{ color: ACCENT }}>
{r.rate !== null ? ZAR2(r.rate) : <Blank />}
</td>
<td className="py-2 px-6 text-right">
<RowButton
busy={savedId === r.operatorId}
label={assigningId === r.operatorId ? "Close" : "Allocate"}
onClick={() => setAssigningId(assigningId === r.operatorId ? null : r.operatorId)}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
{overflows && <ScrollFade />}
</div>
</div>
</div>
);
}
/** Blank means "nobody here yet" — never a zero and never a dash that reads as zero. */
function Blank() {
return <span className="text-gray-300 dark:text-text-soft italic font-normal text-[12px]">none</span>;
}
function RowButton({ label, onClick, busy }: { label: string; onClick: () => void; busy: boolean }) {
return (
<button
onClick={onClick}
className="min-h-[44px] min-w-[88px] px-3 rounded-lg text-xs font-medium transition border inline-flex items-center justify-center gap-1.5 bg-[#7A9E06]/5 text-[#7A9E06] border-[#7A9E06]/25 hover:bg-[#7A9E06]/10"
>
{busy ? (
<>
<Check className="w-3.5 h-3.5" /> Saved
</>
) : (
label
)}
</button>
);
}
function AssignPicker({
row, options, pending, onAllocate,
}: {
row: OperatorRow;
options: OperatorRow[];
pending: boolean;
onAllocate: (machineId: string | null) => void;
}) {
const [value, setValue] = useState(row.machineId ?? "");
return (
<div>
<label htmlFor="assign-machine" className="block text-xs text-gray-500 dark:text-gray-400 mb-1.5">
Allocate to machine
</label>
<select
id="assign-machine"
value={value}
onChange={(e) => setValue(e.target.value)}
disabled={pending}
className="min-h-[44px] w-full sm:w-[340px] px-3 text-xs rounded-lg border border-gray-200 dark:border-white/10 bg-white dark:bg-white/5 text-gray-700 dark:text-gray-200"
>
<option value="">— not allocated to a machine —</option>
{options
.filter((o) => !o.isUnassignedOperator && o.machineId)
.map((o) => (
<option key={o.machineId} value={o.machineId ?? ""}>
{o.machineCode} · {o.machineName}
</option>
))}
</select>
<div className="flex items-center gap-2 mt-3">
<button
onClick={() => onAllocate(value || null)}
disabled={pending || value === (row.machineId ?? "")}
className="min-h-[44px] px-5 rounded-lg text-xs font-semibold text-white transition disabled:opacity-40"
style={{ background: ACCENT }}
>
Save allocation
</button>
<span className="text-xs text-gray-500 dark:text-gray-400 flex items-start gap-1.5">
<TriangleAlert className="w-3.5 h-3.5 mt-0.5 shrink-0 text-[#EDAE49]" />
Two people on one machine is allowed — you will be told if you create one.
</span>
</div>
</div>
);
}

View file

@ -0,0 +1,28 @@
'use client';
import { useState } from 'react';
type Option = { id: string; label: string; sub?: string };
/** Type to filter, tap to choose. A native <select> cannot be searched on a phone. */
export function SearchPicker({ id, options, value, onChange, placeholder }: { id: string; options: Option[]; value: string; onChange: (id: string) => void; placeholder: string }) {
const chosen = options.find(o => o.id === value);
const [query, setQuery] = useState('');
const [open, setOpen] = useState(false);
const needle = query.trim().toLowerCase();
const matches = needle ? options.filter(o => `${o.label} ${o.sub ?? ''}`.toLowerCase().includes(needle)) : options;
return <div className="relative mt-1">
<input id={id} className="plant-clock-input" autoComplete="off" role="combobox" aria-expanded={open} aria-controls={`${id}-list`} placeholder={placeholder}
value={open ? query : chosen ? `${chosen.label}${chosen.sub ? ` · ${chosen.sub}` : ''}` : ''}
onFocus={() => { setQuery(''); setOpen(true); }}
onChange={e => { setQuery(e.target.value); setOpen(true); if (value) onChange(''); }}
onBlur={() => setTimeout(() => setOpen(false), 150)}
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); if (matches[0]) { onChange(matches[0].id); setOpen(false); } } if (e.key === 'Escape') setOpen(false); }}/>
{open && <ul id={`${id}-list`} role="listbox" className="absolute z-30 mt-1 w-full max-h-64 overflow-y-auto rounded-xl border border-border bg-surface shadow-lg">
{matches.length === 0 ? <li className="px-3 py-3 text-sm text-text-muted">Nothing matches “{query}”.</li>
: matches.map(o => <li key={o.id} role="option" aria-selected={o.id === value}>
<button type="button" className="w-full min-h-[44px] px-3 text-left text-sm hover:bg-bg"
onMouseDown={e => e.preventDefault()} onClick={() => { onChange(o.id); setOpen(false); }}>
<span className="font-semibold">{o.label}</span>{o.sub ? ` · ${o.sub}` : ''}</button></li>)}
</ul>}
</div>;
}

View file

@ -2,9 +2,10 @@
import { useMemo, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { ChevronRight, Truck, UserPlus, X } from 'lucide-react';
import { ChevronRight, Download, Truck, UserPlus, X } from 'lucide-react';
import type { WorkforceData } from '@/lib/workforce-store';
import { attendanceSummary, employeeSlug } from '@/lib/workforce';
import { buildBadgesPdf, downloadPdf } from '@/lib/qr-pdf';
import { Kpi, StatusChip, rateText } from './WorkforceBits';
/**
@ -19,6 +20,14 @@ export function EmployeesView({data}:{data:WorkforceData}) {
const [kind,setKind]=useState<'operators'|'mechanics'>('operators');
const [busy,setBusy]=useState(false);
const [error,setError]=useState('');
const [building,setBuilding]=useState(false);
const mechanics=data.employees.filter(e=>e.kind==='mechanics' && e.employeeNo.trim());
async function downloadBadges() {
setBuilding(true);
try{downloadPdf(await buildBadgesPdf(mechanics.map(m=>({name:m.name,employee_code:m.employeeNo,role:m.role}))),'mega-plant-mechanic-badges.pdf');}
catch{alert('Could not build the PDF. Please try again.');}
finally{setBuilding(false);}
}
const status=useMemo(()=>{
const byEmployee=new Map<string,typeof data.events>();
@ -50,8 +59,11 @@ export function EmployeesView({data}:{data:WorkforceData}) {
<div className="flex flex-wrap justify-between items-start gap-4 mb-6">
<div><p className="plant-clock-eyebrow">MEGA PLANT / WORKFORCE</p><h1 className="text-3xl font-semibold tracking-tight mt-2">Employees</h1>
<p className="text-sm text-text-muted mt-2">Everyone on the Plant roster, the machine they run and their rate. Open someone for their dashboard.</p></div>
<div className="flex flex-wrap gap-2">
{mechanics.length>0 && <button className="plant-clock-button plant-clock-secondary" disabled={building} onClick={downloadBadges}><Download size={18}/>{building?'Building PDF…':'Download mechanic badges'}</button>}
{!adding && <button className="plant-clock-button plant-clock-primary" onClick={()=>{setError('');setAdding(true);}}><UserPlus size={18}/>Add employee</button>}
</div>
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-2 sm:gap-4 mb-5">
<Kpi label="Employees" value={data.employees.length}/>

21
lib/job-cards.ts Normal file
View file

@ -0,0 +1,21 @@
/** Kinds of work a job card can record. Order is the order they are offered. */
export const JOB_TYPES = [
{ id: "service", label: "Service" },
{ id: "breakdown", label: "Breakdown" },
{ id: "repair", label: "General repairs" },
] as const;
export type JobType = (typeof JOB_TYPES)[number]["id"];
export const jobTypeLabel = (id: string) => JOB_TYPES.find((t) => t.id === id)?.label ?? "";
export const isJobType = (v: unknown): v is JobType => JOB_TYPES.some((t) => t.id === v);
export const MAX_NOTE = 1000;
/** Whole minutes between two instants; never negative. PocketBase stamps look like "2026-10-22 08:15:00.000Z". */
export function minutesBetween(from: string, to: string): number {
const a = new Date(from.replace(" ", "T")).getTime();
const b = new Date(to.replace(" ", "T")).getTime();
if (!Number.isFinite(a) || !Number.isFinite(b)) return 0;
return Math.max(0, Math.round((b - a) / 60000));
}
export const formatMinutes = (m: number) => `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, "0")}m`;

View file

@ -0,0 +1,73 @@
// 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})`);
}
process.exit(0);