Wire Newbook credentials to settings service
This commit is contained in:
commit
63a5a72fa3
32 changed files with 3386 additions and 0 deletions
182
frontend/src/pages/History.tsx
Normal file
182
frontend/src/pages/History.tsx
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
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 } from '../types'
|
||||
import type { CashUp } from '../types'
|
||||
|
||||
export function History() {
|
||||
const navigate = useNavigate()
|
||||
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 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>
|
||||
{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}>
|
||||
{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 + '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}`)}>
|
||||
{row.status === 'draft' ? 'Edit' : 'View'}
|
||||
</Btn>
|
||||
{row.status === 'draft' && (
|
||||
<Btn small variant="danger" onClick={() => deleteDraft(row.id)}>Delete</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' }
|
||||
Loading…
Add table
Add a link
Reference in a new issue