mega-plant/components/StockOut.tsx

146 lines
10 KiB
TypeScript
Raw Normal View History

'use client';
import { useEffect, useRef, useState } from 'react';
import { useRouter } from 'next/navigation';
import { AlertTriangle, Camera, CheckCircle2, Droplets, X } from 'lucide-react';
import { BadgeCamera } from './BadgeCamera';
import { SearchPicker } from './SearchPicker';
import { assetFromQr, oilCodeFromQr } from '@/lib/stock-out';
import { shrinkImage } from '@/lib/shrink-image';
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. */
type Employee = { name: string; code: string };
const LAST_EMPLOYEE = 'mp:lastFuelEmployee';
export function StockOut({ items, assets, employees }: { items: Item[]; assets: Asset[]; employees: Employee[] }) {
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 [employee, setEmployee] = useState('');
const [odometer, setOdometer] = useState('');
const [photo, setPhoto] = useState<File | null>(null);
const [preview, setPreview] = useState('');
const lastScan = useRef({ value: '', until: 0 });
useEffect(() => { try { const e = localStorage.getItem(LAST_EMPLOYEE); if (e) setEmployee(e); } catch { /* private mode */ } }, []);
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 shrinkImage(file, 'meter.jpg');
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()); if (employee) body.set('employee_code', employee);
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}.` });
try { if (employee) localStorage.setItem(LAST_EMPLOYEE, employee); } catch { /* ignore */ }
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 ? <>
{/* 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>
{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="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 }))}/>
<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>;
}