2026-10-22 09:39:18 +00:00
|
|
|
'use client';
|
|
|
|
|
import { useRef, useState } from 'react';
|
|
|
|
|
import { useRouter } from 'next/navigation';
|
2026-10-22 09:53:31 +00:00
|
|
|
import { AlertTriangle, CheckCircle2, Droplets, X } from 'lucide-react';
|
2026-10-22 09:39:18 +00:00
|
|
|
import { BadgeCamera } from './BadgeCamera';
|
|
|
|
|
import { oilCodeFromQr } from '@/lib/stock-out';
|
|
|
|
|
|
|
|
|
|
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. */
|
2026-10-22 09:53:31 +00:00
|
|
|
export function StockOut({ items, assets }: { items: Item[]; assets: Asset[] }) {
|
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('');
|
|
|
|
|
const lastScan = useRef({ value: '', until: 0 });
|
|
|
|
|
|
|
|
|
|
function pick(next: Item | null) {
|
|
|
|
|
setItem(next); setLitres(''); setAssetId(''); setUnknownScan('');
|
|
|
|
|
}
|
|
|
|
|
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); }
|
|
|
|
|
else setUnknownScan(raw.slice(0, 60));
|
|
|
|
|
}
|
|
|
|
|
async function submit(e: React.FormEvent) {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
if (!item || busy) return;
|
|
|
|
|
setBusy(true); setMessage(null);
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch('/api/clock/stock-out', { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
body: JSON.stringify({ item_id: item.id, asset_id: assetId, litres: litres.trim() }) });
|
|
|
|
|
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 QR on the drum or tank"/></div>
|
2026-10-22 09:53:31 +00:00
|
|
|
{unknownScan && <p role="alert" className="plant-clock-warning mt-3">That QR is not an oil or diesel label ({unknownScan}). Scan the label on the drum or tank.</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>
|
|
|
|
|
<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}/>
|
|
|
|
|
<button className="plant-clock-button plant-clock-primary w-full mt-5" type="submit" disabled={busy || !(entered > 0) || !assetId}>
|
|
|
|
|
{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>;
|
|
|
|
|
}
|