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 attachment_type TEXT NOT NULL DEFAULT 'other';
|
||||||
ALTER TABLE cash_count_attachments ADD COLUMN IF NOT EXISTS label TEXT;
|
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 (
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
key TEXT PRIMARY KEY,
|
key TEXT PRIMARY KEY,
|
||||||
|
|
@ -138,6 +139,7 @@ export async function initDb() {
|
||||||
INSERT INTO settings (key, value) VALUES
|
INSERT INTO settings (key, value) VALUES
|
||||||
('default_report_days', '7'),
|
('default_report_days', '7'),
|
||||||
('petty_cash_float', '200.00'),
|
('petty_cash_float', '200.00'),
|
||||||
|
('till_float_target', '0.00'),
|
||||||
('sales_breakdown_columns', '[]'),
|
('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}')
|
('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
|
ON CONFLICT (key) DO NOTHING
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ export async function cashupRoutes(app) {
|
||||||
|
|
||||||
// POST /api/cashup/save
|
// POST /api/cashup/save
|
||||||
app.post('/api/cashup/save', async (req, reply) => {
|
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 (!session_date) return reply.status(400).send({ error: 'session_date required' })
|
||||||
if (!['draft', 'final'].includes(status)) return reply.status(400).send({ error: 'invalid status' })
|
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, totalFloat, totalCash, notes || null,
|
||||||
status === 'final' ? new Date() : null,
|
status === 'final' ? new Date() : null,
|
||||||
status === 'final' ? req.user.email : null,
|
status === 'final' ? req.user.email : null,
|
||||||
new Date(), cashUpId,
|
new Date(), JSON.stringify(checked_transactions), cashUpId,
|
||||||
]
|
]
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`UPDATE cash_ups SET status=$1, total_float_counted=$2, total_cash_counted=$3, notes=$4,
|
`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
|
updateData
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
const ins = await pool.query(
|
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)
|
`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) RETURNING id`,
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING id`,
|
||||||
[
|
[
|
||||||
session_date, req.user.email, status, totalFloat, totalCash, notes || null,
|
session_date, req.user.email, status, totalFloat, totalCash, notes || null,
|
||||||
status === 'final' ? new Date() : null,
|
status === 'final' ? new Date() : null,
|
||||||
status === 'final' ? req.user.email : null,
|
status === 'final' ? req.user.email : null,
|
||||||
|
JSON.stringify(checked_transactions),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
cashUpId = ins.rows[0].id
|
cashUpId = ins.rows[0].id
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { requireAuth, hasCap } from '../auth.js'
|
||||||
import { testConnection, fetchGlAccountsGrouped } from '../lib/newbook.js'
|
import { testConnection, fetchGlAccountsGrouped } from '../lib/newbook.js'
|
||||||
|
|
||||||
const ALL_KEYS = [
|
const ALL_KEYS = [
|
||||||
'default_report_days', 'petty_cash_float',
|
'default_report_days', 'petty_cash_float', 'till_float_target',
|
||||||
'sales_breakdown_columns', 'change_tin_breakdown',
|
'sales_breakdown_columns', 'change_tin_breakdown',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ export function DailyCashUp({ user }: Props) {
|
||||||
const [tillPayments, setTillPayments] = useState<TillPayment[]>([])
|
const [tillPayments, setTillPayments] = useState<TillPayment[]>([])
|
||||||
const [transactionBreakdown, setTransactionBreakdown] = useState<TransactionBreakdown | null>(null)
|
const [transactionBreakdown, setTransactionBreakdown] = useState<TransactionBreakdown | null>(null)
|
||||||
const [checkedItems, setCheckedItems] = useState<Set<string>>(new Set())
|
const [checkedItems, setCheckedItems] = useState<Set<string>>(new Set())
|
||||||
|
const [tillFloatTarget, setTillFloatTarget] = useState(0)
|
||||||
const [fetching, setFetching] = useState(false)
|
const [fetching, setFetching] = useState(false)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null)
|
const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null)
|
||||||
|
|
@ -67,6 +68,7 @@ export function DailyCashUp({ user }: Props) {
|
||||||
setCashUp(data.cash_up)
|
setCashUp(data.cash_up)
|
||||||
setNotes(data.cash_up.notes || '')
|
setNotes(data.cash_up.notes || '')
|
||||||
setAttachments(data.attachments || [])
|
setAttachments(data.attachments || [])
|
||||||
|
setCheckedItems(new Set(data.cash_up.checked_transactions || []))
|
||||||
|
|
||||||
const rebuild = (ct: 'takings' | 'float') =>
|
const rebuild = (ct: 'takings' | 'float') =>
|
||||||
GBP_DENOMINATIONS.map(d => {
|
GBP_DENOMINATIONS.map(d => {
|
||||||
|
|
@ -96,6 +98,13 @@ export function DailyCashUp({ user }: Props) {
|
||||||
setPageState(data.cash_up.status === 'final' ? 'locked' : 'editing')
|
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
|
// Auto-check on mount and whenever date changes; auto-fetch Newbook in parallel
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPageState('checking')
|
setPageState('checking')
|
||||||
|
|
@ -164,7 +173,6 @@ export function DailyCashUp({ user }: Props) {
|
||||||
setNewbookTotals(data.totals)
|
setNewbookTotals(data.totals)
|
||||||
setTillPayments(data.till_payments || [])
|
setTillPayments(data.till_payments || [])
|
||||||
setTransactionBreakdown(data.transaction_breakdown || null)
|
setTransactionBreakdown(data.transaction_breakdown || null)
|
||||||
setCheckedItems(new Set())
|
|
||||||
flash(`Fetched ${data.count} payment(s) from Newbook.`)
|
flash(`Fetched ${data.count} payment(s) from Newbook.`)
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
flash(e instanceof Error ? e.message : 'Failed to fetch Newbook data.', false)
|
flash(e instanceof Error ? e.message : 'Failed to fetch Newbook data.', false)
|
||||||
|
|
@ -181,6 +189,7 @@ export function DailyCashUp({ user }: Props) {
|
||||||
session_date: date,
|
session_date: date,
|
||||||
status,
|
status,
|
||||||
notes,
|
notes,
|
||||||
|
checked_transactions: [...checkedItems],
|
||||||
denominations: allDenoms.map(d => ({
|
denominations: allDenoms.map(d => ({
|
||||||
count_type: d.count_type,
|
count_type: d.count_type,
|
||||||
type: d.denomination_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 totalPdq = machines.reduce((s, m) => s + m.total_amount, 0)
|
||||||
|
const floatCounted = denomTotal(float)
|
||||||
|
const floatVariance = tillFloatTarget > 0 ? floatCounted - tillFloatTarget : null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '1.5rem', maxWidth: '900px' }}>
|
<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>
|
<h2 style={{ fontSize: '1rem', fontWeight: 700 }}>Float Count</h2>
|
||||||
<span style={{ fontSize: '0.9rem', color: 'var(--text-mid)' }}>
|
<span style={{ fontSize: '0.9rem', color: 'var(--text-mid)' }}>
|
||||||
{fmtGBP(denomTotal(float))} {showFloat ? '▲' : '▼'}
|
{fmtGBP(floatCounted)} {showFloat ? '▲' : '▼'}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</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 && (
|
{showFloat && (
|
||||||
<div style={{ marginTop: '1rem' }}>
|
<div style={{ marginTop: '1rem' }}>
|
||||||
<DenomGrid denoms={float} onChange={(i, f, v) => updateDenom(float, setFloat, i, f, v)} disabled={isFinal} tabBase={24} />
|
<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({
|
function PhotoUploader({
|
||||||
cashUpId, attachmentType, label, attachments, onAdded, onRemoved, disabled,
|
cashUpId, attachmentType, label, attachments, onAdded, onRemoved, disabled,
|
||||||
}: {
|
}: {
|
||||||
|
|
@ -561,7 +606,8 @@ function PhotoUploader({
|
||||||
setUploading(true)
|
setUploading(true)
|
||||||
for (const file of Array.from(files)) {
|
for (const file of Array.from(files)) {
|
||||||
try {
|
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)
|
onAdded(a as Attachment)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Upload failed', 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></>}
|
{uploading ? <Loader size={18} style={{ animation: 'spin 1s linear infinite' }} /> : <><Camera size={18} /><span>Add photo</span></>}
|
||||||
</button>
|
</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)} />
|
onChange={e => e.target.files?.length && handleFiles(e.target.files)} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { can, type User } from '../types'
|
||||||
interface SettingsData {
|
interface SettingsData {
|
||||||
default_report_days: string
|
default_report_days: string
|
||||||
petty_cash_float: string
|
petty_cash_float: string
|
||||||
|
till_float_target: string
|
||||||
sales_breakdown_columns: string
|
sales_breakdown_columns: string
|
||||||
change_tin_breakdown: string
|
change_tin_breakdown: string
|
||||||
}
|
}
|
||||||
|
|
@ -131,6 +132,8 @@ export function SettingsPage({ user }: { user: User }) {
|
||||||
<Card style={{ marginBottom: '1rem' }}>
|
<Card style={{ marginBottom: '1rem' }}>
|
||||||
<h2 style={{ fontSize: '1rem', fontWeight: 700, marginBottom: '1rem' }}>Float Settings</h2>
|
<h2 style={{ fontSize: '1rem', fontWeight: 700, marginBottom: '1rem' }}>Float Settings</h2>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}>
|
<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'}
|
<FieldRow label="Petty Cash Float (£)" value={settings.petty_cash_float ?? '200'}
|
||||||
type="number" onChange={v => set('petty_cash_float', v)} />
|
type="number" onChange={v => set('petty_cash_float', v)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ export interface CashUp {
|
||||||
notes: string | null
|
notes: string | null
|
||||||
submitted_at: string | null
|
submitted_at: string | null
|
||||||
submitted_by: string | null
|
submitted_by: string | null
|
||||||
|
checked_transactions: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Denomination {
|
export interface Denomination {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue