cashup/frontend/src/pages/History.tsx
jtricerolph 2e9b4a2ca3 Let admins unfinalise a cash up from history
Adds POST /api/cashup/:id/unfinalise (admin-only) to revert a
finalised cash up back to draft so it can be corrected, plus an
Unfinalise button in the history table for admin users.
2026-08-03 08:53:00 +00:00

193 lines
8.6 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, useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import { api } from '../api'
import { PageHeader, Card, Btn, StatusBadge } from '../components/Layout'
import { fmtGBP, today, can } from '../types'
import type { CashUp, User } from '../types'
export function History({ user }: { user: User }) {
const navigate = useNavigate()
const canFinalise = can(user, 'finalise')
const [rows, setRows] = useState<CashUp[]>([])
const [total, setTotal] = useState(0)
const [offset, setOffset] = useState(0)
const [status, setStatus] = useState('all')
const [from, setFrom] = useState('')
const [to, setTo] = useState(today())
const [loading, setLoading] = useState(false)
const [selected, setSelected] = useState<Set<number>>(new Set())
const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null)
const limit = 20
const flash = (text: string, ok = true) => {
setMsg({ text, ok })
setTimeout(() => setMsg(null), 4000)
}
const load = useCallback(async () => {
setLoading(true)
try {
const params = new URLSearchParams({ offset: String(offset), limit: String(limit) })
if (status !== 'all') params.set('status', status)
if (from) params.set('from', from)
if (to) params.set('to', to)
const data = await api.get<{ rows: CashUp[]; total: number }>(`/cashup/history?${params}`)
setRows(data.rows)
setTotal(data.total)
} finally {
setLoading(false)
}
}, [offset, status, from, to])
useEffect(() => { load() }, [load])
function toggleSelect(id: number) {
setSelected(prev => {
const next = new Set(prev)
next.has(id) ? next.delete(id) : next.add(id)
return next
})
}
async function deleteDraft(id: number) {
if (!confirm('Delete this draft cash up?')) return
await api.delete(`/cashup/${id}`)
flash('Deleted.')
load()
}
async function unfinalise(id: number) {
if (!confirm('Revert this cash up to draft so it can be edited?')) return
await api.post(`/cashup/${id}/unfinalise`, {})
flash('Reverted to draft.')
load()
}
async function bulkFinalize() {
if (!selected.size) return
if (!confirm(`Finalise ${selected.size} draft(s)?`)) return
const { success, failed_count } = await api.post<{ success: number; failed_count: number }>(
'/cashup/bulk-finalize', { ids: Array.from(selected) }
)
flash(`${success} finalised${failed_count ? `, ${failed_count} failed` : ''}.`, !failed_count)
setSelected(new Set())
load()
}
return (
<div style={{ padding: '1.5rem', maxWidth: '960px' }}>
<PageHeader title="Cash Up History" />
{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>
)}
{/* Filters */}
<Card style={{ marginBottom: '1rem', display: 'flex', gap: '1rem', alignItems: 'flex-end', flexWrap: 'wrap' }}>
<div>
<label style={{ fontSize: '0.75rem', color: 'var(--text-mid)', display: 'block', marginBottom: '0.25rem' }}>Status</label>
<select value={status} onChange={e => { setStatus(e.target.value); setOffset(0) }} style={selSt}>
<option value="all">All</option>
<option value="draft">Draft</option>
<option value="final">Final</option>
</select>
</div>
<div>
<label style={{ fontSize: '0.75rem', color: 'var(--text-mid)', display: 'block', marginBottom: '0.25rem' }}>From</label>
<input type="date" value={from} onChange={e => { setFrom(e.target.value); setOffset(0) }} style={inpSt} />
</div>
<div>
<label style={{ fontSize: '0.75rem', color: 'var(--text-mid)', display: 'block', marginBottom: '0.25rem' }}>To</label>
<input type="date" value={to} onChange={e => { setTo(e.target.value); setOffset(0) }} style={inpSt} />
</div>
<Btn onClick={() => { setOffset(0); load() }} small>Filter</Btn>
{canFinalise && selected.size > 0 && (
<Btn onClick={bulkFinalize} small>Finalise {selected.size} selected</Btn>
)}
</Card>
{loading ? (
<div style={{ color: 'var(--text-mid)', padding: '2rem', textAlign: 'center' }}>Loading</div>
) : rows.length === 0 ? (
<Card>
<p style={{ color: 'var(--text-mid)', textAlign: 'center', padding: '1rem' }}>No cash ups 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={thSt}></th>
<th style={{ ...thSt, textAlign: 'left' }}>Date</th>
<th style={{ ...thSt, textAlign: 'left' }}>Status</th>
<th style={{ ...thSt, textAlign: 'right' }}>Cash</th>
<th style={{ ...thSt, textAlign: 'right' }}>Float</th>
<th style={{ ...thSt, textAlign: 'left' }}>Created By</th>
<th style={{ ...thSt, textAlign: 'left' }}>Submitted</th>
<th style={thSt}></th>
</tr>
</thead>
<tbody>
{rows.map(row => (
<tr key={row.id} style={{ borderBottom: '1px solid var(--card-border)' }}>
<td style={tdSt}>
{canFinalise && row.status === 'draft' && (
<input type="checkbox" checked={selected.has(row.id)}
onChange={() => toggleSelect(row.id)} />
)}
</td>
<td style={tdSt}>
<span style={{ fontWeight: 600 }}>
{new Date(row.session_date.slice(0, 10) + 'T12:00:00').toLocaleDateString('en-GB', { weekday: 'short', day: '2-digit', month: 'short', year: 'numeric' })}
</span>
</td>
<td style={tdSt}><StatusBadge status={row.status} /></td>
<td style={{ ...tdSt, textAlign: 'right' }}>{fmtGBP(row.total_cash_counted)}</td>
<td style={{ ...tdSt, textAlign: 'right' }}>{fmtGBP(row.total_float_counted)}</td>
<td style={{ ...tdSt, color: 'var(--text-mid)' }}>{row.created_by}</td>
<td style={{ ...tdSt, color: 'var(--text-mid)', fontSize: '0.8rem' }}>
{row.submitted_at ? new Date(row.submitted_at).toLocaleDateString('en-GB') : '—'}
</td>
<td style={{ ...tdSt, display: 'flex', gap: '0.4rem', justifyContent: 'flex-end' }}>
<Btn small variant="ghost"
onClick={() => navigate(`/daily?date=${row.session_date.slice(0, 10)}`)}>
{row.status === 'draft' ? 'Edit' : 'View'}
</Btn>
{canFinalise && row.status === 'draft' && (
<Btn small variant="danger" onClick={() => deleteDraft(row.id)}>Delete</Btn>
)}
{user.is_admin && row.status === 'final' && (
<Btn small variant="ghost" onClick={() => unfinalise(row.id)}>Unfinalise</Btn>
)}
</td>
</tr>
))}
</tbody>
</table>
</Card>
)}
{/* Pagination */}
{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>
)
}
const thSt: React.CSSProperties = { padding: '0.6rem 0.75rem', fontWeight: 600, color: 'var(--text-mid)', fontSize: '0.8rem' }
const tdSt: React.CSSProperties = { padding: '0.6rem 0.75rem' }
const selSt: React.CSSProperties = { border: '1px solid var(--card-border)', borderRadius: '6px', padding: '0.4rem 0.6rem', fontSize: '0.875rem' }
const inpSt: React.CSSProperties = { border: '1px solid var(--card-border)', borderRadius: '6px', padding: '0.4rem 0.6rem', fontSize: '0.875rem' }