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:
parent
4754231f6f
commit
fb66dcd6bf
6 changed files with 63 additions and 10 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ export interface CashUp {
|
|||
notes: string | null
|
||||
submitted_at: string | null
|
||||
submitted_by: string | null
|
||||
checked_transactions: string[]
|
||||
}
|
||||
|
||||
export interface Denomination {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue