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:
parent
29a035a4f2
commit
c269cd00b4
19 changed files with 546 additions and 883 deletions
22
AGENTS.md
22
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.
|
||||
|
||||
<!-- 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.
|
||||
|
|
|
|||
|
|
@ -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 }))}
|
||||
/>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
47
app/(dashboard)/running/job-cards/page.tsx
Normal file
47
app/(dashboard)/running/job-cards/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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'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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
41
app/api/clock/job-finish/route.ts
Normal file
41
app/api/clock/job-finish/route.ts
Normal 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); }
|
||||
}
|
||||
40
app/api/clock/job-start/route.ts
Normal file
40
app/api/clock/job-start/route.ts
Normal 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); }
|
||||
}
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
118
components/JobCardsTable.tsx
Normal file
118
components/JobCardsTable.tsx
Normal 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
132
components/JobScanner.tsx
Normal 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>;
|
||||
}
|
||||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
28
components/SearchPicker.tsx
Normal file
28
components/SearchPicker.tsx
Normal 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>;
|
||||
}
|
||||
|
|
@ -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
21
lib/job-cards.ts
Normal 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`;
|
||||
73
scripts/setup-job-cards-collection.mjs
Normal file
73
scripts/setup-job-cards-collection.mjs
Normal 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);
|
||||
Loading…
Reference in a new issue