Cash float card, Office role + Slips tab, job card PDF, manual close/delete
- Cash Floats: ledger card (opening balance, top-ups, slips), manual entries, slip photos
- Office access level; clock app Slips tab (photograph a slip, popup for supplier/cost
code/comments/amount); only Office sees it; per-role route table in lib/clock-access
- Job cards: Machine Service PDF (with Oil block), JC numbers, manual Close job, Delete
- Hide Expenses > Vehicle Costs and Finance (Bastion) from the menu
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 13:09:57 +00:00
'use client' ;
import { useState } from 'react' ;
import { useRouter } from 'next/navigation' ;
import { AlertTriangle , Camera , CheckCircle2 , X } from 'lucide-react' ;
import { shrinkImage } from '@/lib/shrink-image' ;
import { rand } from '@/lib/cash-float' ;
/ * *
* Photograph a cash - card slip , fill in what it was for , and it comes off the card ' s balance .
* The first time ever , the card ' s opening balance is asked for .
* /
2026-10-22 13:43:57 +00:00
export function SlipCapture ( { hasOpening , balance , costCodes , suppliers } : { hasOpening : boolean ; balance : number ; costCodes : string [ ] ; suppliers : string [ ] } ) {
Cash float card, Office role + Slips tab, job card PDF, manual close/delete
- Cash Floats: ledger card (opening balance, top-ups, slips), manual entries, slip photos
- Office access level; clock app Slips tab (photograph a slip, popup for supplier/cost
code/comments/amount); only Office sees it; per-role route table in lib/clock-access
- Job cards: Machine Service PDF (with Oil block), JC numbers, manual Close job, Delete
- Hide Expenses > Vehicle Costs and Finance (Bastion) from the menu
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 13:09:57 +00:00
const router = useRouter ( ) ;
const [ photo , setPhoto ] = useState < File | null > ( null ) ;
const [ preview , setPreview ] = useState ( '' ) ;
const [ supplier , setSupplier ] = useState ( '' ) ;
const [ costCode , setCostCode ] = useState ( '' ) ;
const [ comment , setComment ] = useState ( '' ) ;
const [ amount , setAmount ] = useState ( '' ) ;
const [ opening , setOpening ] = useState ( '' ) ;
const [ busy , setBusy ] = useState ( false ) ;
const [ error , setError ] = useState ( '' ) ;
const [ message , setMessage ] = useState ( '' ) ;
async function picked ( file : File | undefined ) {
if ( ! file ) return ;
const small = await shrinkImage ( file , 'slip.jpg' , 1800 ) ;
setPhoto ( small ) ; setPreview ( URL . createObjectURL ( small ) ) ;
setSupplier ( '' ) ; setCostCode ( '' ) ; setComment ( '' ) ; setAmount ( '' ) ; setError ( '' ) ; setMessage ( '' ) ;
}
function close() { if ( ! busy ) { setPhoto ( null ) ; setPreview ( '' ) ; } }
async function saveOpening ( e : React.FormEvent ) {
e . preventDefault ( ) ;
setBusy ( true ) ; setError ( '' ) ;
try {
const res = await fetch ( '/api/clock/cash-float/opening' , { method : 'POST' , headers : { 'Content-Type' : 'application/json' } , body : JSON.stringify ( { amount : opening } ) } ) ;
const data = await res . json ( ) . catch ( ( ) = > ( { } ) ) ;
if ( ! res . ok ) throw new Error ( data . error || 'Could not save.' ) ;
router . refresh ( ) ;
} catch ( err ) { setError ( err instanceof Error ? err . message : 'Could not save.' ) ; } finally { setBusy ( false ) ; }
}
async function saveSlip ( e : React.FormEvent ) {
e . preventDefault ( ) ;
if ( ! photo ) return ;
setBusy ( true ) ; setError ( '' ) ;
try {
const body = new FormData ( ) ;
body . set ( 'slip_photo' , photo ) ; body . set ( 'supplier' , supplier ) ; body . set ( 'cost_code' , costCode ) ; body . set ( 'comment' , comment ) ; body . set ( 'amount' , amount ) ;
const res = await fetch ( '/api/clock/slip' , { method : 'POST' , body } ) ;
const data = await res . json ( ) . catch ( ( ) = > ( { } ) ) ;
if ( ! res . ok ) throw new Error ( data . error || 'Could not save.' ) ;
setMessage ( ` Slip for ${ rand ( data . amount ) } saved. Card balance: ${ rand ( data . balance ) } . ` ) ;
setPhoto ( null ) ; setPreview ( '' ) ; router . refresh ( ) ; navigator . vibrate ? . ( 120 ) ;
} catch ( err ) { setError ( err instanceof Error ? err . message : 'Could not save.' ) ; } finally { setBusy ( false ) ; }
}
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" > Slips < / h1 >
{ message && < div role = "status" className = "plant-clock-banner mt-4 text-white bg-[#00798C]" > < CheckCircle2 size = { 26 } className = "shrink-0" / > < p className = "text-base font-semibold break-words" > { message } < / p > < / div > }
{ ! hasOpening ? < form onSubmit = { saveOpening } className = "plant-clock-panel mt-4 p-4 sm:p-5" >
< h2 className = "text-lg font-semibold" > First time : set up the card < / h2 >
< p className = "text-sm text-text-muted mt-1" > What is the balance on the card right now ? Every slip you photograph will come off this amount . < / p >
< label htmlFor = "opening" className = "block text-sm font-medium mt-4" > Opening balance ( R ) < / label >
< input id = "opening" className = "plant-clock-input mt-1 text-2xl tabular-nums" type = "text" inputMode = "decimal" autoFocus value = { opening } onChange = { e = > setOpening ( e . target . value ) } placeholder = "0.00" / >
{ error && < p role = "alert" className = "plant-clock-warning mt-3" > { error } < / p > }
< button className = "plant-clock-button plant-clock-primary w-full mt-4" type = "submit" disabled = { busy || ! opening . trim ( ) } > { busy ? 'Saving…' : 'Save opening balance' } < / button >
< / form > : < >
< div className = "plant-clock-panel mt-4 p-4 sm:p-5" >
< p className = "text-sm text-text-muted" > Card balance < / p >
< p className = { ` text-4xl font-semibold tabular-nums mt-1 ${ balance < 0 ? 'text-[#D1495B]' : '' } ` } > { rand ( balance ) } < / p >
< / div >
< label htmlFor = "slip-photo" className = "plant-clock-button plant-clock-primary w-full mt-4 min-h-[64px] text-lg cursor-pointer" > < Camera size = { 24 } / > Take photo of slip < / label >
< input id = "slip-photo" type = "file" accept = "image/*" capture = "environment" className = "sr-only" onChange = { e = > { void picked ( e . target . files ? . [ 0 ] ) ; e . target . value = '' ; } } / >
< p className = "text-sm text-text-muted mt-2" > Take a clear photo of the slip . You will then fill in the supplier , cost code , comments and amount . < / p >
< / > }
{ photo && < div className = "fixed inset-0 z-50 grid place-items-end sm:place-items-center bg-black/60 p-0 sm:p-4" onClick = { close } >
< form onClick = { e = > e . stopPropagation ( ) } onSubmit = { saveSlip } role = "dialog" aria-modal = "true" aria-label = "Slip details"
className = "w-full max-w-md max-h-[94svh] overflow-y-auto rounded-t-2xl sm:rounded-2xl bg-surface p-4 sm:p-5 shadow-xl" >
< div className = "flex items-start justify-between gap-3" >
< h2 className = "text-lg font-semibold" > Slip details < / h2 >
< button type = "button" onClick = { close } disabled = { busy } className = "plant-clock-button plant-clock-secondary shrink-0" aria-label = "Cancel" > < X size = { 16 } / > < / button >
< / div >
< img src = { preview } alt = "Photo of the slip" className = "mt-3 max-h-48 w-full rounded-xl object-contain bg-bg border border-border" / >
< label htmlFor = "supplier" className = "block text-sm font-medium mt-4" > Supplier < / label >
2026-10-22 13:43:57 +00:00
< input id = "supplier" className = "plant-clock-input mt-1" list = "slip-suppliers" value = { supplier } onChange = { e = > setSupplier ( e . target . value ) } maxLength = { 200 } autoComplete = "off" placeholder = "e.g. Builders Warehouse" / >
< datalist id = "slip-suppliers" > { suppliers . map ( s = > < option key = { s } value = { s } / > ) } < / datalist >
Cash float card, Office role + Slips tab, job card PDF, manual close/delete
- Cash Floats: ledger card (opening balance, top-ups, slips), manual entries, slip photos
- Office access level; clock app Slips tab (photograph a slip, popup for supplier/cost
code/comments/amount); only Office sees it; per-role route table in lib/clock-access
- Job cards: Machine Service PDF (with Oil block), JC numbers, manual Close job, Delete
- Hide Expenses > Vehicle Costs and Finance (Bastion) from the menu
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 13:09:57 +00:00
< label htmlFor = "cost-code" className = "block text-sm font-medium mt-3" > Cost code < span className = "font-normal text-text-muted" > ( optional ) < / span > < / label >
< input id = "cost-code" className = "plant-clock-input mt-1" list = "slip-cost-codes" value = { costCode } onChange = { e = > setCostCode ( e . target . value ) } maxLength = { 100 } autoComplete = "off" autoCapitalize = "characters" placeholder = "e.g. TLB04" / >
< datalist id = "slip-cost-codes" > { costCodes . map ( c = > < option key = { c } value = { c } / > ) } < / datalist >
< label htmlFor = "amount" className = "block text-sm font-medium mt-3" > Amount on the slip ( R ) < / label >
< input id = "amount" className = "plant-clock-input mt-1 text-2xl tabular-nums" type = "text" inputMode = "decimal" value = { amount } onChange = { e = > setAmount ( e . target . value ) } placeholder = "0.00" / >
< label htmlFor = "comment" className = "block text-sm font-medium mt-3" > Comments < span className = "font-normal text-text-muted" > ( optional ) < / span > < / label >
< textarea id = "comment" className = "plant-clock-input mt-1 min-h-[80px]" value = { comment } onChange = { e = > setComment ( e . target . value ) } maxLength = { 1000 } placeholder = "What was it for?" / >
{ error && < p role = "alert" className = "plant-clock-warning mt-3 flex items-start gap-2" > < AlertTriangle size = { 18 } className = "shrink-0 mt-0.5" / > { error } < / p > }
< button className = "plant-clock-button plant-clock-primary w-full mt-4" type = "submit" disabled = { busy || ! supplier . trim ( ) || ! amount . trim ( ) } > { busy ? 'Saving…' : 'Save slip' } < / button >
< / form >
< / div > }
< / section > ;
}