From c269cd00b4956f71bb6ee2e2cead639f7ffc057b Mon Sep 17 00:00:00 2001 From: David Date: Thu, 22 Oct 2026 12:54:59 +0200 Subject: [PATCH] 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 --- AGENTS.md | 22 +- app/(clock)/clock/job-cards/page.tsx | 23 +- app/(dashboard)/layout.tsx | 3 +- app/(dashboard)/running/job-cards/page.tsx | 47 +++ app/(dashboard)/running/mechanics/page.tsx | 82 ----- app/(dashboard)/running/operators/page.tsx | 97 ----- app/api/clock/job-finish/route.ts | 41 +++ app/api/clock/job-start/route.ts | 40 +++ app/api/mechanics/route.ts | 2 +- app/api/operators/[id]/route.ts | 3 - app/api/operators/route.ts | 1 - components/JobCardsTable.tsx | 118 ++++++ components/JobScanner.tsx | 132 +++++++ components/MechanicsBoard.tsx | 288 --------------- components/OperatorsView.tsx | 394 --------------------- components/SearchPicker.tsx | 28 ++ components/workforce/EmployeesView.tsx | 14 +- lib/job-cards.ts | 21 ++ scripts/setup-job-cards-collection.mjs | 73 ++++ 19 files changed, 546 insertions(+), 883 deletions(-) create mode 100644 app/(dashboard)/running/job-cards/page.tsx delete mode 100644 app/(dashboard)/running/mechanics/page.tsx delete mode 100644 app/(dashboard)/running/operators/page.tsx create mode 100644 app/api/clock/job-finish/route.ts create mode 100644 app/api/clock/job-start/route.ts create mode 100644 components/JobCardsTable.tsx create mode 100644 components/JobScanner.tsx delete mode 100644 components/MechanicsBoard.tsx delete mode 100644 components/OperatorsView.tsx create mode 100644 components/SearchPicker.tsx create mode 100644 lib/job-cards.ts create mode 100644 scripts/setup-job-cards-collection.mjs diff --git a/AGENTS.md b/AGENTS.md index 948ced5..62ba500 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. + + +## 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. diff --git a/app/(clock)/clock/job-cards/page.tsx b/app/(clock)/clock/job-cards/page.tsx index bbe9ee9..ab890b6 100644 --- a/app/(clock)/clock/job-cards/page.tsx +++ b/app/(clock)/clock/job-cards/page.tsx @@ -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
-

MEGA PLANT

-

Job cards

-
- -

Coming soon

-

Job cards will be built next.

-
-
; + 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 ({ 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 }))} + />; } diff --git a/app/(dashboard)/layout.tsx b/app/(dashboard)/layout.tsx index 56c9370..58c9df2 100644 --- a/app/(dashboard)/layout.tsx +++ b/app/(dashboard)/layout.tsx @@ -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" }, ], }, { diff --git a/app/(dashboard)/running/job-cards/page.tsx b/app/(dashboard)/running/job-cards/page.tsx new file mode 100644 index 0000000..ee4f399 --- /dev/null +++ b/app/(dashboard)/running/job-cards/page.tsx @@ -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 ( +
+ + Back to Running Costs + +
+
+
+ +
+

Job Cards

+
+

Work done on each vehicle, logged by mechanics in the Plant Clock app

