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:
parent
4cbe68ebf7
commit
e6188fb337
1 changed files with 77 additions and 57 deletions
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue