mega-plant/components/StockOut.tsx
David 8f3a74cf6e Job cards oil path, Fuel tab diesel-only, Mechanic role, Plant Register QR column
- Job cards scanner: oil QR books litres + vehicle (no odo/photo), bundled onto the
  vehicle's job card (open job now, or the next job started the same day)
- Fuel tab is diesel only; /api/clock/oil-out added
- Users: Full / Clock-in user / Mechanic; mechanics see only Fuel + Job cards
- Plant Register: QR code column (double-click to set), kept across re-seeds and
  used when matching scans

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 13:44:01 +02:00

140 lines
9.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
import { useRef, useState } from 'react';
import { useRouter } from 'next/navigation';
import { AlertTriangle, Camera, CheckCircle2, Droplets, X } from 'lucide-react';
import { BadgeCamera } from './BadgeCamera';
import { assetFromQr, oilCodeFromQr } from '@/lib/stock-out';
/** Phone photos are 4–8 MB; shrink to ~1600px JPEG so a yard with poor signal can still upload. */
async function shrink(file: File): Promise<File> {
try {
const bmp = await createImageBitmap(file);
const scale = Math.min(1, 1600 / Math.max(bmp.width, bmp.height));
const canvas = document.createElement('canvas');
canvas.width = Math.round(bmp.width * scale); canvas.height = Math.round(bmp.height * scale);
canvas.getContext('2d')!.drawImage(bmp, 0, 0, canvas.width, canvas.height);
const blob = await new Promise<Blob | null>(r => canvas.toBlob(r, 'image/jpeg', 0.8));
return blob ? new File([blob], 'meter.jpg', { type: 'image/jpeg' }) : file;
} catch { return file; }
}
type Item = { id: string; code: string; name: string; colour: string; onHand: number; tank: boolean };
type Asset = { id: string; code: string; name: string; qr?: string };
const fmt = (n: number) => n.toLocaleString('en-ZA', { maximumFractionDigits: 2 });
/** Scan the drum/tank QR, say how many litres and which vehicle, and it is booked out. */
export function StockOut({ items, assets }: { items: Item[]; assets: Asset[] }) {
const router = useRouter();
const [item, setItem] = useState<Item | null>(null);
const [litres, setLitres] = useState('');
const [assetId, setAssetId] = useState('');
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null);
const [unknownScan, setUnknownScan] = useState('');
const [odometer, setOdometer] = useState('');
const [photo, setPhoto] = useState<File | null>(null);
const [preview, setPreview] = useState('');
const lastScan = useRef({ value: '', until: 0 });
function pick(next: Item | null) {
setItem(next); setLitres(''); setAssetId(''); setUnknownScan(''); setPhoto(null); setPreview(''); setOdometer('');
}
async function takePhoto(file: File | undefined) {
if (!file) return;
const small = await shrink(file);
setPhoto(small); setPreview(URL.createObjectURL(small));
}
function onDecode(raw: string) {
if (item || busy) return;
if (lastScan.current.value === raw && Date.now() < lastScan.current.until) return;
lastScan.current = { value: raw, until: Date.now() + 4000 };
const code = oilCodeFromQr(raw);
const found = code && items.find(i => i.code.toUpperCase() === code.toUpperCase());
if (found) { setMessage(null); pick(found); navigator.vibrate?.(120); return; }
// A vehicle's own QR books DIESEL from the main tank to that vehicle.
const vehicle = assetFromQr(raw, assets);
const tank = items.find(i => i.tank);
if (vehicle && tank) { setMessage(null); pick(tank); setAssetId(vehicle.id); navigator.vibrate?.(120); return; }
setUnknownScan(raw.slice(0, 60));
}
async function submit(e: React.FormEvent) {
e.preventDefault();
if (!item || busy) return;
setBusy(true); setMessage(null);
try {
const body = new FormData();
body.set('item_id', item.id); body.set('asset_id', assetId); body.set('litres', litres.trim()); body.set('meter_photo', photo!); if (odometer.trim()) body.set('odometer', odometer.trim());
const res = await fetch('/api/clock/stock-out', { method: 'POST', body });
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || 'Could not save.');
setMessage({ ok: true, text: `${fmt(data.litres)} L of ${data.item} booked to ${data.asset}.` });
pick(null); router.refresh(); navigator.vibrate?.(120);
} catch (err) {
setMessage({ ok: false, text: err instanceof Error ? err.message : 'Could not save.' });
} finally { setBusy(false); }
}
const entered = Number(litres);
const over = item && entered > item.onHand;
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">Book out diesel</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>}
{!item ? <>
<div className="mt-4"><BadgeCamera onDecode={onDecode} paused={busy} hint="Scan the vehicle QR to book diesel to it"/></div>
{unknownScan && <p role="alert" className="plant-clock-warning mt-3">That QR is not a vehicle in the plant register or the diesel tank label ({unknownScan}). Oil is booked on the Job cards tab.</p>}
</> : <form onSubmit={submit} 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: item.colour }}/><h2 className="text-lg font-semibold break-words">{item.name}</h2></div>
<p className="text-sm text-text-muted mt-1">{fmt(item.onHand)} L in {item.tank ? 'the tank' : 'stock'}</p></div>
<button type="button" onClick={() => pick(null)} className="plant-clock-button plant-clock-secondary shrink-0" aria-label="Scan something else"><X size={16}/>Change</button>
</div>
<label htmlFor="litres" className="block text-sm font-medium mt-4">How many litres?</label>
<input id="litres" className="plant-clock-input mt-1 text-2xl tabular-nums" type="number" inputMode="decimal" min="0" step="0.1" autoFocus value={litres} onChange={e => setLitres(e.target.value)} placeholder="0"/>
{over && <p className="plant-clock-warning mt-2">That is more than the {fmt(item.onHand)} L recorded. It will still be booked — check the stock level afterwards.</p>}
<label htmlFor="vehicle-search" className="block text-sm font-medium mt-4">Which vehicle is it for?</label>
<VehiclePicker assets={assets} value={assetId} onChange={setAssetId}/>
<label htmlFor="odometer" className="block text-sm font-medium mt-4">Odometer / hour meter <span className="font-normal text-text-muted">(if it has one)</span></label>
<input id="odometer" className="plant-clock-input mt-1 tabular-nums" type="number" inputMode="decimal" min="0" step="any" value={odometer} onChange={e => setOdometer(e.target.value)} placeholder="Current reading"/>
<p className="block text-sm font-medium mt-4">Photo of the meter</p>
<label htmlFor="meter-photo" className="plant-clock-button plant-clock-secondary w-full mt-1 cursor-pointer"><Camera size={18}/>{photo ? 'Retake photo' : 'Take photo'}</label>
<input id="meter-photo" type="file" accept="image/*" capture="environment" className="sr-only" onChange={e => { void takePhoto(e.target.files?.[0]); e.target.value = ''; }}/>
{preview && <img src={preview} alt="Meter photo to upload" className="mt-2 max-h-56 w-full rounded-xl object-contain bg-bg border border-border"/>}
<button className="plant-clock-button plant-clock-primary w-full mt-5" type="submit" disabled={busy || !(entered > 0) || !assetId || !photo}>
{busy ? 'Saving…' : 'Book out'}</button>
</form>}
</section>;
}
/** Type to filter by code or name, tap to choose. A native <select> cannot be searched on a phone. */
function VehiclePicker({ assets, value, onChange }: { assets: Asset[]; value: string; onChange: (id: string) => void }) {
const chosen = assets.find(a => a.id === value);
const [query, setQuery] = useState('');
const [open, setOpen] = useState(false);
const needle = query.trim().toLowerCase();
const matches = needle ? assets.filter(a => `${a.code} ${a.name}`.toLowerCase().includes(needle)) : assets;
return <div className="relative mt-1">
<input id="vehicle-search" className="plant-clock-input" autoComplete="off" role="combobox" aria-expanded={open} aria-controls="vehicle-list"
placeholder="Search by code or name…"
value={open ? query : chosen ? `${chosen.code} · ${chosen.name}` : ''}
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="vehicle-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">No vehicle matches “{query}”.</li>
: matches.map(a => <li key={a.id} role="option" aria-selected={a.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(a.id); setOpen(false); }}>
<span className="font-semibold">{a.code}</span> · {a.name}</button></li>)}
</ul>}
</div>;
}