- Scanning a vehicle QR (its register code, e.g. TLB04) books diesel from the main tank; litres, optional odometer and a required meter photo (stored protected, viewable from the Booked out table) - Settings: users are Full or Clock-app-only; clock-only users are confined to /clock - Clock app: tapped tab highlights at once plus a loading screen; remove the "scanned on this device" list Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
140 lines
9.2 KiB
TypeScript
140 lines
9.2 KiB
TypeScript
'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 };
|
||
|
||
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 oil & 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 (or a drum / tank 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 / diesel label ({unknownScan}).</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>;
|
||
}
|