Clock app: search + scanner on one screen; employee on fuel; drop codeless vehicles
- Job cards and Fuel: vehicle search above a compact camera - Fuel: optional "who is fuelling" employee search, recorded in Booked by - Remove the codeless "Diesel Truck" / "Water truck" register rows Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
fd2d9262ae
commit
82d34103d7
7 changed files with 44 additions and 28 deletions
|
|
@ -7,14 +7,19 @@ export const metadata = { title: 'Book out oil & diesel · Mega Plant' };
|
|||
export default async function StockPage() {
|
||||
await requireClockUser();
|
||||
const pb = await clockPB();
|
||||
const [items, assets, moves] = await Promise.all([
|
||||
const [items, assets, moves, operators, mechanics] = await Promise.all([
|
||||
pb.collection('oil_items').getFullList({ sort: 'code' }),
|
||||
pb.collection('assets').getFullList({ sort: 'code', filter: 'is_active = true' }),
|
||||
pb.collection('oil_stock_movements').getFullList({ expand: 'item,asset' }),
|
||||
pb.collection('operators').getFullList({ sort: 'name', fields: 'name,employee_code' }),
|
||||
pb.collection('mechanics').getFullList({ sort: 'name', fields: 'name,employee_code' }),
|
||||
]);
|
||||
const employees = [...operators, ...mechanics].filter(e => String(e.employee_code || '').trim())
|
||||
.map(e => ({ name: String(e.name), code: String(e.employee_code) })).sort((a, b) => a.name.localeCompare(b.name));
|
||||
const onHand = (id: string) => moves.filter(m => m.item === id).reduce((s, m) => s + (m.kind === 'used' ? -(m.litres || 0) : m.litres || 0), 0);
|
||||
return <StockOut
|
||||
items={items.filter(i => String(i.code).startsWith('FUEL')).map(i => ({ id: i.id, code: i.code, name: i.name, colour: i.colour || '#30638E', onHand: Math.round(onHand(i.id) * 100) / 100, tank: String(i.code).startsWith('FUEL') }))}
|
||||
employees={employees}
|
||||
assets={assets.map(a => ({ id: a.id, code: a.code, name: a.name, qr: a.qr_code || '' }))}
|
||||
/>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,7 +35,15 @@ export async function POST(request: Request) {
|
|||
if (!asset) return Response.json({ error: 'That vehicle is not in the plant register.' }, { status: 404 });
|
||||
if (!String(item.code).startsWith('FUEL')) return Response.json({ error: 'Oil is booked on the Job cards tab.' }, { status: 400 });
|
||||
|
||||
const who = String(user.user.name || user.user.email || user.user.id);
|
||||
// Optional: who is fuelling. Looked up by employee number, never trusted as free text.
|
||||
const employeeCode = String(form.get('employee_code') || '').trim();
|
||||
let who = String(user.user.name || user.user.email || user.user.id);
|
||||
if (employeeCode) {
|
||||
const filter = pb.filter('employee_code = {:c}', { c: employeeCode });
|
||||
const person = (await pb.collection('operators').getFullList({ filter }))[0] ?? (await pb.collection('mechanics').getFullList({ filter }))[0];
|
||||
if (!person) return Response.json({ error: 'That employee is not on the register.' }, { status: 404 });
|
||||
who = `${person.name} (${employeeCode})`;
|
||||
}
|
||||
const data = new FormData();
|
||||
data.set('item', item.id); data.set('asset', asset.id); data.set('kind', 'used'); data.set('litres', String(litres));
|
||||
data.set('occurred_on', new Date().toISOString()); // full instant: `created` is not auto-filled here
|
||||
|
|
|
|||
|
|
@ -148,6 +148,7 @@ body, .dark body { transition: background-color 0.2s ease, color 0.2s ease; }
|
|||
.plant-clock-warning { padding: 12px 14px; border-radius: 10px; border: 1px solid #EDAE4980; background: #EDAE4915; font-size: 14px; line-height: 1.6; overflow-wrap: anywhere; }
|
||||
.plant-camera { position: relative; overflow: hidden; display: grid; place-items: center; border-radius: 12px; min-height: min(62svh, 520px); background: #003D5B; color: white; }
|
||||
.plant-clock-banner { display: flex; align-items: center; gap: 12px; padding: 14px 16px; border-radius: 12px; box-shadow: 0 8px 24px rgb(0 0 0 / .25); }
|
||||
.plant-camera-compact { min-height: min(34svh, 300px); }
|
||||
.plant-scan-frame { display: grid; place-items: center; width: 154px; height: 140px; border: 2px solid #EDAE49; border-radius: 16px; color: #EDAE49; }
|
||||
.plant-clock-status { display: inline-flex; align-items: center; border-radius: 7px; background: var(--color-bg); color: var(--color-text-muted); padding: 5px 9px; font-size: 12px; font-weight: 600; white-space: nowrap; }
|
||||
.plant-clock-status-in { background: #00798C15; color: var(--color-accent-ink); }
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import jsQR from 'jsqr';
|
|||
import { Camera, ScanLine } from 'lucide-react';
|
||||
|
||||
/** Starts on mount: the clock IS the scanner, so there is nothing to open first. */
|
||||
export function BadgeCamera({onDecode,paused,overlay,hint='Hold the badge QR inside the frame'}:{onDecode:(code:string)=>void;paused:boolean;overlay?:ReactNode;hint?:string}) {
|
||||
export function BadgeCamera({onDecode,paused,overlay,hint='Hold the badge QR inside the frame',compact=false}:{onDecode:(code:string)=>void;paused:boolean;overlay?:ReactNode;hint?:string;compact?:boolean}) {
|
||||
const video=useRef<HTMLVideoElement>(null);
|
||||
const callback=useRef(onDecode);callback.current=onDecode;
|
||||
const pause=useRef(paused);pause.current=paused;
|
||||
|
|
@ -45,7 +45,7 @@ export function BadgeCamera({onDecode,paused,overlay,hint='Hold the badge QR ins
|
|||
return ()=>{stopped=true;setLive(false);clearTimeout(timer);stream?.getTracks().forEach(track=>track.stop());};
|
||||
},[enabled]);
|
||||
return <div>
|
||||
<div className="plant-camera">
|
||||
<div className={`plant-camera${compact?' plant-camera-compact':''}`}>
|
||||
{enabled && <video ref={video} muted playsInline aria-label="Employee badge camera" className="absolute inset-0 h-full w-full object-cover" />}
|
||||
<div className="relative z-10 flex flex-col items-center gap-4 p-6 text-center">
|
||||
<div className="plant-scan-frame"><ScanLine size={38} strokeWidth={1.4}/></div>
|
||||
|
|
|
|||
|
|
@ -117,14 +117,15 @@ export function JobScanner({ assets, mechanics, oils, open }: { assets: Asset[];
|
|||
options={assets.map(a => ({ id: a.id, label: a.code, sub: a.name }))}/>
|
||||
<button className="plant-clock-button plant-clock-primary w-full mt-5" type="submit" disabled={busy || !(Number(oilLitres) > 0) || !oilAssetId}>{busy ? 'Saving…' : 'Book out oil'}</button>
|
||||
</form> : !asset ? <>
|
||||
<div className="mt-4"><BadgeCamera onDecode={onDecode} paused={busy} hint="Scan a vehicle QR to start / finish a job, or an oil 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 label ({unknownScan}).</p>}
|
||||
{/* Search sits ABOVE the camera so camera + search fit one screen and the list opens over the camera. */}
|
||||
<div className="mt-4">
|
||||
<label htmlFor="vehicle-find" className="block text-sm font-medium">QR not scanning? Search for the vehicle</label>
|
||||
<label htmlFor="vehicle-find" className="block text-sm font-medium">Search for a vehicle</label>
|
||||
<SearchPicker id="vehicle-find" placeholder="Type the vehicle code or name…" value=""
|
||||
onChange={id => { const found = assetById(id); if (found) { setMessage(null); choose(found); } }}
|
||||
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 a vehicle QR / oil 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 label ({unknownScan}).</p>}
|
||||
</> : !job ? <form onSubmit={start} 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"><h2 className="text-lg font-semibold break-words">{asset.code} <span className="font-normal text-text-muted">· {asset.name}</span></h2>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
'use client';
|
||||
import { useRef, useState } from 'react';
|
||||
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';
|
||||
|
||||
/** Phone photos are 4–8 MB; shrink to ~1600px JPEG so a yard with poor signal can still upload. */
|
||||
|
|
@ -24,7 +25,10 @@ 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[] }) {
|
||||
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('');
|
||||
|
|
@ -32,10 +36,12 @@ export function StockOut({ items, assets }: { items: Item[]; assets: Asset[] })
|
|||
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('');
|
||||
|
|
@ -64,11 +70,12 @@ export function StockOut({ items, assets }: { items: Item[]; assets: Asset[] })
|
|||
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());
|
||||
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.' });
|
||||
|
|
@ -87,7 +94,14 @@ export function StockOut({ items, assets }: { items: Item[]; assets: Asset[] })
|
|||
<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>
|
||||
{/* 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">
|
||||
|
|
@ -95,6 +109,9 @@ export function StockOut({ items, assets }: { items: Item[]; assets: Asset[] })
|
|||
<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>}
|
||||
|
|
|
|||
|
|
@ -340,21 +340,5 @@
|
|||
"monthly_incl": 0,
|
||||
"type": "Solar Office",
|
||||
"is_provisional": true
|
||||
},
|
||||
{
|
||||
"name": "Diesel Truck",
|
||||
"code": "",
|
||||
"category": "Vehicle",
|
||||
"monthly_incl": 0,
|
||||
"type": "",
|
||||
"is_provisional": false
|
||||
},
|
||||
{
|
||||
"name": "Water truck",
|
||||
"code": "",
|
||||
"category": "Vehicle",
|
||||
"monthly_incl": 0,
|
||||
"type": "",
|
||||
"is_provisional": false
|
||||
}
|
||||
]
|
||||
]
|
||||
|
|
|
|||
Loading…
Reference in a new issue