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
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue