Float management: save→view/print, history columns, uploads path fix
- Change tin count: remove bags/loose split — single qty per denomination - After save: show saved record inline with Print button (clean new-window printout with denomination table, receipts, totals/variance, signature lines) - Change tin print: bank exchange order table (surplus=to bank, shortfall=in exchange) - Petty cash history: Cash | Receipts | Total | Variance columns - Fix uploads path: /app/src/../../uploads resolved to /uploads (container root, outside Docker volume). Changed to /app/src/../uploads = /app/uploads so files persist across rebuilds on the named volume. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
6c805ce5a7
commit
8193b05acc
2 changed files with 335 additions and 197 deletions
|
|
@ -1,4 +1,4 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { Routes, Route, NavLink, Navigate, useNavigate } from 'react-router-dom'
|
||||
import { api } from '../api'
|
||||
import { PageHeader, Card, Btn } from '../components/Layout'
|
||||
|
|
@ -13,34 +13,219 @@ const TYPE_LABELS: Record<CountType, string> = {
|
|||
safe_cash: 'Safe Cash',
|
||||
}
|
||||
|
||||
// Denominations relevant for each type (change_tin excludes 1p/2p)
|
||||
// Change tin counts all denoms from 5p upward (notes + coins, no 1p/2p)
|
||||
const CHANGE_TIN_DENOMS = GBP_DENOMINATIONS.filter(d => d.value >= 0.05)
|
||||
|
||||
// UK standard bag values (£ per sealed bag of each denomination)
|
||||
// UK standard sealed bag values per coin denomination
|
||||
const BAG_VALUES: Record<number, number> = {
|
||||
0.05: 5, // 100 × 5p
|
||||
0.10: 5, // 50 × 10p
|
||||
0.20: 10, // 50 × 20p
|
||||
0.50: 10, // 20 × 50p
|
||||
1.00: 20, // 20 × £1
|
||||
2.00: 20, // 10 × £2
|
||||
0.05: 5,
|
||||
0.10: 5,
|
||||
0.20: 10,
|
||||
0.50: 10,
|
||||
1.00: 20,
|
||||
2.00: 20,
|
||||
}
|
||||
|
||||
// ── Shared record detail + print view ─────────────────────────────────────────
|
||||
|
||||
type DetailRecord = FloatCount & { denominations: FloatDenomination[]; receipts: FloatReceipt[] }
|
||||
|
||||
function FloatRecordPrint({ record, changeTinTargets, onClose, closeLabel = 'New Count' }: {
|
||||
record: DetailRecord
|
||||
changeTinTargets: Record<string, number>
|
||||
onClose: () => void
|
||||
closeLabel?: string
|
||||
}) {
|
||||
const printRef = useRef<HTMLDivElement>(null)
|
||||
const isPetty = record.count_type === 'petty_cash'
|
||||
const isTin = record.count_type === 'change_tin'
|
||||
const cash = parseFloat(record.total_counted)
|
||||
const receipts = parseFloat(record.total_receipts)
|
||||
const combined = cash + receipts
|
||||
const variance = parseFloat(record.variance)
|
||||
const target = parseFloat(record.target_amount)
|
||||
|
||||
function doPrint() {
|
||||
const content = printRef.current?.innerHTML ?? ''
|
||||
const win = window.open('', '_blank', 'width=700,height=900')
|
||||
if (!win) return
|
||||
win.document.write(`<!DOCTYPE html><html><head><title>${TYPE_LABELS[record.count_type as CountType]} Count</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; font-size: 13px; margin: 24px; color: #000; }
|
||||
h1 { font-size: 18px; margin: 0 0 4px; }
|
||||
h2 { font-size: 13px; font-weight: 700; margin: 16px 0 6px; border-bottom: 1px solid #000; padding-bottom: 2px; }
|
||||
table { width: 100%; border-collapse: collapse; margin-bottom: 12px; }
|
||||
th, td { padding: 4px 8px; text-align: left; border-bottom: 1px solid #ddd; }
|
||||
th { font-weight: 700; background: #f5f5f5; }
|
||||
.right { text-align: right; }
|
||||
.total-row { font-weight: 700; border-top: 2px solid #000; }
|
||||
.variance { font-weight: 700; font-size: 14px; }
|
||||
.variance.ok { color: #16a34a; }
|
||||
.variance.over { color: #16a34a; }
|
||||
.variance.short { color: #dc2626; }
|
||||
.sig { margin-top: 32px; display: flex; gap: 48px; }
|
||||
.sig-line { flex: 1; border-top: 1px solid #000; padding-top: 4px; font-size: 11px; color: #666; }
|
||||
.subtitle { color: #666; font-size: 12px; margin: 0 0 12px; }
|
||||
.exchange-note { font-size: 11px; color: #666; margin: 6px 0; }
|
||||
</style>
|
||||
</head><body>${content}</body></html>`)
|
||||
win.document.close()
|
||||
win.focus()
|
||||
win.print()
|
||||
}
|
||||
|
||||
// Build exchange table for change tin: surplus → "to bank", shortfall → "in exchange"
|
||||
const exchangeRows = isTin ? CHANGE_TIN_DENOMS.map(d => {
|
||||
const tgt = parseFloat(String(changeTinTargets[d.value.toFixed(2)] ?? 0))
|
||||
if (tgt <= 0) return null
|
||||
const denom = record.denominations.find(x => Math.abs(parseFloat(x.denomination_value) - d.value) < 0.001)
|
||||
const counted = parseFloat(denom?.total_amount ?? '0')
|
||||
const diff = counted - tgt
|
||||
if (Math.abs(diff) < 0.005) return null
|
||||
return {
|
||||
label: d.label,
|
||||
toBank: diff > 0 ? diff : 0,
|
||||
required: diff < 0 ? Math.abs(diff) : 0,
|
||||
}
|
||||
}).filter(Boolean) : []
|
||||
|
||||
const totalToBank = exchangeRows.reduce((s, r) => s + (r?.toBank ?? 0), 0)
|
||||
const totalRequired = exchangeRows.reduce((s, r) => s + (r?.required ?? 0), 0)
|
||||
const varClass = Math.abs(variance) < 0.01 ? 'ok' : variance > 0 ? 'over' : 'short'
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', gap: '0.75rem', marginBottom: '1rem' }}>
|
||||
<Btn onClick={doPrint}>Print</Btn>
|
||||
<Btn variant="ghost" onClick={onClose}>{closeLabel}</Btn>
|
||||
</div>
|
||||
|
||||
<div ref={printRef}>
|
||||
<h1>{TYPE_LABELS[record.count_type as CountType]} Count</h1>
|
||||
<p className="subtitle">
|
||||
{new Date(record.count_date).toLocaleString('en-GB', { dateStyle: 'full', timeStyle: 'short' })}
|
||||
{record.created_by && ` · ${record.created_by}`}
|
||||
</p>
|
||||
|
||||
<h2>DENOMINATION COUNT</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Denomination</th>
|
||||
<th className="right">Qty</th>
|
||||
<th className="right">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{record.denominations.map(d => (
|
||||
<tr key={String(d.denomination_value)}>
|
||||
<td>{fmtGBP(d.denomination_value)}</td>
|
||||
<td className="right">×{d.quantity}</td>
|
||||
<td className="right">{fmtGBP(d.total_amount)}</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr className="total-row">
|
||||
<td colSpan={2}>Total Cash</td>
|
||||
<td className="right">{fmtGBP(cash)}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{isPetty && record.receipts.length > 0 && (
|
||||
<>
|
||||
<h2>RECEIPTS</h2>
|
||||
<table>
|
||||
<thead><tr><th>Description</th><th className="right">Amount</th></tr></thead>
|
||||
<tbody>
|
||||
{record.receipts.map(r => (
|
||||
<tr key={r.id}>
|
||||
<td>{r.receipt_description || '—'}</td>
|
||||
<td className="right">{fmtGBP(r.receipt_value)}</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr className="total-row">
|
||||
<td>Total Receipts</td>
|
||||
<td className="right">{fmtGBP(receipts)}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(isPetty || isTin) && (
|
||||
<table>
|
||||
<tbody>
|
||||
{isPetty && (
|
||||
<tr><td>Cash + Receipts</td><td className="right">{fmtGBP(combined)}</td></tr>
|
||||
)}
|
||||
<tr><td>Target</td><td className="right">{fmtGBP(target)}</td></tr>
|
||||
<tr className="total-row">
|
||||
<td>Variance</td>
|
||||
<td className={`right variance ${varClass}`}>
|
||||
{Math.abs(variance) < 0.01 ? '£0.00' : (variance > 0 ? '+' : '') + fmtGBP(Math.abs(variance))}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{isTin && exchangeRows.length > 0 && (
|
||||
<>
|
||||
<h2>BANK EXCHANGE ORDER</h2>
|
||||
<p className="exchange-note">Present this slip to the bank. Total taken to bank must equal total required in exchange.</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Denomination</th>
|
||||
<th className="right">Value Taken to Bank</th>
|
||||
<th className="right">Required in Exchange</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{exchangeRows.map(r => r && (
|
||||
<tr key={r.label}>
|
||||
<td>{r.label}</td>
|
||||
<td className="right">{r.toBank > 0 ? fmtGBP(r.toBank) : '—'}</td>
|
||||
<td className="right">{r.required > 0 ? fmtGBP(r.required) : '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr className="total-row">
|
||||
<td>Total</td>
|
||||
<td className="right">{totalToBank > 0 ? fmtGBP(totalToBank) : '—'}</td>
|
||||
<td className="right">{totalRequired > 0 ? fmtGBP(totalRequired) : '—'}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
)}
|
||||
|
||||
{record.notes && <p style={{ marginTop: '12px', fontStyle: 'italic' }}>Notes: {record.notes}</p>}
|
||||
|
||||
<div className="sig">
|
||||
<div className="sig-line">Counted by</div>
|
||||
<div className="sig-line">Checked by</div>
|
||||
<div className="sig-line">Date</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Count form ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function FloatCountForm({ type }: { type: CountType }) {
|
||||
const navigate = useNavigate()
|
||||
const [denomQtys, setDenomQtys] = useState<Record<number, number>>({})
|
||||
const [bagQtys, setBagQtys] = 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 [error, setError] = useState<string | null>(null)
|
||||
const [savedRecord, setSavedRecord] = useState<DetailRecord | null>(null)
|
||||
const [changeTinTargets, setChangeTinTargets] = useState<Record<string, number>>({})
|
||||
const [pettyTarget, setPettyTarget] = useState(200)
|
||||
|
||||
const denoms = type === 'change_tin' ? CHANGE_TIN_DENOMS : GBP_DENOMINATIONS
|
||||
|
||||
useEffect(() => {
|
||||
api.get<Record<string, string>>('/settings').then(s => {
|
||||
if (type === 'change_tin') {
|
||||
|
|
@ -52,12 +237,7 @@ export function FloatCountForm({ type }: { type: CountType }) {
|
|||
}).catch(() => {})
|
||||
}, [type])
|
||||
|
||||
const totalCounted = type === 'change_tin'
|
||||
? denoms.reduce((s, d) => {
|
||||
const bagVal = BAG_VALUES[d.value] ?? 0
|
||||
return s + d.value * (denomQtys[d.value] ?? 0) + bagVal * (bagQtys[d.value] ?? 0)
|
||||
}, 0)
|
||||
: denoms.reduce((s, d) => s + d.value * (denomQtys[d.value] ?? 0), 0)
|
||||
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)
|
||||
|
|
@ -68,19 +248,13 @@ export function FloatCountForm({ type }: { type: CountType }) {
|
|||
|
||||
async function save() {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const denominations = type === 'change_tin'
|
||||
? denoms
|
||||
.filter(d => (denomQtys[d.value] ?? 0) > 0 || (bagQtys[d.value] ?? 0) > 0)
|
||||
.map(d => {
|
||||
const loose = denomQtys[d.value] ?? 0
|
||||
const bags = bagQtys[d.value] ?? 0
|
||||
return { denomination: d.value, quantity: loose, bag_quantity: bags, total: d.value * loose + (BAG_VALUES[d.value] ?? 0) * bags }
|
||||
})
|
||||
: denoms.filter(d => (denomQtys[d.value] ?? 0) > 0).map(d => ({
|
||||
denomination: d.value, quantity: denomQtys[d.value] ?? 0, bag_quantity: 0, total: d.value * (denomQtys[d.value] ?? 0),
|
||||
}))
|
||||
await api.post('/floats/save', {
|
||||
const denominations = denoms.filter(d => (denomQtys[d.value] ?? 0) > 0).map(d => ({
|
||||
denomination: d.value, quantity: denomQtys[d.value] ?? 0, bag_quantity: 0,
|
||||
total: d.value * (denomQtys[d.value] ?? 0),
|
||||
}))
|
||||
const result = await api.post<{ count_id: number }>('/floats/save', {
|
||||
count_type: type,
|
||||
count_date: new Date().toISOString(),
|
||||
denominations,
|
||||
|
|
@ -91,91 +265,60 @@ export function FloatCountForm({ type }: { type: CountType }) {
|
|||
variance,
|
||||
notes,
|
||||
})
|
||||
setMsg({ text: 'Count saved.', ok: true })
|
||||
setDenomQtys({})
|
||||
setBagQtys({})
|
||||
setReceipts([])
|
||||
setNotes('')
|
||||
const detail = await api.get<DetailRecord>(`/floats/${result.count_id}`)
|
||||
setSavedRecord(detail)
|
||||
} catch (e: unknown) {
|
||||
setMsg({ text: e instanceof Error ? e.message : 'Save failed.', ok: false })
|
||||
setError(e instanceof Error ? e.message : 'Save failed.')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
setSavedRecord(null)
|
||||
setDenomQtys({})
|
||||
setReceipts([])
|
||||
setNotes('')
|
||||
}
|
||||
|
||||
if (savedRecord) {
|
||||
return (
|
||||
<div style={{ maxWidth: '680px' }}>
|
||||
<PageHeader title={`${TYPE_LABELS[type]} — Saved`} />
|
||||
<FloatRecordPrint record={savedRecord} changeTinTargets={changeTinTargets} onClose={reset} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: '640px' }}>
|
||||
<div style={{ maxWidth: '600px' }}>
|
||||
<PageHeader title={TYPE_LABELS[type]} subtitle="Enter denomination counts" />
|
||||
|
||||
{msg && (
|
||||
{error && (
|
||||
<div style={{
|
||||
background: msg.ok ? '#dcfce7' : '#fee2e2', color: msg.ok ? '#16a34a' : 'var(--danger)',
|
||||
border: `1px solid ${msg.ok ? '#bbf7d0' : '#fecaca'}`, borderRadius: '6px',
|
||||
background: '#fee2e2', color: 'var(--danger)',
|
||||
border: '1px solid #fecaca', borderRadius: '6px',
|
||||
padding: '0.625rem 1rem', marginBottom: '1rem', fontSize: '0.875rem',
|
||||
}}>
|
||||
{msg.text}
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card style={{ marginBottom: '1rem' }}>
|
||||
<h2 style={{ fontSize: '0.875rem', fontWeight: 700, marginBottom: '0.75rem', color: 'var(--text-mid)' }}>DENOMINATIONS</h2>
|
||||
{type === 'change_tin' ? (
|
||||
<>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '60px 1fr 1fr 90px', gap: '0.4rem 0.5rem', alignItems: 'center', marginBottom: '0.35rem' }}>
|
||||
<span />
|
||||
<span style={{ fontSize: '0.7rem', color: 'var(--text-mid)', textAlign: 'center' }}>Bags / Qty</span>
|
||||
<span style={{ fontSize: '0.7rem', color: 'var(--text-mid)', textAlign: 'center' }}>Loose</span>
|
||||
<span style={{ fontSize: '0.7rem', color: 'var(--text-mid)', textAlign: 'right' }}>Total</span>
|
||||
{denoms.map(d => {
|
||||
const qty = denomQtys[d.value] ?? 0
|
||||
const rowTotal = d.value * qty
|
||||
return (
|
||||
<div key={d.value} style={{ display: 'grid', gridTemplateColumns: '64px 1fr 90px', 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} />
|
||||
<span style={{ textAlign: 'right', fontSize: '0.875rem' }}>{rowTotal > 0 ? fmtGBP(rowTotal) : '—'}</span>
|
||||
</div>
|
||||
{denoms.map(d => {
|
||||
const isNote = !BAG_VALUES[d.value]
|
||||
const qty = denomQtys[d.value] ?? 0
|
||||
const bags = bagQtys[d.value] ?? 0
|
||||
const bagVal = BAG_VALUES[d.value] ?? 0
|
||||
const rowTotal = isNote ? d.value * qty : bagVal * bags + d.value * (denomQtys[d.value] ?? 0)
|
||||
return (
|
||||
<div key={d.value} style={{ display: 'grid', gridTemplateColumns: '60px 1fr 1fr 90px', gap: '0.4rem 0.5rem', alignItems: 'center', marginBottom: '0.35rem' }}>
|
||||
<span style={{ fontWeight: 600, fontSize: '0.875rem' }}>{d.label}</span>
|
||||
{isNote ? (
|
||||
<>
|
||||
<input type="number" min="0" step="1" value={qty || ''}
|
||||
onChange={e => setDenomQtys(prev => ({ ...prev, [d.value]: parseInt(e.target.value) || 0 }))}
|
||||
placeholder="0 notes" style={inpSt} />
|
||||
<span />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<input type="number" min="0" step="1" value={bags || ''}
|
||||
onChange={e => setBagQtys(prev => ({ ...prev, [d.value]: parseInt(e.target.value) || 0 }))}
|
||||
placeholder="0" style={inpSt} />
|
||||
<div style={{ fontSize: '0.65rem', color: 'var(--text-mid)', textAlign: 'center', marginTop: '0.1rem' }}>={fmtGBP(bagVal)} ea</div>
|
||||
</div>
|
||||
<input type="number" min="0" step="1" value={(denomQtys[d.value] ?? 0) || ''}
|
||||
onChange={e => setDenomQtys(prev => ({ ...prev, [d.value]: parseInt(e.target.value) || 0 }))}
|
||||
placeholder="0" style={inpSt} />
|
||||
</>
|
||||
)}
|
||||
<span style={{ textAlign: 'right', fontSize: '0.875rem' }}>{rowTotal > 0 ? fmtGBP(rowTotal) : '—'}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
) : (
|
||||
denoms.map(d => {
|
||||
const qty = denomQtys[d.value] ?? 0
|
||||
const rowTotal = d.value * qty
|
||||
return (
|
||||
<div key={d.value} style={{ display: 'grid', gridTemplateColumns: '60px 1fr 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} />
|
||||
<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>
|
||||
|
|
@ -210,8 +353,14 @@ export function FloatCountForm({ type }: { type: CountType }) {
|
|||
|
||||
{type !== 'safe_cash' && (
|
||||
<Card style={{ marginBottom: '1rem' }}>
|
||||
{type === 'petty_cash' && (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.875rem', marginBottom: '0.4rem' }}>
|
||||
<span style={{ color: 'var(--text-mid)' }}>Cash + Receipts</span>
|
||||
<span>{fmtGBP(totalCounted + totalReceipts)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.875rem', marginBottom: '0.4rem' }}>
|
||||
<span style={{ color: 'var(--text-mid)' }}>Target Amount</span>
|
||||
<span style={{ color: 'var(--text-mid)' }}>Target</span>
|
||||
<span>{fmtGBP(targetAmount)}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.875rem', fontWeight: 700 }}>
|
||||
|
|
@ -223,53 +372,51 @@ export function FloatCountForm({ type }: { type: CountType }) {
|
|||
</Card>
|
||||
)}
|
||||
|
||||
{type === 'change_tin' && Object.values(changeTinTargets).some(v => v > 0) && (
|
||||
<Card style={{ marginBottom: '1rem' }}>
|
||||
<h2 style={{ fontSize: '0.875rem', fontWeight: 700, marginBottom: '0.75rem', color: 'var(--text-mid)' }}>CHANGE ORDER</h2>
|
||||
<table style={{ width: '100%', fontSize: '0.8rem', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '2px solid var(--card-border)' }}>
|
||||
<th style={{ textAlign: 'left', padding: '0.3rem 0.4rem', fontWeight: 600, color: 'var(--text-mid)' }}>Denom</th>
|
||||
<th style={{ textAlign: 'right', padding: '0.3rem 0.4rem', fontWeight: 600, color: 'var(--text-mid)' }}>Target</th>
|
||||
<th style={{ textAlign: 'right', padding: '0.3rem 0.4rem', fontWeight: 600, color: 'var(--text-mid)' }}>Counted</th>
|
||||
<th style={{ textAlign: 'right', padding: '0.3rem 0.4rem', fontWeight: 600, color: 'var(--text-mid)' }}>Order</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{denoms.map(d => {
|
||||
const isNote = !BAG_VALUES[d.value]
|
||||
const bagVal = BAG_VALUES[d.value] ?? 0
|
||||
const unitVal = isNote ? d.value : bagVal
|
||||
const target = parseFloat(String(changeTinTargets[d.value.toFixed(2)] ?? 0))
|
||||
if (target <= 0) return null
|
||||
const targetUnits = unitVal > 0 ? Math.round(target / unitVal) : 0
|
||||
const bags = bagQtys[d.value] ?? 0
|
||||
const looseOrNotes = denomQtys[d.value] ?? 0
|
||||
const countedVal = isNote ? d.value * looseOrNotes : bagVal * bags + d.value * looseOrNotes
|
||||
const needed = target - countedVal
|
||||
const orderUnits = needed > 0.005 ? Math.ceil(needed / unitVal) : 0
|
||||
const unitLabel = isNote ? 'note' : 'bag'
|
||||
return (
|
||||
<tr key={d.value} style={{ borderBottom: '1px solid var(--card-border)' }}>
|
||||
<td style={{ padding: '0.3rem 0.4rem', fontWeight: 600 }}>{d.label}</td>
|
||||
{type === 'change_tin' && Object.values(changeTinTargets).some(v => v > 0) && (() => {
|
||||
const orderRows = CHANGE_TIN_DENOMS.map(d => {
|
||||
const bagVal = BAG_VALUES[d.value] ?? 0
|
||||
const unitVal = bagVal > 0 ? bagVal : d.value
|
||||
const unitLabel = bagVal > 0 ? 'bag' : 'note'
|
||||
const target = parseFloat(String(changeTinTargets[d.value.toFixed(2)] ?? 0))
|
||||
if (target <= 0) return null
|
||||
const targetUnits = unitVal > 0 ? Math.round(target / unitVal) : 0
|
||||
const counted = d.value * (denomQtys[d.value] ?? 0)
|
||||
const needed = target - counted
|
||||
const orderUnits = needed > 0.005 ? Math.ceil(needed / unitVal) : 0
|
||||
return { d, target, targetUnits, counted, orderUnits, unitLabel }
|
||||
}).filter(Boolean)
|
||||
if (!orderRows.length) return null
|
||||
return (
|
||||
<Card style={{ marginBottom: '1rem' }}>
|
||||
<h2 style={{ fontSize: '0.875rem', fontWeight: 700, marginBottom: '0.75rem', color: 'var(--text-mid)' }}>CHANGE ORDER</h2>
|
||||
<table style={{ width: '100%', fontSize: '0.8rem', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '2px solid var(--card-border)' }}>
|
||||
{['Denom', 'Target', 'Counted', 'Order'].map(h => (
|
||||
<th key={h} style={{ padding: '0.3rem 0.4rem', fontWeight: 600, color: 'var(--text-mid)', textAlign: h === 'Denom' ? 'left' : 'right' }}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{orderRows.map(r => r && (
|
||||
<tr key={r.d.value} style={{ borderBottom: '1px solid var(--card-border)' }}>
|
||||
<td style={{ padding: '0.3rem 0.4rem', fontWeight: 600 }}>{r.d.label}</td>
|
||||
<td style={{ padding: '0.3rem 0.4rem', textAlign: 'right', color: 'var(--text-mid)' }}>
|
||||
{targetUnits} {unitLabel}{targetUnits !== 1 ? 's' : ''}
|
||||
{r.targetUnits} {r.unitLabel}{r.targetUnits !== 1 ? 's' : ''}
|
||||
</td>
|
||||
<td style={{ padding: '0.3rem 0.4rem', textAlign: 'right' }}>
|
||||
{isNote
|
||||
? (looseOrNotes > 0 ? `${looseOrNotes}` : '—')
|
||||
: (bags > 0 ? `${bags}bg` : '') + (bags > 0 && looseOrNotes > 0 ? ' + ' : '') + (looseOrNotes > 0 ? `${looseOrNotes}×` : '') + (bags === 0 && looseOrNotes === 0 ? '—' : '')}
|
||||
{r.counted > 0 ? fmtGBP(r.counted) : '—'}
|
||||
</td>
|
||||
<td style={{ padding: '0.3rem 0.4rem', textAlign: 'right', fontWeight: 700, color: orderUnits > 0 ? 'var(--danger)' : '#16a34a' }}>
|
||||
{orderUnits > 0 ? `+${orderUnits} ${unitLabel}${orderUnits !== 1 ? 's' : ''}` : 'OK'}
|
||||
<td style={{ padding: '0.3rem 0.4rem', textAlign: 'right', fontWeight: 700, color: r.orderUnits > 0 ? 'var(--danger)' : '#16a34a' }}>
|
||||
{r.orderUnits > 0 ? `+${r.orderUnits} ${r.unitLabel}${r.orderUnits !== 1 ? 's' : ''}` : 'OK'}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
)}
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
)
|
||||
})()}
|
||||
|
||||
<Card style={{ marginBottom: '1rem' }}>
|
||||
<label style={{ fontSize: '0.875rem', fontWeight: 600, display: 'block', marginBottom: '0.5rem' }}>Notes</label>
|
||||
|
|
@ -285,13 +432,24 @@ export function FloatCountForm({ type }: { type: CountType }) {
|
|||
)
|
||||
}
|
||||
|
||||
// ── History list + detail ──────────────────────────────────────────────────────
|
||||
|
||||
export 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 [detail, setDetail] = useState<DetailRecord | null>(null)
|
||||
const [changeTinTargets, setChangeTinTargets] = useState<Record<string, number>>({})
|
||||
const limit = 10
|
||||
|
||||
useEffect(() => {
|
||||
if (type === 'change_tin') {
|
||||
api.get<Record<string, string>>('/settings').then(s => {
|
||||
try { setChangeTinTargets(JSON.parse(s.change_tin_breakdown || '{}')) } catch {}
|
||||
}).catch(() => {})
|
||||
}
|
||||
}, [type])
|
||||
|
||||
useEffect(() => {
|
||||
api.get<{ rows: FloatCount[]; total: number }>(`/floats?type=${type}&offset=${offset}&limit=${limit}`)
|
||||
.then(d => { setRows(d.rows); setTotal(d.total) })
|
||||
|
|
@ -299,55 +457,23 @@ export function FloatHistory({ type }: { type: CountType }) {
|
|||
}, [type, offset])
|
||||
|
||||
async function loadDetail(id: number) {
|
||||
const d = await api.get<FloatCount & { denominations: FloatDenomination[]; receipts: FloatReceipt[] }>(`/floats/${id}`)
|
||||
const d = await api.get<DetailRecord>(`/floats/${id}`)
|
||||
setDetail(d)
|
||||
}
|
||||
|
||||
const isPetty = type === 'petty_cash'
|
||||
|
||||
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 => {
|
||||
const bags = d.bag_quantity ?? 0
|
||||
return (
|
||||
<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' }}>
|
||||
{bags > 0 ? `${bags} bag${bags !== 1 ? 's' : ''}` : ''}{bags > 0 && d.quantity > 0 ? ' + ' : ''}{d.quantity > 0 ? `×${d.quantity}` : ''}{bags === 0 && d.quantity === 0 ? '—' : ''}
|
||||
</td>
|
||||
<td style={{ padding: '0.3rem 0.5rem', textAlign: 'right' }}>{fmtGBP(d.total_amount)}</td>
|
||||
{d.target !== undefined && d.target > 0 && (
|
||||
<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>}
|
||||
<FloatRecordPrint
|
||||
record={detail}
|
||||
changeTinTargets={changeTinTargets}
|
||||
onClose={() => setDetail(null)}
|
||||
closeLabel="Back to History"
|
||||
/>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
|
|
@ -359,24 +485,34 @@ export function FloatHistory({ type }: { type: CountType }) {
|
|||
<thead>
|
||||
<tr style={{ borderBottom: '2px solid var(--card-border)', background: 'var(--body-bg)' }}>
|
||||
<th style={{ ...thS, textAlign: 'left' }}>Date / Time</th>
|
||||
{isPetty && <th style={thS}>Cash</th>}
|
||||
{isPetty && <th style={thS}>Receipts</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>
|
||||
))}
|
||||
{rows.map(row => {
|
||||
const cash = parseFloat(row.total_counted)
|
||||
const rec = parseFloat(row.total_receipts)
|
||||
const combined = cash + rec
|
||||
const v = parseFloat(row.variance)
|
||||
return (
|
||||
<tr key={row.id} style={{ borderBottom: '1px solid var(--card-border)' }}>
|
||||
<td style={tdS}>{new Date(row.count_date).toLocaleString('en-GB')}</td>
|
||||
{isPetty && <td style={{ ...tdS, textAlign: 'right' }}>{fmtGBP(cash)}</td>}
|
||||
{isPetty && <td style={{ ...tdS, textAlign: 'right' }}>{rec > 0 ? fmtGBP(rec) : '—'}</td>}
|
||||
<td style={{ ...tdS, textAlign: 'right', fontWeight: 600 }}>{fmtGBP(isPetty ? combined : cash)}</td>
|
||||
{type !== 'safe_cash' && (
|
||||
<td style={{ ...tdS, textAlign: 'right', color: Math.abs(v) < 0.01 ? 'var(--text-mid)' : v > 0 ? 'var(--success)' : 'var(--danger)' }}>
|
||||
{Math.abs(v) < 0.01 ? '£0.00' : (v > 0 ? '+' : '') + fmtGBP(Math.abs(v))}
|
||||
</td>
|
||||
)}
|
||||
<td style={tdS}><Btn small variant="ghost" onClick={() => loadDetail(row.id)}>View</Btn></td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
|
|
@ -394,6 +530,8 @@ export function FloatHistory({ type }: { type: CountType }) {
|
|||
)
|
||||
}
|
||||
|
||||
// ── Shell ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function FloatManagement() {
|
||||
const tabs: Array<{ path: string; label: string; type: CountType }> = [
|
||||
{ path: 'petty-cash', label: 'Petty Cash', type: 'petty_cash' },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue