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 { 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>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue