- Add vite-env.d.ts for import.meta.env type support - Remove unused imports: ChevronRight, useCallback (x2), today - Silence unused attachments state read (keep setter for future use) - Fix FloatManagement changeTinTargets reducer: drop unused k, fix v type Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
321 lines
16 KiB
TypeScript
321 lines
16 KiB
TypeScript
import { useState, useEffect } from 'react'
|
||
import { Routes, Route, NavLink, Navigate, useNavigate } from 'react-router-dom'
|
||
import { api } from '../api'
|
||
import { PageHeader, Card, Btn } from '../components/Layout'
|
||
import { GBP_DENOMINATIONS, fmtGBP } from '../types'
|
||
import type { FloatCount, FloatDenomination, FloatReceipt } from '../types'
|
||
|
||
type CountType = 'petty_cash' | 'change_tin' | 'safe_cash'
|
||
|
||
const TYPE_LABELS: Record<CountType, string> = {
|
||
petty_cash: 'Petty Cash',
|
||
change_tin: 'Change Tin',
|
||
safe_cash: 'Safe Cash',
|
||
}
|
||
|
||
// Denominations relevant for each type (change_tin uses bags, no £0.02/£0.01)
|
||
const CHANGE_TIN_DENOMS = GBP_DENOMINATIONS.filter(d => d.value >= 0.05)
|
||
|
||
function FloatCountForm({ type }: { type: CountType }) {
|
||
const navigate = useNavigate()
|
||
const [denomQtys, setDenomQtys] = useState<Record<number, number>>({})
|
||
const [receipts, setReceipts] = useState<Array<{ amount: string; description: string }>>([])
|
||
const [notes, setNotes] = useState('')
|
||
const [saving, setSaving] = useState(false)
|
||
const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null)
|
||
|
||
const denoms = type === 'change_tin' ? CHANGE_TIN_DENOMS : GBP_DENOMINATIONS
|
||
|
||
// Load settings for change tin targets
|
||
const [changeTinTargets, setChangeTinTargets] = useState<Record<string, number>>({})
|
||
const [pettyTarget, setPettyTarget] = useState(200)
|
||
|
||
useEffect(() => {
|
||
api.get<Record<string, string>>('/settings').then(s => {
|
||
if (type === 'change_tin') {
|
||
try { setChangeTinTargets(JSON.parse(s.change_tin_breakdown || '{}')) } catch {}
|
||
}
|
||
if (type === 'petty_cash') {
|
||
setPettyTarget(parseFloat(s.petty_cash_float || '200'))
|
||
}
|
||
}).catch(() => {})
|
||
}, [type])
|
||
|
||
const totalCounted = denoms.reduce((s, d) => s + d.value * (denomQtys[d.value] ?? 0), 0)
|
||
const totalReceipts = receipts.reduce((s, r) => s + (parseFloat(r.amount) || 0), 0)
|
||
const targetAmount = type === 'petty_cash' ? pettyTarget : type === 'change_tin'
|
||
? Object.entries(changeTinTargets).reduce((s, [, v]) => s + (v || 0), 0)
|
||
: 0
|
||
const variance = type === 'petty_cash'
|
||
? totalCounted + totalReceipts - pettyTarget
|
||
: type === 'change_tin' ? totalCounted - targetAmount : 0
|
||
|
||
async function save() {
|
||
setSaving(true)
|
||
try {
|
||
await api.post('/floats/save', {
|
||
count_type: type,
|
||
count_date: new Date().toISOString(),
|
||
denominations: denoms.filter(d => (denomQtys[d.value] ?? 0) > 0).map(d => ({
|
||
denomination: d.value, quantity: denomQtys[d.value] ?? 0, total: d.value * (denomQtys[d.value] ?? 0),
|
||
})),
|
||
receipts: type === 'petty_cash' ? receipts.filter(r => r.amount) : [],
|
||
total_counted: totalCounted,
|
||
total_receipts: totalReceipts,
|
||
target_amount: targetAmount,
|
||
variance,
|
||
notes,
|
||
})
|
||
setMsg({ text: 'Count saved.', ok: true })
|
||
setDenomQtys({})
|
||
setReceipts([])
|
||
setNotes('')
|
||
} catch (e: unknown) {
|
||
setMsg({ text: e instanceof Error ? e.message : 'Save failed.', ok: false })
|
||
} finally {
|
||
setSaving(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div style={{ maxWidth: '640px' }}>
|
||
<PageHeader title={TYPE_LABELS[type]} subtitle="Enter denomination counts" />
|
||
|
||
{msg && (
|
||
<div style={{
|
||
background: msg.ok ? '#dcfce7' : '#fee2e2', color: msg.ok ? '#16a34a' : 'var(--danger)',
|
||
border: `1px solid ${msg.ok ? '#bbf7d0' : '#fecaca'}`, borderRadius: '6px',
|
||
padding: '0.625rem 1rem', marginBottom: '1rem', fontSize: '0.875rem',
|
||
}}>
|
||
{msg.text}
|
||
</div>
|
||
)}
|
||
|
||
<Card style={{ marginBottom: '1rem' }}>
|
||
<h2 style={{ fontSize: '0.875rem', fontWeight: 700, marginBottom: '0.75rem', color: 'var(--text-mid)' }}>DENOMINATIONS</h2>
|
||
{denoms.map(d => {
|
||
const qty = denomQtys[d.value] ?? 0
|
||
const target = type === 'change_tin' ? parseFloat(String(changeTinTargets[d.value.toFixed(2)] ?? '0')) : undefined
|
||
const rowTotal = d.value * qty
|
||
return (
|
||
<div key={d.value} style={{ display: 'grid', gridTemplateColumns: '60px 1fr 80px 80px', gap: '0.4rem 0.75rem', alignItems: 'center', marginBottom: '0.35rem' }}>
|
||
<span style={{ fontWeight: 600, fontSize: '0.875rem' }}>{d.label}</span>
|
||
<input type="number" min="0" step="1" value={qty || ''}
|
||
onChange={e => setDenomQtys(prev => ({ ...prev, [d.value]: parseInt(e.target.value) || 0 }))}
|
||
placeholder="0" style={inpSt} />
|
||
{target !== undefined && (
|
||
<span style={{ fontSize: '0.75rem', color: 'var(--text-mid)', textAlign: 'right' }}>
|
||
tgt {fmtGBP(target)}
|
||
</span>
|
||
)}
|
||
<span style={{ textAlign: 'right', fontSize: '0.875rem' }}>{rowTotal > 0 ? fmtGBP(rowTotal) : '—'}</span>
|
||
</div>
|
||
)
|
||
})}
|
||
<div style={{ borderTop: '2px solid var(--card-border)', paddingTop: '0.75rem', marginTop: '0.5rem', display: 'flex', justifyContent: 'space-between', fontWeight: 700 }}>
|
||
<span>Total Counted</span>
|
||
<span>{fmtGBP(totalCounted)}</span>
|
||
</div>
|
||
</Card>
|
||
|
||
{type === 'petty_cash' && (
|
||
<Card style={{ marginBottom: '1rem' }}>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.75rem' }}>
|
||
<h2 style={{ fontSize: '0.875rem', fontWeight: 700, color: 'var(--text-mid)' }}>RECEIPTS</h2>
|
||
<Btn small variant="ghost" onClick={() => setReceipts(r => [...r, { amount: '', description: '' }])}>+ Add</Btn>
|
||
</div>
|
||
{receipts.map((r, i) => (
|
||
<div key={i} style={{ display: 'grid', gridTemplateColumns: '100px 1fr 32px', gap: '0.4rem', marginBottom: '0.35rem', alignItems: 'center' }}>
|
||
<input type="number" min="0" step="0.01" value={r.amount} placeholder="0.00"
|
||
onChange={e => setReceipts(prev => prev.map((x, j) => j === i ? { ...x, amount: e.target.value } : x))}
|
||
style={inpSt} />
|
||
<input type="text" value={r.description} placeholder="Description"
|
||
onChange={e => setReceipts(prev => prev.map((x, j) => j === i ? { ...x, description: e.target.value } : x))}
|
||
style={inpSt} />
|
||
<button onClick={() => setReceipts(prev => prev.filter((_, j) => j !== i))}
|
||
style={{ background: 'none', border: 'none', color: 'var(--danger)', fontSize: '1.1rem', cursor: 'pointer' }}>×</button>
|
||
</div>
|
||
))}
|
||
{receipts.length > 0 && (
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', fontWeight: 600, fontSize: '0.875rem', paddingTop: '0.5rem' }}>
|
||
<span>Total Receipts</span><span>{fmtGBP(totalReceipts)}</span>
|
||
</div>
|
||
)}
|
||
</Card>
|
||
)}
|
||
|
||
{type !== 'safe_cash' && (
|
||
<Card style={{ marginBottom: '1rem' }}>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.875rem', marginBottom: '0.4rem' }}>
|
||
<span style={{ color: 'var(--text-mid)' }}>Target Amount</span>
|
||
<span>{fmtGBP(targetAmount)}</span>
|
||
</div>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.875rem', fontWeight: 700 }}>
|
||
<span>Variance</span>
|
||
<span style={{ color: Math.abs(variance) < 0.01 ? 'var(--text-mid)' : variance > 0 ? 'var(--success)' : 'var(--danger)' }}>
|
||
{Math.abs(variance) < 0.01 ? '£0.00' : (variance > 0 ? '+' : '') + fmtGBP(Math.abs(variance))}
|
||
</span>
|
||
</div>
|
||
</Card>
|
||
)}
|
||
|
||
<Card style={{ marginBottom: '1rem' }}>
|
||
<label style={{ fontSize: '0.875rem', fontWeight: 600, display: 'block', marginBottom: '0.5rem' }}>Notes</label>
|
||
<textarea value={notes} onChange={e => setNotes(e.target.value)} rows={2}
|
||
style={{ width: '100%', border: '1px solid var(--card-border)', borderRadius: '6px', padding: '0.5rem', fontSize: '0.875rem', resize: 'vertical' }} />
|
||
</Card>
|
||
|
||
<div style={{ display: 'flex', gap: '0.75rem' }}>
|
||
<Btn onClick={save} disabled={saving}>{saving ? 'Saving…' : 'Save Count'}</Btn>
|
||
<Btn variant="ghost" onClick={() => navigate(`/floats/${type}/history`)}>View History</Btn>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function FloatHistory({ type }: { type: CountType }) {
|
||
const [rows, setRows] = useState<FloatCount[]>([])
|
||
const [total, setTotal] = useState(0)
|
||
const [offset, setOffset] = useState(0)
|
||
const [detail, setDetail] = useState<(FloatCount & { denominations: FloatDenomination[]; receipts: FloatReceipt[] }) | null>(null)
|
||
const limit = 10
|
||
|
||
useEffect(() => {
|
||
api.get<{ rows: FloatCount[]; total: number }>(`/floats?type=${type}&offset=${offset}&limit=${limit}`)
|
||
.then(d => { setRows(d.rows); setTotal(d.total) })
|
||
.catch(() => {})
|
||
}, [type, offset])
|
||
|
||
async function loadDetail(id: number) {
|
||
const d = await api.get<FloatCount & { denominations: FloatDenomination[]; receipts: FloatReceipt[] }>(`/floats/${id}`)
|
||
setDetail(d)
|
||
}
|
||
|
||
return (
|
||
<div>
|
||
<PageHeader title={`${TYPE_LABELS[type]} History`} />
|
||
{detail ? (
|
||
<Card>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '1rem' }}>
|
||
<h2 style={{ fontSize: '0.875rem', fontWeight: 700 }}>{new Date(detail.count_date).toLocaleString('en-GB')}</h2>
|
||
<Btn small variant="ghost" onClick={() => setDetail(null)}>Back</Btn>
|
||
</div>
|
||
<div style={{ display: 'flex', gap: '2rem', marginBottom: '1rem', fontSize: '0.875rem' }}>
|
||
<span>Total: <strong>{fmtGBP(detail.total_counted)}</strong></span>
|
||
{detail.count_type !== 'safe_cash' && <span>Variance: <strong>{fmtGBP(detail.variance)}</strong></span>}
|
||
{detail.count_type === 'petty_cash' && <span>Receipts: <strong>{fmtGBP(detail.total_receipts)}</strong></span>}
|
||
</div>
|
||
<table style={{ width: '100%', fontSize: '0.8rem', borderCollapse: 'collapse' }}>
|
||
<tbody>
|
||
{detail.denominations.map(d => (
|
||
<tr key={String(d.denomination_value)} style={{ borderBottom: '1px solid var(--card-border)' }}>
|
||
<td style={{ padding: '0.3rem 0.5rem' }}>{fmtGBP(d.denomination_value)}</td>
|
||
<td style={{ padding: '0.3rem 0.5rem', textAlign: 'right' }}>×{d.quantity}</td>
|
||
<td style={{ padding: '0.3rem 0.5rem', textAlign: 'right' }}>{fmtGBP(d.total_amount)}</td>
|
||
{d.target !== undefined && (
|
||
<td style={{ padding: '0.3rem 0.5rem', textAlign: 'right', color: 'var(--text-mid)' }}>tgt {fmtGBP(d.target)}</td>
|
||
)}
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
{detail.receipts.length > 0 && (
|
||
<>
|
||
<h3 style={{ fontSize: '0.8rem', fontWeight: 700, margin: '0.75rem 0 0.4rem', color: 'var(--text-mid)' }}>RECEIPTS</h3>
|
||
{detail.receipts.map(r => (
|
||
<div key={r.id} style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.8rem', borderBottom: '1px solid var(--card-border)', padding: '0.3rem 0.5rem' }}>
|
||
<span>{r.receipt_description || '—'}</span>
|
||
<span>{fmtGBP(r.receipt_value)}</span>
|
||
</div>
|
||
))}
|
||
</>
|
||
)}
|
||
{detail.notes && <p style={{ marginTop: '0.75rem', fontSize: '0.8rem', color: 'var(--text-mid)' }}>{detail.notes}</p>}
|
||
</Card>
|
||
) : (
|
||
<>
|
||
{rows.length === 0 ? (
|
||
<Card><p style={{ color: 'var(--text-mid)', textAlign: 'center', padding: '1rem' }}>No records found.</p></Card>
|
||
) : (
|
||
<Card style={{ padding: 0, overflow: 'hidden' }}>
|
||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.875rem' }}>
|
||
<thead>
|
||
<tr style={{ borderBottom: '2px solid var(--card-border)', background: 'var(--body-bg)' }}>
|
||
<th style={{ ...thS, textAlign: 'left' }}>Date / Time</th>
|
||
<th style={thS}>Total</th>
|
||
{type !== 'safe_cash' && <th style={thS}>Variance</th>}
|
||
<th style={thS}></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{rows.map(row => (
|
||
<tr key={row.id} style={{ borderBottom: '1px solid var(--card-border)' }}>
|
||
<td style={tdS}>{new Date(row.count_date).toLocaleString('en-GB')}</td>
|
||
<td style={{ ...tdS, textAlign: 'right', fontWeight: 600 }}>{fmtGBP(row.total_counted)}</td>
|
||
{type !== 'safe_cash' && (
|
||
<td style={{ ...tdS, textAlign: 'right', color: Math.abs(parseFloat(row.variance)) < 0.01 ? 'var(--text-mid)' : parseFloat(row.variance) > 0 ? 'var(--success)' : 'var(--danger)' }}>
|
||
{Math.abs(parseFloat(row.variance)) < 0.01 ? '£0.00' : (parseFloat(row.variance) > 0 ? '+' : '') + fmtGBP(Math.abs(parseFloat(row.variance)))}
|
||
</td>
|
||
)}
|
||
<td style={tdS}><Btn small variant="ghost" onClick={() => loadDetail(row.id)}>View</Btn></td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</Card>
|
||
)}
|
||
{total > limit && (
|
||
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1rem', justifyContent: 'center' }}>
|
||
<Btn onClick={() => setOffset(Math.max(0, offset - limit))} disabled={offset === 0} small variant="secondary">Prev</Btn>
|
||
<span style={{ alignSelf: 'center', fontSize: '0.875rem', color: 'var(--text-mid)' }}>{offset + 1}–{Math.min(offset + limit, total)} of {total}</span>
|
||
<Btn onClick={() => setOffset(offset + limit)} disabled={offset + limit >= total} small variant="secondary">Next</Btn>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export function FloatManagement() {
|
||
const tabs: Array<{ path: string; label: string; type: CountType }> = [
|
||
{ path: 'petty-cash', label: 'Petty Cash', type: 'petty_cash' },
|
||
{ path: 'change-tin', label: 'Change Tin', type: 'change_tin' },
|
||
{ path: 'safe-cash', label: 'Safe Cash', type: 'safe_cash' },
|
||
]
|
||
|
||
return (
|
||
<div style={{ padding: '1.5rem' }}>
|
||
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1.5rem', borderBottom: '2px solid var(--card-border)', paddingBottom: '0' }}>
|
||
{tabs.map(t => (
|
||
<NavLink key={t.path} to={t.path}
|
||
style={({ isActive }) => ({
|
||
padding: '0.5rem 1rem', textDecoration: 'none', fontSize: '0.875rem', fontWeight: 600,
|
||
color: isActive ? 'var(--gold)' : 'var(--text-mid)',
|
||
borderBottom: isActive ? '2px solid var(--gold)' : '2px solid transparent',
|
||
marginBottom: '-2px',
|
||
})}>
|
||
{t.label}
|
||
</NavLink>
|
||
))}
|
||
</div>
|
||
|
||
<Routes>
|
||
<Route index element={<Navigate to="petty-cash" replace />} />
|
||
{tabs.map(t => (
|
||
<Route key={t.path} path={t.path} element={<FloatCountForm type={t.type} />} />
|
||
))}
|
||
{tabs.map(t => (
|
||
<Route key={t.path + '/history'} path={`${t.path}/history`} element={<FloatHistory type={t.type} />} />
|
||
))}
|
||
</Routes>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const inpSt: React.CSSProperties = {
|
||
border: '1px solid var(--card-border)', borderRadius: '4px',
|
||
padding: '0.3rem 0.5rem', fontSize: '0.875rem', width: '100%',
|
||
}
|
||
const thS: React.CSSProperties = { padding: '0.6rem 0.75rem', fontWeight: 600, color: 'var(--text-mid)', fontSize: '0.8rem', textAlign: 'right' }
|
||
const tdS: React.CSSProperties = { padding: '0.6rem 0.75rem' }
|