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