mega-plant/components/JobScanner.tsx

158 lines
12 KiB
TypeScript
Raw Permalink Normal View History

'use client';
import { useEffect, useRef, useState } from 'react';
import { useRouter } from 'next/navigation';
import { AlertTriangle, CheckCircle2, Droplets, Play, Square, X } from 'lucide-react';
import { BadgeCamera } from './BadgeCamera';
import { SearchPicker } from './SearchPicker';
import { clockTime } from '@/lib/clock-display';
import { assetFromQr, oilCodeFromQr } from '@/lib/stock-out';
import { formatMinutes, JOB_TYPES, minutesBetween, type JobType } from '@/lib/job-cards';
type Asset = { id: string; code: string; name: string; qr?: string };
type Mechanic = { id: string; name: string; code: string };
type Oil = { id: string; code: string; name: string; colour: string; onHand: number };
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, oils, open }: { assets: Asset[]; mechanics: Mechanic[]; oils: Oil[]; open: OpenJob[] }) {
const router = useRouter();
const [asset, setAsset] = useState<Asset | null>(null);
// Oil is its own path: scan the drum, say how many litres and which vehicle. No odometer, no photo.
const [oil, setOil] = useState<Oil | null>(null);
const [oilAssetId, setOilAssetId] = useState('');
const [oilLitres, setOilLitres] = useState('');
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) {
setOil(null); setOilAssetId(''); setOilLitres('');
setAsset(next); setOdometer(''); setNoMeter(false); setJobType(''); setNote(''); setUnknownScan('');
}
function onDecode(raw: string) {
if (asset || oil || busy) return;
if (lastScan.current.value === raw && Date.now() < lastScan.current.until) return;
lastScan.current = { value: raw, until: Date.now() + 4000 };
const oilCode = oilCodeFromQr(raw);
const oilFound = oilCode && oils.find(o => o.code.toUpperCase() === oilCode.toUpperCase());
if (oilFound) { setMessage(null); setUnknownScan(''); setOil(oilFound); navigator.vibrate?.(120); return; }
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)}.${data.oil ? ` ${data.oil} oil booking${data.oil > 1 ? 's' : ''} from today added to the job card.` : ''}` });
choose(null); router.refresh(); navigator.vibrate?.(120);
}
async function bookOil(e: React.FormEvent) {
e.preventDefault();
if (!oil || !oilAssetId) return;
const data = await call('/api/clock/oil-out', { item_id: oil.id, asset_id: oilAssetId, litres: oilLitres.trim() });
if (!data) return;
setMessage({ ok: true, text: `${data.litres} L of ${data.item} booked out to ${data.asset}. ${data.onJob ? 'Added to its open job card.' : 'It joins the job card if you start a job on that vehicle today.'}` });
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>}
{oil ? <form onSubmit={bookOil} 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"><div className="flex items-center gap-2"><Droplets size={20} style={{ color: oil.colour }}/><h2 className="text-lg font-semibold break-words">{oil.name}</h2></div>
<p className="text-sm text-text-muted mt-1">{oil.onHand.toLocaleString('en-ZA', { maximumFractionDigits: 2 })} L in stock · booked out as soon as you save</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="oil-litres" className="block text-sm font-medium mt-4">How many litres?</label>
<input id="oil-litres" className="plant-clock-input mt-1 text-2xl tabular-nums" type="number" inputMode="decimal" min="0" step="0.1" autoFocus value={oilLitres} onChange={e => setOilLitres(e.target.value)} placeholder="0"/>
<label htmlFor="oil-vehicle" className="block text-sm font-medium mt-4">Which vehicle is it for?</label>
<SearchPicker id="oil-vehicle" placeholder="Search by code or name…" value={oilAssetId} onChange={setOilAssetId}
options={assets.map(a => ({ id: a.id, label: a.code, sub: a.name }))}/>
<button className="plant-clock-button plant-clock-primary w-full mt-5" type="submit" disabled={busy || !(Number(oilLitres) > 0) || !oilAssetId}>{busy ? 'Saving…' : 'Book out oil'}</button>
</form> : !asset ? <>
{/* Search sits ABOVE the camera so camera + search fit one screen and the list opens over the camera. */}
<div className="mt-4">
<label htmlFor="vehicle-find" className="block text-sm font-medium">Search for a vehicle</label>
<SearchPicker id="vehicle-find" placeholder="Type the vehicle code or name…" value=""
onChange={id => { const found = assetById(id); if (found) { setMessage(null); choose(found); } }}
options={assets.map(a => ({ id: a.id, label: a.code, sub: a.name }))}/>
</div>
<div className="mt-3"><BadgeCamera compact onDecode={onDecode} paused={busy} hint="Or scan a vehicle QR / oil label"/></div>
{unknownScan && <p role="alert" className="plant-clock-warning mt-3">That QR is not a vehicle in the plant register or an oil label ({unknownScan}).</p>}
</> : !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>;
}