cashup/frontend/src/pages/FloatManagement.tsx
jtricerolph 577e98e6b1 Fix TypeScript compile errors blocking Docker build
- 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>
2026-07-01 20:11:01 +00:00

321 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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' }