mega-plant/components/JobScanner.tsx
David c269cd00b4 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>
2026-10-22 12:54:59 +02:00

132 lines
9.2 KiB
TypeScript

'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>;
}