2026-10-22 10:54:59 +00:00
'use client' ;
import { useEffect , useRef , useState } from 'react' ;
import { useRouter } from 'next/navigation' ;
2026-10-22 11:44:01 +00:00
import { AlertTriangle , CheckCircle2 , Droplets , Play , Square , X } from 'lucide-react' ;
2026-10-22 10:54:59 +00:00
import { BadgeCamera } from './BadgeCamera' ;
import { SearchPicker } from './SearchPicker' ;
import { clockTime } from '@/lib/clock-display' ;
2026-10-22 11:44:01 +00:00
import { assetFromQr , oilCodeFromQr } from '@/lib/stock-out' ;
2026-10-22 10:54:59 +00:00
import { formatMinutes , JOB_TYPES , minutesBetween , type JobType } from '@/lib/job-cards' ;
2026-10-22 11:44:01 +00:00
type Asset = { id : string ; code : string ; name : string ; qr? : string } ;
2026-10-22 10:54:59 +00:00
type Mechanic = { id : string ; name : string ; code : string } ;
2026-10-22 11:44:01 +00:00
type Oil = { id : string ; code : string ; name : string ; colour : string ; onHand : number } ;
2026-10-22 10:54:59 +00:00
type OpenJob = { id : string ; asset : string ; mechanic : string ; startedAt : string } ;
const LAST_MECHANIC = 'mp:lastMechanic' ;
/ * *
* Scan a vehicle ' s QR . No open job on it → START one ( pick who is working ) . An open job →
* FINISH it : odometer , kind of work and a note . The clock runs from the first scan .
* /
2026-10-22 11:44:01 +00:00
export function JobScanner ( { assets , mechanics , oils , open } : { assets : Asset [ ] ; mechanics : Mechanic [ ] ; oils : Oil [ ] ; open : OpenJob [ ] } ) {
2026-10-22 10:54:59 +00:00
const router = useRouter ( ) ;
const [ asset , setAsset ] = useState < Asset | null > ( null ) ;
2026-10-22 11:44:01 +00:00
// Oil is its own path: scan the drum, say how many litres and which vehicle. No odometer, no photo.
const [ oil , setOil ] = useState < Oil | null > ( null ) ;
const [ oilAssetId , setOilAssetId ] = useState ( '' ) ;
const [ oilLitres , setOilLitres ] = useState ( '' ) ;
2026-10-22 10:54:59 +00:00
const [ mechanicId , setMechanicId ] = useState ( '' ) ;
const [ odometer , setOdometer ] = useState ( '' ) ;
const [ noMeter , setNoMeter ] = useState ( false ) ;
const [ jobType , setJobType ] = useState < JobType | ' ' > ( '' ) ;
const [ note , setNote ] = useState ( '' ) ;
const [ busy , setBusy ] = useState ( false ) ;
const [ message , setMessage ] = useState < { ok : boolean ; text : string } | null > ( null ) ;
const [ unknownScan , setUnknownScan ] = useState ( '' ) ;
const [ now , setNow ] = useState ( ( ) = > new Date ( ) . toISOString ( ) ) ;
const lastScan = useRef ( { value : '' , until : 0 } ) ;
useEffect ( ( ) = > { const t = setInterval ( ( ) = > setNow ( new Date ( ) . toISOString ( ) ) , 30000 ) ; return ( ) = > clearInterval ( t ) ; } , [ ] ) ;
useEffect ( ( ) = > { try { const m = localStorage . getItem ( LAST_MECHANIC ) ; if ( m ) setMechanicId ( m ) ; } catch { /* private mode */ } } , [ ] ) ;
const job = asset ? open . find ( j = > j . asset === asset . id ) ? ? null : null ;
const assetById = ( id : string ) = > assets . find ( a = > a . id === id ) ;
function choose ( next : Asset | null ) {
2026-10-22 11:44:01 +00:00
setOil ( null ) ; setOilAssetId ( '' ) ; setOilLitres ( '' ) ;
2026-10-22 10:54:59 +00:00
setAsset ( next ) ; setOdometer ( '' ) ; setNoMeter ( false ) ; setJobType ( '' ) ; setNote ( '' ) ; setUnknownScan ( '' ) ;
}
function onDecode ( raw : string ) {
2026-10-22 11:44:01 +00:00
if ( asset || oil || busy ) return ;
2026-10-22 10:54:59 +00:00
if ( lastScan . current . value === raw && Date . now ( ) < lastScan . current . until ) return ;
lastScan . current = { value : raw , until : Date.now ( ) + 4000 } ;
2026-10-22 11:44:01 +00:00
const oilCode = oilCodeFromQr ( raw ) ;
const oilFound = oilCode && oils . find ( o = > o . code . toUpperCase ( ) === oilCode . toUpperCase ( ) ) ;
if ( oilFound ) { setMessage ( null ) ; setUnknownScan ( '' ) ; setOil ( oilFound ) ; navigator . vibrate ? . ( 120 ) ; return ; }
2026-10-22 10:54:59 +00:00
const found = assetFromQr ( raw , assets ) ;
if ( found ) { setMessage ( null ) ; choose ( found ) ; navigator . vibrate ? . ( 120 ) ; }
else setUnknownScan ( raw . slice ( 0 , 60 ) ) ;
}
async function call ( url : string , body : object ) {
setBusy ( true ) ; setMessage ( null ) ;
try {
const res = await fetch ( url , { method : 'POST' , headers : { 'Content-Type' : 'application/json' } , body : JSON.stringify ( body ) } ) ;
const data = await res . json ( ) . catch ( ( ) = > ( { } ) ) ;
if ( ! res . ok ) throw new Error ( data . error || 'Could not save.' ) ;
return data ;
} catch ( e ) {
setMessage ( { ok : false , text : e instanceof Error ? e . message : 'Could not save.' } ) ;
return null ;
} finally { setBusy ( false ) ; }
}
async function start ( e : React.FormEvent ) {
e . preventDefault ( ) ;
if ( ! asset || ! mechanicId ) return ;
const data = await call ( '/api/clock/job-start' , { asset_id : asset.id , mechanic_id : mechanicId } ) ;
if ( ! data ) return ;
try { localStorage . setItem ( LAST_MECHANIC , mechanicId ) ; } catch { /* ignore */ }
2026-10-22 11:44:01 +00:00
setMessage ( { ok : true , text : ` Job started on ${ data . asset } for ${ data . mechanic } at ${ clockTime ( data . at ) } . ${ data . oil ? ` ${ data . oil } oil booking ${ data . oil > 1 ? 's' : '' } from today added to the job card. ` : '' } ` } ) ;
choose ( null ) ; router . refresh ( ) ; navigator . vibrate ? . ( 120 ) ;
}
async function bookOil ( e : React.FormEvent ) {
e . preventDefault ( ) ;
if ( ! oil || ! oilAssetId ) return ;
const data = await call ( '/api/clock/oil-out' , { item_id : oil.id , asset_id : oilAssetId , litres : oilLitres.trim ( ) } ) ;
if ( ! data ) return ;
setMessage ( { ok : true , text : ` ${ data . litres } L of ${ data . item } booked out to ${ data . asset } . ${ data . onJob ? 'Added to its open job card.' : 'It joins the job card if you start a job on that vehicle today.' } ` } ) ;
2026-10-22 10:54:59 +00:00
choose ( null ) ; router . refresh ( ) ; navigator . vibrate ? . ( 120 ) ;
}
async function finish ( e : React.FormEvent ) {
e . preventDefault ( ) ;
if ( ! job || ! jobType ) return ;
const data = await call ( '/api/clock/job-finish' , { job_id : job.id , job_type : jobType , note , odometer , no_meter : noMeter } ) ;
if ( ! data ) return ;
setMessage ( { ok : true , text : ` Job on ${ data . asset } finished — ${ formatMinutes ( data . minutes ) } on the job. ` } ) ;
choose ( null ) ; router . refresh ( ) ; navigator . vibrate ? . ( 120 ) ;
}
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" > Job cards < / 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 > }
2026-10-22 11:44:01 +00:00
{ oil ? < form onSubmit = { bookOil } 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 : oil.colour } } / > < h2 className = "text-lg font-semibold break-words" > { oil . name } < / h2 > < / div >
< p className = "text-sm text-text-muted mt-1" > { oil . onHand . toLocaleString ( 'en-ZA' , { maximumFractionDigits : 2 } ) } L in stock · booked out as soon as you save < / p > < / div >
< button type = "button" onClick = { ( ) = > choose ( null ) } className = "plant-clock-button plant-clock-secondary shrink-0" > < X size = { 16 } / > Cancel < / button >
< / div >
< label htmlFor = "oil-litres" className = "block text-sm font-medium mt-4" > How many litres ? < / label >
< input id = "oil-litres" className = "plant-clock-input mt-1 text-2xl tabular-nums" type = "number" inputMode = "decimal" min = "0" step = "0.1" autoFocus value = { oilLitres } onChange = { e = > setOilLitres ( e . target . value ) } placeholder = "0" / >
< label htmlFor = "oil-vehicle" className = "block text-sm font-medium mt-4" > Which vehicle is it for ? < / label >
< SearchPicker id = "oil-vehicle" placeholder = "Search by code or name…" value = { oilAssetId } onChange = { setOilAssetId }
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 ? < >
2026-10-22 11:55:13 +00:00
{ /* Search sits ABOVE the camera so camera + search fit one screen and the list opens over the camera. */ }
2026-10-22 10:58:31 +00:00
< div className = "mt-4" >
2026-10-22 11:55:13 +00:00
< label htmlFor = "vehicle-find" className = "block text-sm font-medium" > Search for a vehicle < / label >
2026-10-22 10:58:31 +00:00
< 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 } ) ) } / >
2026-10-22 10:54:59 +00:00
< / div >
2026-10-22 11:55:13 +00:00
< 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 > }
2026-10-22 10:54:59 +00:00
< / > : ! 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 >
< p className = "text-sm text-text-muted mt-1" > No job open — this starts one , clock running from now . < / p > < / div >
< button type = "button" onClick = { ( ) = > choose ( null ) } className = "plant-clock-button plant-clock-secondary shrink-0" > < X size = { 16 } / > Cancel < / button >
< / div >
< label htmlFor = "mechanic" className = "block text-sm font-medium mt-4" > Who is working on it ? < / label >
< SearchPicker id = "mechanic" placeholder = "Search by name or number…" value = { mechanicId } onChange = { setMechanicId }
options = { mechanics . map ( m = > ( { id : m.id , label : m.name , sub : m.code } ) ) } / >
< button className = "plant-clock-button plant-clock-primary w-full mt-5" type = "submit" disabled = { busy || ! mechanicId } > < Play size = { 16 } / > { busy ? 'Starting…' : 'Start job' } < / button >
< / form > : < form onSubmit = { finish } 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 >
< p className = "text-sm text-text-muted mt-1" > { job . mechanic } · started { clockTime ( job . startedAt . replace ( ' ' , 'T' ) ) } · { formatMinutes ( minutesBetween ( job . startedAt , now ) ) } so far < / p > < / div >
< button type = "button" onClick = { ( ) = > choose ( null ) } className = "plant-clock-button plant-clock-secondary shrink-0" > < X size = { 16 } / > Cancel < / button >
< / div >
< label htmlFor = "odo" className = "block text-sm font-medium mt-4" > Odometer / hour meter < / label >
< input id = "odo" className = "plant-clock-input mt-1 text-2xl tabular-nums" type = "number" inputMode = "decimal" min = "0" step = "any" disabled = { noMeter } value = { odometer } onChange = { e = > setOdometer ( e . target . value ) } placeholder = "Current reading" / >
< label className = "flex items-center gap-3 min-h-[44px] text-sm mt-1" > < input type = "checkbox" className = "h-5 w-5" checked = { noMeter } onChange = { e = > setNoMeter ( e . target . checked ) } / > This machine has no meter < / label >
< p className = "block text-sm font-medium mt-3" > What kind of work was it ? < / p >
< div className = "grid grid-cols-3 gap-2 mt-1" role = "radiogroup" aria-label = "Kind of work" > { JOB_TYPES . map ( t = >
< button key = { t . id } type = "button" role = "radio" aria-checked = { jobType === t . id } onClick = { ( ) = > setJobType ( t . id ) }
className = { ` plant-clock-button min-h-[56px] ${ jobType === t . id ? 'plant-clock-primary' : 'plant-clock-secondary' } ` } > { t . label } < / button > ) } < / div >
< label htmlFor = "note" className = "block text-sm font-medium mt-4" > What was done ? < / label >
< textarea id = "note" className = "plant-clock-input mt-1 min-h-[110px]" maxLength = { 1000 } value = { note } onChange = { e = > setNote ( e . target . value ) } placeholder = "e.g. Changed engine oil and filters, greased all points" / >
< button className = "plant-clock-button plant-clock-primary w-full mt-5" type = "submit" disabled = { busy || ! jobType || ! note . trim ( ) || ( ! noMeter && ! odometer . trim ( ) ) } > < Square size = { 16 } / > { busy ? 'Saving…' : 'Finish job' } < / button >
< / form > }
< / section > ;
}