Add proper state machine flow to DailyCashUp

- On mount/date change: auto-fetch existing cash up
  - Draft found → auto-load into editing state
  - Finalised → auto-load into locked state (read-only + banner)
  - Not found → show 'Start Cash Up' prompt
- Removed manual 'Load Existing' / 'New' buttons
- Submitting final transitions immediately to locked state

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-01 21:30:22 +00:00
parent 4cbe68ebf7
commit e6188fb337

View file

@ -1,4 +1,4 @@
import { useState } from 'react'
import { useState, useEffect } from 'react'
import { RefreshCw, Save, CheckCircle, Loader } from 'lucide-react'
import { api } from '../api'
import { PageHeader, Card, Btn, StatusBadge } from '../components/Layout'
@ -31,8 +31,12 @@ function denomTotal(denoms: Denomination[]) {
interface Props { user: User }
// flow: checking → empty (no record) | editing (draft) | locked (final)
type PageState = 'checking' | 'empty' | 'editing' | 'locked'
export function DailyCashUp({ user: _user }: Props) {
const [date, setDate] = useState(today())
const [pageState, setPageState] = useState<PageState>('checking')
const [cashUp, setCashUp] = useState<CashUp | null>(null)
const [takings, setTakings] = useState<Denomination[]>(initDenominations('takings'))
const [float, setFloat] = useState<Denomination[]>(initDenominations('float'))
@ -43,60 +47,49 @@ export function DailyCashUp({ user: _user }: Props) {
const [tillPayments, setTillPayments] = useState<TillPayment[]>([])
const [fetching, setFetching] = useState(false)
const [saving, setSaving] = useState(false)
const [loading, setLoading] = useState(false)
const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null)
const [showFloat, setShowFloat] = useState(false)
const isFinal = cashUp?.status === 'final'
const isFinal = pageState === 'locked'
function flash(text: string, ok = true) {
setMsg({ text, ok })
setTimeout(() => setMsg(null), 4000)
}
async function loadExisting() {
setLoading(true)
try {
const data = await api.get<{
cash_up: CashUp; denominations: Denomination[]; card_machines: CardMachine[];
reconciliation: ReconciliationRow[]; attachments: Attachment[]
}>(`/cashup?date=${date}`)
function applyLoaded(data: {
cash_up: CashUp; denominations: Denomination[]; card_machines: CardMachine[]; attachments: Attachment[]
}) {
setCashUp(data.cash_up)
setNotes(data.cash_up.notes || '')
setAttachments(data.attachments || [])
setCashUp(data.cash_up)
setNotes(data.cash_up.notes || '')
setAttachments(data.attachments || [])
const rebuild = (ct: 'takings' | 'float') =>
GBP_DENOMINATIONS.map(d => {
const saved = data.denominations.find(
s => s.count_type === ct && parseFloat(String(s.denomination_value)) === d.value
)
return saved
? { ...saved, denomination_value: d.value }
: { count_type: ct, denomination_type: d.type, denomination_value: d.value, quantity: null, value_entered: null, total_amount: 0 }
})
// Rebuild denomination grids from saved data
const rebuild = (ct: 'takings' | 'float') =>
GBP_DENOMINATIONS.map(d => {
const saved = data.denominations.find(
s => s.count_type === ct && parseFloat(String(s.denomination_value)) === d.value
)
return saved
? { ...saved, denomination_value: d.value }
: { count_type: ct, denomination_type: d.type, denomination_value: d.value, quantity: null, value_entered: null, total_amount: 0 }
})
setTakings(rebuild('takings'))
setFloat(rebuild('float'))
setTakings(rebuild('takings'))
setFloat(rebuild('float'))
if (data.card_machines.length) {
setMachines(MACHINES.map(name => {
const m = data.card_machines.find(c => c.machine_name === name)
return m ?? { machine_name: name, total_amount: 0, amex_amount: 0, visa_mc_amount: 0 }
}))
}
flash('Loaded existing cash up.')
} catch (e: unknown) {
if (e instanceof Error && e.message === 'Not found') flash('No cash up for this date.', false)
else flash('Failed to load.', false)
} finally {
setLoading(false)
if (data.card_machines.length) {
setMachines(MACHINES.map(name => {
const m = data.card_machines.find(c => c.machine_name === name)
return m ?? { machine_name: name, total_amount: 0, amex_amount: 0, visa_mc_amount: 0 }
}))
}
setPageState(data.cash_up.status === 'final' ? 'locked' : 'editing')
}
function reset() {
// Auto-check on mount and whenever date changes
useEffect(() => {
setPageState('checking')
setCashUp(null)
setTakings(initDenominations('takings'))
setFloat(initDenominations('float'))
@ -105,7 +98,17 @@ export function DailyCashUp({ user: _user }: Props) {
setAttachments([])
setNewbookTotals(null)
setTillPayments([])
}
api.get<{
cash_up: CashUp; denominations: Denomination[]; card_machines: CardMachine[];
reconciliation: ReconciliationRow[]; attachments: Attachment[]
}>(`/cashup?date=${date}`)
.then(data => applyLoaded(data))
.catch(e => {
if (e instanceof Error && e.message === 'Not found') setPageState('empty')
else { flash('Failed to load data for this date.', false); setPageState('empty') }
})
}, [date])
function updateDenom(list: Denomination[], setList: (d: Denomination[]) => void, idx: number, field: 'quantity' | 'value_entered', raw: string) {
const val = raw === '' ? null : parseFloat(raw)
@ -174,6 +177,7 @@ export function DailyCashUp({ user: _user }: Props) {
flash(result.message)
if (status === 'final') {
setCashUp(prev => prev ? { ...prev, status: 'final' } : null)
setPageState('locked')
}
} catch (e: unknown) {
flash(e instanceof Error ? e.message : 'Save failed.', false)
@ -196,8 +200,7 @@ export function DailyCashUp({ user: _user }: Props) {
return (
<div style={{ padding: '1.5rem', maxWidth: '900px' }}>
<PageHeader title="Daily Cash Up" subtitle={cashUp ? `Status: ` : undefined} />
{cashUp && <div style={{ marginTop: '-1rem', marginBottom: '1rem' }}><StatusBadge status={cashUp.status} /></div>}
<PageHeader title="Daily Cash Up" />
{msg && (
<div style={{
@ -213,19 +216,40 @@ export function DailyCashUp({ user: _user }: Props) {
<Card style={{ marginBottom: '1rem', display: 'flex', alignItems: 'center', gap: '1rem', flexWrap: 'wrap' }}>
<div>
<label style={{ fontSize: '0.8rem', color: 'var(--text-mid)', display: 'block', marginBottom: '0.25rem' }}>Business Date</label>
<input type="date" value={date} disabled={isFinal}
onChange={e => { setDate(e.target.value); reset() }}
<input type="date" value={date}
onChange={e => setDate(e.target.value)}
style={{ border: '1px solid var(--card-border)', borderRadius: '6px', padding: '0.45rem 0.75rem', fontSize: '0.9rem' }}
/>
</div>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'flex-end', paddingBottom: '1px' }}>
<Btn onClick={loadExisting} disabled={loading || isFinal} variant="secondary" small>
{loading ? 'Loading…' : 'Load Existing'}
</Btn>
{cashUp && <Btn onClick={reset} variant="ghost" small>New</Btn>}
</div>
{cashUp && <div style={{ paddingBottom: '1px' }}><StatusBadge status={cashUp.status} /></div>}
</Card>
{/* Checking */}
{pageState === 'checking' && (
<div style={{ color: 'var(--text-mid)', padding: '2rem 0', textAlign: 'center' }}>Loading</div>
)}
{/* No cash up for this date */}
{pageState === 'empty' && (
<Card style={{ textAlign: 'center', padding: '2.5rem 1.5rem' }}>
<p style={{ color: 'var(--text-mid)', marginBottom: '1.25rem' }}>No cash up recorded for this date.</p>
<Btn onClick={() => setPageState('editing')}>Start Cash Up</Btn>
</Card>
)}
{/* Locked banner */}
{pageState === 'locked' && (
<div style={{
background: '#f0fdf4', border: '1px solid #bbf7d0', borderRadius: '6px',
padding: '0.75rem 1rem', marginBottom: '1rem', fontSize: '0.875rem',
color: '#16a34a', display: 'flex', alignItems: 'center', gap: '0.5rem',
}}>
<CheckCircle size={15} /> This cash up has been finalised and is locked.
</div>
)}
{(pageState === 'editing' || pageState === 'locked') && <>
{/* Cash denomination — Takings */}
<Card style={{ marginBottom: '1rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
@ -363,7 +387,7 @@ export function DailyCashUp({ user: _user }: Props) {
</Card>
{/* Action buttons */}
{!isFinal && (
{pageState === 'editing' && (
<div style={{ display: 'flex', gap: '0.75rem' }}>
<Btn onClick={() => save('draft')} disabled={saving} variant="secondary">
<Save size={14} style={{ marginRight: '0.4rem' }} />
@ -376,11 +400,7 @@ export function DailyCashUp({ user: _user }: Props) {
</div>
)}
{isFinal && (
<div style={{ color: 'var(--text-mid)', fontSize: '0.875rem', fontStyle: 'italic' }}>
This cash up has been finalised and cannot be edited.
</div>
)}
</> /* end editing | locked */}
<style>{`@keyframes spin { from { transform: rotate(0deg) } to { transform: rotate(360deg) } }`}</style>
</div>