Save transaction checks, till float variance, fix image upload

- Persist transaction breakdown checked state in cash_ups.checked_transactions
  (JSONB column); saved on each draft/final save and restored on page load.
  Re-fetching Newbook no longer resets the checked items.
- Add till_float_target setting; Float Count card shows target and variance
  so staff can see at a glance whether the till balances.
- Compress images client-side (max 1600px, 85% JPEG) before upload to avoid
  504 gateway timeouts from NPM when uploading large mobile photos.
- File input accepts image/* only (was image/*+PDF which confuses mobile pickers).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-02 14:46:52 +00:00
parent 4754231f6f
commit fb66dcd6bf
6 changed files with 63 additions and 10 deletions

View file

@ -49,6 +49,7 @@ export function DailyCashUp({ user }: Props) {
const [tillPayments, setTillPayments] = useState<TillPayment[]>([])
const [transactionBreakdown, setTransactionBreakdown] = useState<TransactionBreakdown | null>(null)
const [checkedItems, setCheckedItems] = useState<Set<string>>(new Set())
const [tillFloatTarget, setTillFloatTarget] = useState(0)
const [fetching, setFetching] = useState(false)
const [saving, setSaving] = useState(false)
const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null)
@ -67,6 +68,7 @@ export function DailyCashUp({ user }: Props) {
setCashUp(data.cash_up)
setNotes(data.cash_up.notes || '')
setAttachments(data.attachments || [])
setCheckedItems(new Set(data.cash_up.checked_transactions || []))
const rebuild = (ct: 'takings' | 'float') =>
GBP_DENOMINATIONS.map(d => {
@ -96,6 +98,13 @@ export function DailyCashUp({ user }: Props) {
setPageState(data.cash_up.status === 'final' ? 'locked' : 'editing')
}
// Fetch till float target once on mount
useEffect(() => {
api.get<{ till_float_target?: string }>('/settings')
.then(s => setTillFloatTarget(parseFloat(s.till_float_target || '0') || 0))
.catch(() => {})
}, [])
// Auto-check on mount and whenever date changes; auto-fetch Newbook in parallel
useEffect(() => {
setPageState('checking')
@ -164,7 +173,6 @@ export function DailyCashUp({ user }: Props) {
setNewbookTotals(data.totals)
setTillPayments(data.till_payments || [])
setTransactionBreakdown(data.transaction_breakdown || null)
setCheckedItems(new Set())
flash(`Fetched ${data.count} payment(s) from Newbook.`)
} catch (e: unknown) {
flash(e instanceof Error ? e.message : 'Failed to fetch Newbook data.', false)
@ -181,6 +189,7 @@ export function DailyCashUp({ user }: Props) {
session_date: date,
status,
notes,
checked_transactions: [...checkedItems],
denominations: allDenoms.map(d => ({
count_type: d.count_type,
type: d.denomination_type,
@ -219,6 +228,8 @@ export function DailyCashUp({ user }: Props) {
] : []
const totalPdq = machines.reduce((s, m) => s + m.total_amount, 0)
const floatCounted = denomTotal(float)
const floatVariance = tillFloatTarget > 0 ? floatCounted - tillFloatTarget : null
return (
<div style={{ padding: '1.5rem', maxWidth: '900px' }}>
@ -289,9 +300,19 @@ export function DailyCashUp({ user }: Props) {
}}>
<h2 style={{ fontSize: '1rem', fontWeight: 700 }}>Float Count</h2>
<span style={{ fontSize: '0.9rem', color: 'var(--text-mid)' }}>
{fmtGBP(denomTotal(float))} {showFloat ? '▲' : '▼'}
{fmtGBP(floatCounted)} {showFloat ? '▲' : '▼'}
</span>
</button>
{floatVariance !== null && (
<div style={{ display: 'flex', gap: '1.25rem', fontSize: '0.8rem', marginTop: '0.35rem' }}>
<span style={{ color: 'var(--text-mid)' }}>Target: {fmtGBP(tillFloatTarget)}</span>
<span style={{ fontWeight: 600, color: Math.abs(floatVariance) < 0.01 ? 'var(--text-mid)' : floatVariance > 0 ? 'var(--success)' : 'var(--danger)' }}>
{Math.abs(floatVariance) < 0.01
? 'In balance'
: (floatVariance > 0 ? '+' : '') + fmtGBP(Math.abs(floatVariance)) + ' variance'}
</span>
</div>
)}
{showFloat && (
<div style={{ marginTop: '1rem' }}>
<DenomGrid denoms={float} onChange={(i, f, v) => updateDenom(float, setFloat, i, f, v)} disabled={isFinal} tabBase={24} />
@ -538,6 +559,30 @@ function DenomGrid({ denoms, onChange, disabled, tabBase = 0 }: {
)
}
async function compressImage(file: File, maxWidth = 1600, quality = 0.85): Promise<File> {
if (!file.type.startsWith('image/')) return file
return new Promise(resolve => {
const img = new Image()
const url = URL.createObjectURL(file)
img.onload = () => {
URL.revokeObjectURL(url)
const scale = img.width > maxWidth ? maxWidth / img.width : 1
const canvas = document.createElement('canvas')
canvas.width = Math.round(img.width * scale)
canvas.height = Math.round(img.height * scale)
canvas.getContext('2d')!.drawImage(img, 0, 0, canvas.width, canvas.height)
canvas.toBlob(blob => {
if (!blob) { resolve(file); return }
const name = file.name.replace(/\.[^.]+$/, '.jpg')
const compressed = new File([blob], name, { type: 'image/jpeg' })
resolve(compressed.size < file.size ? compressed : file)
}, 'image/jpeg', quality)
}
img.onerror = () => { URL.revokeObjectURL(url); resolve(file) }
img.src = url
})
}
function PhotoUploader({
cashUpId, attachmentType, label, attachments, onAdded, onRemoved, disabled,
}: {
@ -561,7 +606,8 @@ function PhotoUploader({
setUploading(true)
for (const file of Array.from(files)) {
try {
const a = await uploadAttachment(cashUpId, file, attachmentType, label ?? undefined)
const toUpload = await compressImage(file)
const a = await uploadAttachment(cashUpId, toUpload, attachmentType, label ?? undefined)
onAdded(a as Attachment)
} catch (e) {
console.error('Upload failed', e)
@ -608,7 +654,7 @@ function PhotoUploader({
{uploading ? <Loader size={18} style={{ animation: 'spin 1s linear infinite' }} /> : <><Camera size={18} /><span>Add photo</span></>}
</button>
)}
<input ref={inputRef} type="file" accept="image/*,application/pdf" multiple hidden
<input ref={inputRef} type="file" accept="image/*" multiple hidden
onChange={e => e.target.files?.length && handleFiles(e.target.files)} />
</div>
)