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.
193 lines
8.6 KiB
TypeScript
193 lines
8.6 KiB
TypeScript
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' }
|