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 { useMemo , useState } from "react" ;
import { useRouter } from "next/navigation" ;
import { CreditCard , Plus } from "lucide-react" ;
import { cashBalance , hasOpening , rand , sign } from "@/lib/cash-float" ;
import { inBounds , presetBounds , saDay , type LogPreset } from "@/lib/oil-log" ;
export type CashRow = {
id : string ; kind : string ; amount : number ; at : string ; supplier : string ; costCode : string ;
asset : string ; comment : string ; photo : boolean ; by : string ;
} ;
const PRESETS : { id : LogPreset ; label : string } [ ] = [
{ id : "today" , label : "Today" } , { id : "week" , label : "This week" } , { id : "month" , label : "This month" } ,
{ id : "all" , label : "Everything" } , { id : "range" , label : "Date range" } ,
] ;
const when = ( s : string ) = > ( s ? new Date ( s . replace ( " " , "T" ) ) . toLocaleString ( "en-ZA" , {
timeZone : "Africa/Johannesburg" , day : "numeric" , month : "short" , year : "numeric" , hour : "2-digit" , minute : "2-digit" , hourCycle : "h23" } ) : "—" ) ;
const KIND : Record < string , string > = { opening : "Opening balance" , topup : "Top-up" , expense : "Slip" } ;
/ * *
* The cash - float card : opening balance , then every slip ( photographed in the Plant Clock app )
* comes off it . Shown as a ledger so any rand can be traced to a slip .
* /
2026-10-22 13:43:57 +00:00
export function CashFloatCard ( { rows , costCodes = [ ] , suppliers = [ ] } : { rows : CashRow [ ] ; 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 [ preset , setPreset ] = useState < LogPreset > ( "month" ) ;
const [ from , setFrom ] = useState ( "" ) ;
const [ to , setTo ] = useState ( "" ) ;
const [ dialog , setDialog ] = useState < "opening" | "topup" | "correct" | null > ( null ) ;
const [ manual , setManual ] = useState ( false ) ;
const [ mSupplier , setMSupplier ] = useState ( "" ) ;
const [ mCode , setMCode ] = useState ( "" ) ;
const [ mComment , setMComment ] = useState ( "" ) ;
const [ mAmount , setMAmount ] = useState ( "" ) ;
const [ mDate , setMDate ] = useState ( "" ) ;
const [ mPhoto , setMPhoto ] = useState < File | null > ( null ) ;
const [ amount , setAmount ] = useState ( "" ) ;
const [ comment , setComment ] = useState ( "" ) ;
const [ busy , setBusy ] = useState ( false ) ;
const [ error , setError ] = useState ( "" ) ;
const [ deleting , setDeleting ] = useState < CashRow | null > ( null ) ;
const started = hasOpening ( rows ) ;
const balance = cashBalance ( rows ) ;
const opening = rows . find ( ( r ) = > r . kind === "opening" ) ? . amount ? ? 0 ;
const toppedUp = rows . filter ( ( r ) = > r . kind === "topup" ) . reduce ( ( s , r ) = > s + r . amount , 0 ) ;
const spent = rows . filter ( ( r ) = > r . kind === "expense" ) . reduce ( ( s , r ) = > s + r . amount , 0 ) ;
const shown = useMemo ( ( ) = > {
const b = presetBounds ( preset , saDay ( new Date ( ) . toISOString ( ) ) , from , to ) ;
return rows . filter ( ( r ) = > r . kind !== "opening" && inBounds ( saDay ( r . at ) , b ) ) ;
} , [ rows , preset , from , to ] ) ;
const shownSpent = shown . filter ( ( r ) = > r . kind === "expense" ) . reduce ( ( s , r ) = > s + r . amount , 0 ) ;
function open ( kind : "opening" | "topup" | "correct" ) {
setDialog ( kind ) ; setError ( "" ) ;
setAmount ( kind === "correct" ? String ( opening ) : "" ) ; setComment ( "" ) ;
}
async function send ( url : string , method : string , body : object ) {
setBusy ( true ) ; setError ( "" ) ;
try {
const res = await fetch ( url , { method , headers : { "Content-Type" : "application/json" } , body : JSON.stringify ( body ) } ) ;
const j = await res . json ( ) . catch ( ( ) = > ( { } ) ) ;
if ( ! res . ok || j . ok === false || j . error ) throw new Error ( j . error || "Could not save" ) ;
setDialog ( null ) ; setDeleting ( null ) ; router . refresh ( ) ;
} catch ( e : any ) { setError ( e . message ) ; } finally { setBusy ( false ) ; }
}
function openManual() {
setManual ( true ) ; setError ( "" ) ; setMSupplier ( "" ) ; setMCode ( "" ) ; setMComment ( "" ) ; setMAmount ( "" ) ; setMPhoto ( null ) ;
const d = new Date ( ) ; d . setMinutes ( d . getMinutes ( ) - d . getTimezoneOffset ( ) ) ;
setMDate ( d . toISOString ( ) . slice ( 0 , 16 ) ) ;
}
async function submitManual ( e : React.FormEvent ) {
e . preventDefault ( ) ;
setBusy ( true ) ; setError ( "" ) ;
try {
const body = new FormData ( ) ;
body . set ( "supplier" , mSupplier ) ; body . set ( "cost_code" , mCode ) ; body . set ( "comment" , mComment ) ; body . set ( "amount" , mAmount ) ;
if ( mDate ) body . set ( "occurred_on" , new Date ( mDate ) . toISOString ( ) ) ;
if ( mPhoto ) body . set ( "slip_photo" , mPhoto ) ;
const res = await fetch ( "/api/cash-float/entries" , { method : "POST" , body } ) ;
const j = await res . json ( ) . catch ( ( ) = > ( { } ) ) ;
if ( ! res . ok || ! j . ok ) throw new Error ( j . error || "Could not save" ) ;
setManual ( false ) ; router . refresh ( ) ;
} catch ( err : any ) { setError ( err . message ) ; } finally { setBusy ( false ) ; }
}
function submit ( e : React.FormEvent ) {
e . preventDefault ( ) ;
if ( dialog === "opening" ) send ( "/api/clock/cash-float/opening" , "POST" , { amount } ) ;
else if ( dialog === "correct" ) send ( "/api/cash-float/opening" , "PUT" , { amount } ) ;
else if ( dialog === "topup" ) send ( "/api/cash-float/topup" , "POST" , { amount , comment } ) ;
}
const field = "min-h-[44px] rounded-lg border border-gray-200 dark:border-border bg-white dark:bg-surface px-3 text-sm" ;
const card = "bg-white dark:bg-surface border border-gray-100 dark:border-border rounded-2xl shadow-sm" ;
return (
< section className = "mb-8" >
< div className = "flex flex-wrap items-start justify-between gap-3 mb-4" >
< div className = "flex items-center gap-3" >
< div className = "w-10 h-10 shrink-0 rounded-xl bg-gradient-to-br from-[#00798C]/15 to-[#30638E]/15 grid place-items-center text-[#00798C]" > < CreditCard className = "w-5 h-5" / > < / div >
< div >
< h1 className = "text-2xl font-semibold text-[#003D5B] dark:text-white" > Cash Floats < / h1 >
< p className = "text-xs text-gray-500 dark:text-gray-400" > The cash - float card . Slips photographed in the Plant Clock app , or added here by hand , come off the balance . < / p >
< / div >
< / div >
{ started && (
< div className = "flex gap-2" >
< button onClick = { ( ) = > open ( "correct" ) } className = "min-h-[44px] px-3 rounded-lg border border-gray-200 dark:border-border text-xs font-medium" > Correct opening balance < / button >
< button onClick = { openManual } className = "min-h-[44px] px-4 rounded-lg bg-[#00798C] text-white text-xs font-medium inline-flex items-center gap-1.5" > < Plus className = "w-4 h-4" / > Add entry < / button >
< button onClick = { ( ) = > open ( "topup" ) } className = "min-h-[44px] px-4 rounded-lg bg-[#30638E] text-white text-xs font-medium inline-flex items-center gap-1.5" > < Plus className = "w-4 h-4" / > Top up card < / button >
< / div >
) }
< / div >
{ ! started ? (
< div className = { ` ${ card } p-6 text-center ` } >
< p className = "text-sm font-medium text-[#003D5B] dark:text-white" > The card has no opening balance yet < / p >
< p className = "text-xs text-gray-500 dark:text-gray-400 mt-1" > Enter what is on the card now . Every slip will come off this amount . < / p >
< button onClick = { ( ) = > open ( "opening" ) } className = "mt-4 min-h-[44px] px-5 rounded-lg bg-[#30638E] text-white text-sm font-medium" > Set opening balance < / button >
< / div >
) : (
< >
< div className = "grid grid-cols-2 lg:grid-cols-4 gap-3 mb-4" >
< div className = { ` ${ card } p-4 ` } > < p className = "text-xs text-gray-500" > Balance on card < / p > < p className = { ` text-2xl font-semibold tabular-nums mt-1 ${ balance < 0 ? "text-[#D1495B]" : "text-[#00798C]" } ` } > { rand ( balance ) } < / p > < / div >
< div className = { ` ${ card } p-4 ` } > < p className = "text-xs text-gray-500" > Opening balance < / p > < p className = "text-xl font-semibold tabular-nums mt-1" > { rand ( opening ) } < / p > < / div >
< div className = { ` ${ card } p-4 ` } > < p className = "text-xs text-gray-500" > Topped up < / p > < p className = "text-xl font-semibold tabular-nums mt-1" > { rand ( toppedUp ) } < / p > < / div >
< div className = { ` ${ card } p-4 ` } > < p className = "text-xs text-gray-500" > Spent on slips < / p > < p className = "text-xl font-semibold tabular-nums mt-1" > { rand ( spent ) } < / p > < / div >
< / div >
< div className = { ` ${ card } p-4 sm:p-5 ` } >
< div className = "flex flex-col lg:flex-row lg:items-center gap-3 mb-3" >
< div className = "hidden lg:flex gap-1.5" role = "group" aria-label = "Period" >
{ PRESETS . map ( ( p ) = > (
< button key = { p . id } onClick = { ( ) = > setPreset ( p . id ) } aria-pressed = { preset === p . id }
className = { ` min-h-[44px] px-4 rounded-lg text-xs font-medium border ${ preset === p . id ? "bg-[#30638E] text-white border-[#30638E]" : "border-gray-200 dark:border-border text-gray-600 dark:text-gray-300" } ` } > { p . label } < / button >
) ) }
< / div >
< select className = { ` ${ field } lg:hidden ` } value = { preset } onChange = { ( e ) = > setPreset ( e . target . value as LogPreset ) } aria-label = "Period" >
{ PRESETS . map ( ( p ) = > < option key = { p . id } value = { p . id } > { p . label } < / option > ) }
< / select >
{ preset === "range" && (
< div className = "flex items-center gap-2" >
< input type = "date" className = { field } value = { from } max = { to || undefined } onChange = { ( e ) = > setFrom ( e . target . value ) } aria-label = "From" / >
< span className = "text-xs text-gray-500" > to < / span >
< input type = "date" className = { field } value = { to } min = { from || undefined } onChange = { ( e ) = > setTo ( e . target . value ) } aria-label = "To" / >
< / div >
) }
< span className = "lg:ml-auto text-xs font-medium px-3 py-1.5 rounded-lg bg-[#D1495B]/10 text-[#D1495B]" > Spent in this period : { rand ( shownSpent ) } < / span >
< / div >
{ shown . length === 0 ? (
< p className = "text-xs text-gray-500 dark:text-gray-400 py-4" > No slips in this period . Office staff photograph slips in the Plant Clock app , on the Slips tab . < / p >
) : (
< div className = "overflow-x-auto [-webkit-overflow-scrolling:touch]" >
< table className = "w-full text-sm min-w-[900px]" >
< thead >
< tr className = "text-left text-xs text-gray-500 dark:text-gray-400" >
< th className = "sticky left-0 bg-white dark:bg-surface py-2 pr-3 font-medium" > When < / th >
< th className = "py-2 pr-3 font-medium" > Type < / th >
< th className = "py-2 pr-3 font-medium" > Supplier < / th >
< th className = "py-2 pr-3 font-medium" > Cost code < / th >
< th className = "py-2 pr-3 font-medium" > Comments < / th >
< th className = "py-2 pr-3 font-medium text-right" > Amount < / th >
< th className = "py-2 pr-3 font-medium" > By < / th >
< th className = "py-2 font-medium" > Slip < / th >
< / tr >
< / thead >
< tbody >
{ shown . map ( ( r ) = > (
< tr key = { r . id } className = "border-t border-gray-100 dark:border-border align-top" >
< td className = "sticky left-0 bg-white dark:bg-surface py-2 pr-3 whitespace-nowrap tabular-nums" > { when ( r . at ) } < / td >
< td className = "py-2 pr-3 whitespace-nowrap" > { KIND [ r . kind ] ? ? r . kind } < / td >
< td className = "py-2 pr-3" > { r . supplier || "—" } < / td >
< td className = "py-2 pr-3 font-mono" > { r . costCode || "—" } { r . asset && < span className = "ml-1 text-[10px] text-[#00798C]" title = "Linked to a vehicle" > ● < / span > } < / td >
< td className = "py-2 pr-3 min-w-[200px] whitespace-pre-wrap break-words" > { r . comment || "—" } < / td >
< td className = { ` py-2 pr-3 text-right tabular-nums whitespace-nowrap ${ sign ( r . kind ) < 0 ? "text-[#D1495B]" : "text-[#00798C]" } ` } > { sign ( r . kind ) < 0 ? "− " : "+" } { rand ( r . amount ) } < / td >
< td className = "py-2 pr-3" > { r . by || "—" } < / td >
< td className = "py-2 whitespace-nowrap" >
< div className = "flex items-center gap-1" >
{ r . photo && < a href = { ` /api/cash-float/entries/ ${ r . id } /photo ` } target = "_blank" rel = "noopener noreferrer" className = "inline-flex min-h-[44px] items-center px-2 text-[#30638E] underline" > View slip < / a > }
< button onClick = { ( ) = > { setError ( "" ) ; setDeleting ( r ) ; } } className = "min-h-[44px] px-2 text-xs text-[#D1495B] hover:bg-[#D1495B]/10 rounded-lg" > Delete < / button >
< / div >
< / td >
< / tr >
) ) }
< / tbody >
< / table >
< / div >
) }
< / div >
< / >
) }
{ manual && (
< div className = "fixed inset-0 z-50 grid place-items-center bg-black/50 p-4" onClick = { ( ) = > ! busy && setManual ( false ) } >
< form onClick = { ( e ) = > e . stopPropagation ( ) } onSubmit = { submitManual } role = "dialog" aria-modal = "true" aria-label = "Add entry"
className = "w-full max-w-md max-h-[92svh] overflow-y-auto rounded-2xl bg-white dark:bg-surface p-5 shadow-xl" >
< h3 className = "text-base font-semibold text-[#003D5B] dark:text-white" > Add an entry by hand < / h3 >
< p className = "text-xs text-gray-500 dark:text-gray-400 mt-1" > For a slip that was not photographed . It comes off the card balance like any other slip . < / p >
< label htmlFor = "m-supplier" className = "block text-sm font-medium mt-4" > Supplier < / label >
2026-10-22 13:43:57 +00:00
< input id = "m-supplier" autoFocus list = "cf-suppliers" value = { mSupplier } onChange = { ( e ) = > setMSupplier ( e . target . value ) } maxLength = { 200 } className = { ` ${ field } w-full mt-1 ` } / >
< datalist id = "cf-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 = "m-amount" className = "block text-sm font-medium mt-3" > Amount ( R ) < / label >
< input id = "m-amount" type = "text" inputMode = "decimal" value = { mAmount } onChange = { ( e ) = > setMAmount ( e . target . value ) } className = { ` ${ field } w-full mt-1 text-lg tabular-nums ` } placeholder = "0.00" / >
< label htmlFor = "m-date" className = "block text-sm font-medium mt-3" > Date and time of the slip < / label >
< input id = "m-date" type = "datetime-local" value = { mDate } onChange = { ( e ) = > setMDate ( e . target . value ) } className = { ` ${ field } w-full mt-1 ` } / >
< label htmlFor = "m-code" className = "block text-sm font-medium mt-3" > Cost code < span className = "font-normal text-gray-500" > ( optional ) < / span > < / label >
< input id = "m-code" list = "cf-codes" value = { mCode } onChange = { ( e ) = > setMCode ( e . target . value ) } maxLength = { 100 } className = { ` ${ field } w-full mt-1 ` } placeholder = "e.g. TLB04" / >
< datalist id = "cf-codes" > { costCodes . map ( ( c ) = > < option key = { c } value = { c } / > ) } < / datalist >
< label htmlFor = "m-comment" className = "block text-sm font-medium mt-3" > Comments < span className = "font-normal text-gray-500" > ( optional ) < / span > < / label >
< textarea id = "m-comment" value = { mComment } onChange = { ( e ) = > setMComment ( e . target . value ) } maxLength = { 1000 } className = "mt-1 w-full min-h-[70px] rounded-lg border border-gray-200 dark:border-border bg-white dark:bg-surface p-2 text-sm" / >
< label htmlFor = "m-photo" className = "block text-sm font-medium mt-3" > Photo of the slip < span className = "font-normal text-gray-500" > ( optional ) < / span > < / label >
< input id = "m-photo" type = "file" accept = "image/*" onChange = { ( e ) = > setMPhoto ( e . target . files ? . [ 0 ] ? ? null ) } className = "mt-1 w-full text-sm" / >
{ error && < p role = "alert" className = "text-xs text-[#D1495B] mt-3" > { error } < / p > }
< div className = "flex justify-end gap-2 mt-5" >
< button type = "button" disabled = { busy } onClick = { ( ) = > setManual ( false ) } className = "min-h-[44px] px-4 rounded-lg text-sm border border-gray-200 dark:border-border" > Cancel < / button >
< button type = "submit" disabled = { busy || ! mSupplier . trim ( ) || ! mAmount . trim ( ) } className = "min-h-[44px] px-5 rounded-lg text-sm font-medium text-white bg-[#00798C] disabled:opacity-60" > { busy ? "Saving…" : "Add entry" } < / button >
< / div >
< / form >
< / div >
) }
{ dialog && (
< div className = "fixed inset-0 z-50 grid place-items-center bg-black/50 p-4" onClick = { ( ) = > ! busy && setDialog ( null ) } >
< form onClick = { ( e ) = > e . stopPropagation ( ) } onSubmit = { submit } role = "dialog" aria-modal = "true" className = "w-full max-w-sm rounded-2xl bg-white dark:bg-surface p-5 shadow-xl" >
< h3 className = "text-base font-semibold text-[#003D5B] dark:text-white" >
{ dialog === "opening" ? "Set the opening balance" : dialog === "correct" ? "Correct the opening balance" : "Top up the card" }
< / h3 >
< p className = "text-xs text-gray-500 dark:text-gray-400 mt-1" >
{ dialog === "topup" ? "Money loaded onto the card. It is added to the balance." : "The balance on the card when tracking started. Slips and top-ups are not changed." }
< / p >
< label htmlFor = "cf-amount" className = "block text-sm font-medium mt-4" > Amount ( R ) < / label >
< input id = "cf-amount" autoFocus type = "text" inputMode = "decimal" value = { amount } onChange = { ( e ) = > setAmount ( e . target . value ) } className = { ` ${ field } w-full text-lg tabular-nums mt-1 ` } placeholder = "0.00" / >
{ dialog === "topup" && ( < >
< label htmlFor = "cf-comment" className = "block text-sm font-medium mt-3" > Note < span className = "font-normal text-gray-500" > ( optional ) < / span > < / label >
< input id = "cf-comment" value = { comment } onChange = { ( e ) = > setComment ( e . target . value ) } className = { ` ${ field } w-full mt-1 ` } maxLength = { 1000 } / >
< / > ) }
{ error && < p role = "alert" className = "text-xs text-[#D1495B] mt-3" > { error } < / p > }
< div className = "flex justify-end gap-2 mt-5" >
< button type = "button" disabled = { busy } onClick = { ( ) = > setDialog ( null ) } className = "min-h-[44px] px-4 rounded-lg text-sm border border-gray-200 dark:border-border" > Cancel < / button >
< button type = "submit" disabled = { busy || ! amount . trim ( ) } className = "min-h-[44px] px-5 rounded-lg text-sm font-medium text-white bg-[#30638E] disabled:opacity-60" > { busy ? "Saving…" : "Save" } < / button >
< / div >
< / form >
< / div >
) }
{ deleting && (
< div className = "fixed inset-0 z-50 grid place-items-center bg-black/50 p-4" onClick = { ( ) = > ! busy && setDeleting ( null ) } >
< div onClick = { ( e ) = > e . stopPropagation ( ) } role = "alertdialog" aria-modal = "true" className = "w-full max-w-sm rounded-2xl bg-white dark:bg-surface p-5 shadow-xl" >
< h3 className = "text-base font-semibold text-[#003D5B] dark:text-white" > Delete this entry ? < / h3 >
< p className = "text-sm text-gray-600 dark:text-gray-300 mt-2" > { KIND [ deleting . kind ] } · { deleting . supplier || "—" } · { rand ( deleting . amount ) } < / p >
< p className = "text-xs text-gray-500 dark:text-gray-400 mt-2" > The balance is recalculated without it . The slip photo is deleted too . This cannot be undone . < / p >
{ error && < p role = "alert" className = "text-xs text-[#D1495B] mt-3" > { error } < / p > }
< div className = "flex justify-end gap-2 mt-5" >
< button disabled = { busy } onClick = { ( ) = > setDeleting ( null ) } className = "min-h-[44px] px-4 rounded-lg text-sm border border-gray-200 dark:border-border" > Cancel < / button >
< button disabled = { busy } onClick = { ( ) = > send ( ` /api/cash-float/entries/ ${ deleting . id } ` , "DELETE" , { } ) } className = "min-h-[44px] px-5 rounded-lg text-sm font-medium text-white bg-[#D1495B] disabled:opacity-60" > { busy ? "Deleting…" : "Delete" } < / button >
< / div >
< / div >
< / div >
) }
< / section >
) ;
}