+
+ +
+ ); +} diff --git a/app/(dashboard)/running/mechanics/page.tsx b/app/(dashboard)/running/mechanics/page.tsx deleted file mode 100644 index 1fec967..0000000 --- a/app/(dashboard)/running/mechanics/page.tsx +++ /dev/null @@ -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 ( -
- - Back to Running Costs - - -
-
-
-
- -
-

Mechanics

-
-

- Badge QR codes, linked to each mechanic's employee number -

-

- Each QR carries the name and the employee - number — no URL, so it scans anywhere without needing the app open. -

-
-
- -
- - - -
- - {mechanics.length === 0 ? ( -
-
- No mechanics in PocketBase. -
-
- Add them to data/source/mechanics.json and re-run{" "} - npm run seed. -
-
- ) : ( - - )} -
- ); -} - -function StatCard({ label, value, color }: { label: string; value: string; color: string }) { - return ( -
-
{label}
-
- {value} -
-
- ); -} \ No newline at end of file diff --git a/app/(dashboard)/running/operators/page.tsx b/app/(dashboard)/running/operators/page.tsx deleted file mode 100644 index 5220b1d..0000000 --- a/app/(dashboard)/running/operators/page.tsx +++ /dev/null @@ -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 ( -
- - Back to Running Costs - - -
-
-
-
- -
-

Operators

-
-

- Who runs which machine, and their rate · {operators.length} operators -

-
-
-
Combined rate / hour
-
{ZAR2(hourlyTotal)}
-
-
- -
- - - -
- - {unallocated > 0 && ( -
- - {unallocated} operator{unallocated !== 1 && "s"} on the payroll with no machine - {" "} - — they are on the sheet without an allocation. Allocate them from the bottom of - the table. -
- )} - - -
- ); -} - -function StatCard({ label, value, color }: { label: string; value: string; color: string }) { - return ( -
-
{label}
-
- {value} -
-
- ); -} \ No newline at end of file diff --git a/app/api/clock/job-finish/route.ts b/app/api/clock/job-finish/route.ts new file mode 100644 index 0000000..7d44958 --- /dev/null +++ b/app/api/clock/job-finish/route.ts @@ -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); } +} diff --git a/app/api/clock/job-start/route.ts b/app/api/clock/job-start/route.ts new file mode 100644 index 0000000..8a97fe1 --- /dev/null +++ b/app/api/clock/job-start/route.ts @@ -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); } +} diff --git a/app/api/mechanics/route.ts b/app/api/mechanics/route.ts index 1fcb918..2c07e31 100644 --- a/app/api/mechanics/route.ts +++ b/app/api/mechanics/route.ts @@ -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({ diff --git a/app/api/operators/[id]/route.ts b/app/api/operators/[id]/route.ts index c69ebda..0ba8879 100644 --- a/app/api/operators/[id]/route.ts +++ b/app/api/operators/[id]/route.ts @@ -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"); diff --git a/app/api/operators/route.ts b/app/api/operators/route.ts index c2d6a47..efea040 100644 --- a/app/api/operators/route.ts +++ b/app/api/operators/route.ts @@ -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({ diff --git a/components/JobCardsTable.tsx b/components/JobCardsTable.tsx new file mode 100644 index 0000000..ca70afe --- /dev/null +++ b/components/JobCardsTable.tsx @@ -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("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 ( +
+
+
+ {PRESETS.map((p) => ( + + ))} +
+ + {preset === "range" && ( +
+ setFrom(e.target.value)} aria-label="From" /> + to + setTo(e.target.value)} aria-label="To" /> +
+ )} +
+ + + +
+
+ +
+ {shown.length} job{shown.length !== 1 && "s"} + {formatMinutes(totalMinutes)} on finished jobs + {shown.some((r) => r.open) && {shown.filter((r) => r.open).length} in progress} +
+ + {shown.length === 0 ? ( +

+ {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."} +

+ ) : ( +
+ + + + + + + + + + + + + + + {shown.map((r) => ( + + + + + + + + + + + ))} + +
VehicleMechanicStartedFinishedTimeWorkOdometerNote
{r.asset || "—"}{r.assetName}{r.mechanic || "—"}{when(r.startedAt)}{r.open ? In progress : when(r.endedAt)}{r.open ? "—" : formatMinutes(r.minutes)}{jobTypeLabel(r.type) || "—"}{r.odometer != null ? fmt(r.odometer) : "—"}{r.note || "—"}
+
+ )} +
+ ); +} diff --git a/components/JobScanner.tsx b/components/JobScanner.tsx new file mode 100644 index 0000000..81807c5 --- /dev/null +++ b/components/JobScanner.tsx @@ -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(null); + const [mechanicId, setMechanicId] = useState(''); + const [odometer, setOdometer] = useState(''); + const [noMeter, setNoMeter] = useState(false); + const [jobType, setJobType] = useState(''); + 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
+

MEGA PLANT

+

Job cards

+ + {message &&
+ {message.ok ? : } +

{message.text}

} + + {!asset ? <> +
+ {unknownScan &&

That QR is not a vehicle in the plant register ({unknownScan}).

} +
+

Jobs open now

+ {open.length === 0 ?

No jobs running. Scan a vehicle to start one.

+ :
    {open.map(j => { + const a = assetById(j.asset); + return
  • ; + })}
} +
+ : !job ?
+
+

{asset.code} · {asset.name}

+

No job open — this starts one, clock running from now.

+ +
+ + ({ id: m.id, label: m.name, sub: m.code }))}/> + + :
+
+

{asset.code} · {asset.name}

+

{job.mechanic} · started {clockTime(job.startedAt.replace(' ', 'T'))} · {formatMinutes(minutesBetween(job.startedAt, now))} so far

+ +
+ + setOdometer(e.target.value)} placeholder="Current reading"/> + +

What kind of work was it?

+
{JOB_TYPES.map(t => + )}
+ +