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

@ -126,6 +126,7 @@ export async function initDb() {
);
ALTER TABLE cash_count_attachments ADD COLUMN IF NOT EXISTS attachment_type TEXT NOT NULL DEFAULT 'other';
ALTER TABLE cash_count_attachments ADD COLUMN IF NOT EXISTS label TEXT;
ALTER TABLE cash_ups ADD COLUMN IF NOT EXISTS checked_transactions JSONB NOT NULL DEFAULT '[]'::jsonb;
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
@ -138,6 +139,7 @@ export async function initDb() {
INSERT INTO settings (key, value) VALUES
('default_report_days', '7'),
('petty_cash_float', '200.00'),
('till_float_target', '0.00'),
('sales_breakdown_columns', '[]'),
('change_tin_breakdown', '{"50.00":0,"20.00":0,"10.00":0,"5.00":0,"2.00":20,"1.00":20,"0.50":10,"0.20":10,"0.10":5,"0.05":5}')
ON CONFLICT (key) DO NOTHING

View file

@ -33,7 +33,7 @@ export async function cashupRoutes(app) {
// POST /api/cashup/save
app.post('/api/cashup/save', async (req, reply) => {
const { session_date, status, notes, denominations = [], card_machines = [] } = req.body
const { session_date, status, notes, denominations = [], card_machines = [], checked_transactions = [] } = req.body
if (!session_date) return reply.status(400).send({ error: 'session_date required' })
if (!['draft', 'final'].includes(status)) return reply.status(400).send({ error: 'invalid status' })
@ -63,21 +63,22 @@ export async function cashupRoutes(app) {
status, totalFloat, totalCash, notes || null,
status === 'final' ? new Date() : null,
status === 'final' ? req.user.email : null,
new Date(), cashUpId,
new Date(), JSON.stringify(checked_transactions), cashUpId,
]
await pool.query(
`UPDATE cash_ups SET status=$1, total_float_counted=$2, total_cash_counted=$3, notes=$4,
submitted_at=$5, submitted_by=$6, updated_at=$7 WHERE id=$8`,
submitted_at=$5, submitted_by=$6, updated_at=$7, checked_transactions=$8 WHERE id=$9`,
updateData
)
} else {
const ins = await pool.query(
`INSERT INTO cash_ups (session_date, created_by, status, total_float_counted, total_cash_counted, notes, submitted_at, submitted_by)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING id`,
`INSERT INTO cash_ups (session_date, created_by, status, total_float_counted, total_cash_counted, notes, submitted_at, submitted_by, checked_transactions)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING id`,
[
session_date, req.user.email, status, totalFloat, totalCash, notes || null,
status === 'final' ? new Date() : null,
status === 'final' ? req.user.email : null,
JSON.stringify(checked_transactions),
]
)
cashUpId = ins.rows[0].id

View file

@ -3,7 +3,7 @@ import { requireAuth, hasCap } from '../auth.js'
import { testConnection, fetchGlAccountsGrouped } from '../lib/newbook.js'
const ALL_KEYS = [
'default_report_days', 'petty_cash_float',
'default_report_days', 'petty_cash_float', 'till_float_target',
'sales_breakdown_columns', 'change_tin_breakdown',
]

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>
)

View file

@ -6,6 +6,7 @@ import { can, type User } from '../types'
interface SettingsData {
default_report_days: string
petty_cash_float: string
till_float_target: string
sales_breakdown_columns: string
change_tin_breakdown: string
}
@ -131,6 +132,8 @@ export function SettingsPage({ user }: { user: User }) {
<Card style={{ marginBottom: '1rem' }}>
<h2 style={{ fontSize: '1rem', fontWeight: 700, marginBottom: '1rem' }}>Float Settings</h2>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}>
<FieldRow label="Till Float Target (£)" value={settings.till_float_target ?? '0'}
type="number" onChange={v => set('till_float_target', v)} />
<FieldRow label="Petty Cash Float (£)" value={settings.petty_cash_float ?? '200'}
type="number" onChange={v => set('petty_cash_float', v)} />
</div>

View file

@ -24,6 +24,7 @@ export interface CashUp {
notes: string | null
submitted_at: string | null
submitted_by: string | null
checked_transactions: string[]
}
export interface Denomination {