2026-10-22 09:39:18 +00:00
|
|
|
|
'use client';
|
2026-10-22 11:55:13 +00:00
|
|
|
|
import { useEffect, useRef, useState } from 'react';
|
2026-10-22 09:39:18 +00:00
|
|
|
|
import { useRouter } from 'next/navigation';
|
2026-10-22 10:25:15 +00:00
|
|
|
|
import { AlertTriangle, Camera, CheckCircle2, Droplets, X } from 'lucide-react';
|
2026-10-22 09:39:18 +00:00
|
|
|
|
import { BadgeCamera } from './BadgeCamera';
|
2026-10-22 11:55:13 +00:00
|
|
|
|
import { SearchPicker } from './SearchPicker';
|
2026-10-22 10:25:15 +00:00
|
|
|
|
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; }
|
|
|
|
|
|
}
|
2026-10-22 09:39:18 +00:00
|
|
|
|
|
|
|
|
|
|
type Item = { id: string; code: string; name: string; colour: string; onHand: number; tank: boolean };
|
2026-10-22 11:44:01 +00:00
|
|
|
|
type Asset = { id: string; code: string; name: string; qr?: string };
|
2026-10-22 09:39:18 +00:00
|
|
|
|
|
|
|
|
|
|
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. */
|
2026-10-22 11:55:13 +00:00
|
|
|
|
type Employee = { name: string; code: string };
|
|
|
|
|
|
const LAST_EMPLOYEE = 'mp:lastFuelEmployee';
|
|
|
|
|
|
|
|
|
|
|
|
export function StockOut({ items, assets, employees }: { items: Item[]; assets: Asset[]; employees: Employee[] }) {
|
2026-10-22 09:39:18 +00:00
|
|
|
|
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('');
|
2026-10-22 11:55:13 +00:00
|
|
|
|
const [employee, setEmployee] = useState('');
|
2026-10-22 10:25:15 +00:00
|
|
|
|
const [odometer, setOdometer] = useState('');
|
|
|
|
|
|
const [photo, setPhoto] = useState<File | null>(null);
|
|
|
|
|
|
const [preview, setPreview] = useState('');
|
2026-10-22 09:39:18 +00:00
|
|
|
|
const lastScan = useRef({ value: '', until: 0 });
|
2026-10-22 11:55:13 +00:00
|
|
|
|
useEffect(() => { try { const e = localStorage.getItem(LAST_EMPLOYEE); if (e) setEmployee(e); } catch { /* private mode */ } }, []);
|
2026-10-22 09:39:18 +00:00
|
|
|
|
|
|
|
|
|
|
function pick(next: Item | null) {
|
2026-10-22 10:25:15 +00:00
|
|
|
|
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));
|
2026-10-22 09:39:18 +00:00
|
|
|
|
}
|
|
|
|
|
|
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());
|
2026-10-22 10:25:15 +00:00
|
|
|
|
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));
|
2026-10-22 09:39:18 +00:00
|
|
|
|
}
|
|
|
|
|
|
async function submit(e: React.FormEvent) {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
if (!item || busy) return;
|
|
|
|
|
|
setBusy(true); setMessage(null);
|
|
|
|
|
|
try {
|
2026-10-22 10:25:15 +00:00
|
|
|
|
const body = new FormData();
|
2026-10-22 11:55:13 +00:00
|
|
|
|
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()); if (employee) body.set('employee_code', employee);
|
2026-10-22 10:25:15 +00:00
|
|
|
|
const res = await fetch('/api/clock/stock-out', { method: 'POST', body });
|
2026-10-22 09:39:18 +00:00
|
|
|
|
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}.` });
|
2026-10-22 11:55:13 +00:00
|
|
|
|
try { if (employee) localStorage.setItem(LAST_EMPLOYEE, employee); } catch { /* ignore */ }
|
2026-10-22 09:39:18 +00:00
|
|
|
|
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>
|
2026-10-22 11:44:01 +00:00
|
|
|
|
<h1 className="text-2xl font-semibold tracking-tight mt-1">Book out diesel</h1>
|
2026-10-22 09:39:18 +00:00
|
|
|
|
|
|
|
|
|
|
{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 ? <>
|
2026-10-22 11:55:13 +00:00
|
|
|
|
{/* Search above the camera: both fit one screen and the list opens over the camera. */}
|
|
|
|
|
|
<div className="mt-4">
|
|
|
|
|
|
<label htmlFor="fuel-vehicle-find" className="block text-sm font-medium">Search for a vehicle</label>
|
|
|
|
|
|
<SearchPicker id="fuel-vehicle-find" placeholder="Type the vehicle code or name…" value=""
|
|
|
|
|
|
onChange={id => { const found = assets.find(a => a.id === id); const tank = items.find(i => i.tank); if (found && tank) { setMessage(null); pick(tank); setAssetId(found.id); } }}
|
|
|
|
|
|
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 the vehicle QR"/></div>
|
2026-10-22 11:44:01 +00:00
|
|
|
|
{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>}
|
2026-10-22 09:39:18 +00:00
|
|
|
|
</> : <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>
|
2026-10-22 11:55:13 +00:00
|
|
|
|
<label htmlFor="fuel-employee" className="block text-sm font-medium mt-4">Who is fuelling? <span className="font-normal text-text-muted">(search by name or number)</span></label>
|
|
|
|
|
|
<SearchPicker id="fuel-employee" placeholder="Search for an employee…" value={employee} onChange={setEmployee}
|
|
|
|
|
|
options={employees.map(e => ({ id: e.code, label: e.name, sub: e.code }))}/>
|
2026-10-22 09:39:18 +00:00
|
|
|
|
<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}/>
|
2026-10-22 10:25:15 +00:00
|
|
|
|
<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}>
|
2026-10-22 09:39:18 +00:00
|
|
|
|
{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>;
|
|
|
|
|
|
}
|