From 742960e5a14241a05d31f283e98c6587615f8bff Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Mon, 3 Aug 2026 09:08:11 +0000 Subject: [PATCH] Add Excel-style selection/sum to Safe Count's New Total column Extract the click/shift-click cell selection + sum/avg status bar from the weekly report into a shared useExcelSelection hook, and wire it up to the New Total column (and its footer total) on Safe Count. --- frontend/src/hooks/useExcelSelection.ts | 117 ++++++++++++++++++++++++ frontend/src/pages/MultiDayReport.tsx | 116 +---------------------- frontend/src/pages/SafeCount.tsx | 46 ++++++++-- 3 files changed, 158 insertions(+), 121 deletions(-) create mode 100644 frontend/src/hooks/useExcelSelection.ts diff --git a/frontend/src/hooks/useExcelSelection.ts b/frontend/src/hooks/useExcelSelection.ts new file mode 100644 index 0000000..83ede00 --- /dev/null +++ b/frontend/src/hooks/useExcelSelection.ts @@ -0,0 +1,117 @@ +import { useState, useRef, useCallback, useEffect } from 'react' +import type React from 'react' + +export interface SelStats { count: number; numCount: number; sum: number; avg: number } + +// Excel-like click / shift-click cell range selection for plain HTML tables, +// with a live sum/avg readout and Ctrl+C copy. Cells are matched by table id +// plus row/col index, so each table using the hook needs a unique DOM id and +// its cells tagged via cs(tableId, row, col). +export function useExcelSelection() { + type Sel = { tableId: string; r1: number; c1: number; r2: number; c2: number } + const [sel, setSel] = useState(null) + const [stats, setStats] = useState(null) + const selRef = useRef(null) + const anchorRef = useRef<{ tableId: string; row: number; col: number } | null>(null) + const dragging = useRef(false) + + function cellAt(tableId: string, x: number, y: number) { + const tbl = document.getElementById(tableId) as HTMLTableElement | null + if (!tbl) return null + const el = document.elementFromPoint(x, y) + const cell = el?.closest('td,th') as HTMLElement | null + if (!cell || !tbl.contains(cell)) return null + const row = cell.closest('tr') as HTMLTableRowElement + const allRows = Array.from(tbl.querySelectorAll('tr')) + const ri = allRows.indexOf(row) + const ci = Array.from(row.querySelectorAll('td,th')).indexOf(cell as HTMLTableCellElement) + return ri >= 0 && ci >= 0 ? { ri, ci } : null + } + + function applyExtend(tableId: string, ri: number, ci: number) { + const a = anchorRef.current + if (!a || a.tableId !== tableId) return + const s: Sel = { tableId, r1: Math.min(a.row, ri), c1: Math.min(a.col, ci), r2: Math.max(a.row, ri), c2: Math.max(a.col, ci) } + setSel(s); selRef.current = s + } + + function readStats(s: Sel | null) { + if (!s) { setStats(null); return } + const tbl = document.getElementById(s.tableId) as HTMLTableElement | null + if (!tbl) { setStats(null); return } + const rows = Array.from(tbl.querySelectorAll('tr')) + let count = 0; const nums: number[] = [] + for (let r = s.r1; r <= s.r2; r++) { + const cells = Array.from(rows[r]?.querySelectorAll('td,th') ?? []) + for (let c = s.c1; c <= s.c2; c++) { + const text = (cells[c]?.textContent ?? '').trim() + if (!text) continue + count++ + const n = parseFloat(text.replace(/[£,%\s]/g, '').replace(/,/g, '')) + if (!isNaN(n)) nums.push(n) + } + } + const sum = nums.reduce((a, b) => a + b, 0) + setStats({ count, numCount: nums.length, sum, avg: nums.length ? sum / nums.length : 0 }) + } + + const onMouseDown = useCallback((tableId: string, e: React.MouseEvent) => { + e.preventDefault() // stops browser text-selection highlight + const pos = cellAt(tableId, e.clientX, e.clientY) + if (!pos) return + const { ri, ci } = pos + if (e.shiftKey && anchorRef.current?.tableId === tableId) { + applyExtend(tableId, ri, ci) + requestAnimationFrame(() => readStats(selRef.current)) + } else { + anchorRef.current = { tableId, row: ri, col: ci } + const s: Sel = { tableId, r1: ri, c1: ci, r2: ri, c2: ci } + setSel(s); selRef.current = s + dragging.current = true + } + }, []) + + useEffect(() => { + function onMove(e: MouseEvent) { + if (!dragging.current || !anchorRef.current) return + const pos = cellAt(anchorRef.current.tableId, e.clientX, e.clientY) + if (pos) applyExtend(anchorRef.current.tableId, pos.ri, pos.ci) + } + function onUp() { + if (!dragging.current) return + dragging.current = false + requestAnimationFrame(() => readStats(selRef.current)) + } + function onKey(e: KeyboardEvent) { + if (!(e.ctrlKey || e.metaKey) || e.key !== 'c') return + const s = selRef.current + if (!s) return + const tbl = document.getElementById(s.tableId) as HTMLTableElement | null + if (!tbl) return + const rows = Array.from(tbl.querySelectorAll('tr')) + const lines: string[] = [] + for (let r = s.r1; r <= s.r2; r++) { + const cells = Array.from(rows[r]?.querySelectorAll('td,th') ?? []) + lines.push(cells.slice(s.c1, s.c2 + 1).map(c => (c.textContent ?? '').trim()).join('\t')) + } + navigator.clipboard.writeText(lines.join('\n')).catch(() => {}) + } + document.addEventListener('mousemove', onMove) + document.addEventListener('mouseup', onUp) + document.addEventListener('keydown', onKey) + return () => { + document.removeEventListener('mousemove', onMove) + document.removeEventListener('mouseup', onUp) + document.removeEventListener('keydown', onKey) + } + }, []) + + function cs(tableId: string, r: number, c: number): React.CSSProperties { + if (!sel || sel.tableId !== tableId || r < sel.r1 || r > sel.r2 || c < sel.c1 || c > sel.c2) return {} + return { outline: '2px solid #0078d4', outlineOffset: '-2px', background: '#cce4ff' } + } + + function clearSel() { setSel(null); selRef.current = null; setStats(null) } + + return { onMouseDown, cs, stats, clearSel } +} diff --git a/frontend/src/pages/MultiDayReport.tsx b/frontend/src/pages/MultiDayReport.tsx index c24d4fe..f29dfd1 100644 --- a/frontend/src/pages/MultiDayReport.tsx +++ b/frontend/src/pages/MultiDayReport.tsx @@ -1,7 +1,8 @@ -import { useState, useEffect, useRef, useCallback } from 'react' +import { useState, useEffect } from 'react' import { api } from '../api' import { PageHeader, Card, Btn, StatusBadge } from '../components/Layout' import { fmtGBP } from '../types' +import { useExcelSelection } from '../hooks/useExcelSelection' // ── Types ───────────────────────────────────────────────────────────────────── @@ -86,119 +87,6 @@ function varLabel(banked: number, reported: number) { ) } -// ── Excel-like cell selection ───────────────────────────────────────────────── - -interface SelStats { count: number; numCount: number; sum: number; avg: number } - -function useExcelSelection() { - type Sel = { tableId: string; r1: number; c1: number; r2: number; c2: number } - const [sel, setSel] = useState(null) - const [stats, setStats] = useState(null) - const selRef = useRef(null) - const anchorRef = useRef<{ tableId: string; row: number; col: number } | null>(null) - const dragging = useRef(false) - - function cellAt(tableId: string, x: number, y: number) { - const tbl = document.getElementById(tableId) as HTMLTableElement | null - if (!tbl) return null - const el = document.elementFromPoint(x, y) - const cell = el?.closest('td,th') as HTMLElement | null - if (!cell || !tbl.contains(cell)) return null - const row = cell.closest('tr') as HTMLTableRowElement - const allRows = Array.from(tbl.querySelectorAll('tr')) - const ri = allRows.indexOf(row) - const ci = Array.from(row.querySelectorAll('td,th')).indexOf(cell as HTMLTableCellElement) - return ri >= 0 && ci >= 0 ? { ri, ci } : null - } - - function applyExtend(tableId: string, ri: number, ci: number) { - const a = anchorRef.current - if (!a || a.tableId !== tableId) return - const s: Sel = { tableId, r1: Math.min(a.row, ri), c1: Math.min(a.col, ci), r2: Math.max(a.row, ri), c2: Math.max(a.col, ci) } - setSel(s); selRef.current = s - } - - function readStats(s: Sel | null) { - if (!s) { setStats(null); return } - const tbl = document.getElementById(s.tableId) as HTMLTableElement | null - if (!tbl) { setStats(null); return } - const rows = Array.from(tbl.querySelectorAll('tr')) - let count = 0; const nums: number[] = [] - for (let r = s.r1; r <= s.r2; r++) { - const cells = Array.from(rows[r]?.querySelectorAll('td,th') ?? []) - for (let c = s.c1; c <= s.c2; c++) { - const text = (cells[c]?.textContent ?? '').trim() - if (!text) continue - count++ - const n = parseFloat(text.replace(/[£,%\s]/g, '').replace(/,/g, '')) - if (!isNaN(n)) nums.push(n) - } - } - const sum = nums.reduce((a, b) => a + b, 0) - setStats({ count, numCount: nums.length, sum, avg: nums.length ? sum / nums.length : 0 }) - } - - const onMouseDown = useCallback((tableId: string, e: React.MouseEvent) => { - e.preventDefault() // stops browser text-selection highlight - const pos = cellAt(tableId, e.clientX, e.clientY) - if (!pos) return - const { ri, ci } = pos - if (e.shiftKey && anchorRef.current?.tableId === tableId) { - applyExtend(tableId, ri, ci) - requestAnimationFrame(() => readStats(selRef.current)) - } else { - anchorRef.current = { tableId, row: ri, col: ci } - const s: Sel = { tableId, r1: ri, c1: ci, r2: ri, c2: ci } - setSel(s); selRef.current = s - dragging.current = true - } - }, []) - - useEffect(() => { - function onMove(e: MouseEvent) { - if (!dragging.current || !anchorRef.current) return - const pos = cellAt(anchorRef.current.tableId, e.clientX, e.clientY) - if (pos) applyExtend(anchorRef.current.tableId, pos.ri, pos.ci) - } - function onUp() { - if (!dragging.current) return - dragging.current = false - requestAnimationFrame(() => readStats(selRef.current)) - } - function onKey(e: KeyboardEvent) { - if (!(e.ctrlKey || e.metaKey) || e.key !== 'c') return - const s = selRef.current - if (!s) return - const tbl = document.getElementById(s.tableId) as HTMLTableElement | null - if (!tbl) return - const rows = Array.from(tbl.querySelectorAll('tr')) - const lines: string[] = [] - for (let r = s.r1; r <= s.r2; r++) { - const cells = Array.from(rows[r]?.querySelectorAll('td,th') ?? []) - lines.push(cells.slice(s.c1, s.c2 + 1).map(c => (c.textContent ?? '').trim()).join('\t')) - } - navigator.clipboard.writeText(lines.join('\n')).catch(() => {}) - } - document.addEventListener('mousemove', onMove) - document.addEventListener('mouseup', onUp) - document.addEventListener('keydown', onKey) - return () => { - document.removeEventListener('mousemove', onMove) - document.removeEventListener('mouseup', onUp) - document.removeEventListener('keydown', onKey) - } - }, []) - - function cs(tableId: string, r: number, c: number): React.CSSProperties { - if (!sel || sel.tableId !== tableId || r < sel.r1 || r > sel.r2 || c < sel.c1 || c > sel.c2) return {} - return { outline: '2px solid #0078d4', outlineOffset: '-2px', background: '#cce4ff' } - } - - function clearSel() { setSel(null); selRef.current = null; setStats(null) } - - return { onMouseDown, cs, stats, clearSel } -} - // ── Occupancy stats ─────────────────────────────────────────────────────────── function buildOccStats( diff --git a/frontend/src/pages/SafeCount.tsx b/frontend/src/pages/SafeCount.tsx index f7cba83..0cb4a49 100644 --- a/frontend/src/pages/SafeCount.tsx +++ b/frontend/src/pages/SafeCount.tsx @@ -6,6 +6,10 @@ import { GBP_DENOMINATIONS, fmtGBP } from '../types' import type { FloatCount, FloatDenomination } from '../types' import { FloatHistory, FloatRecordPrint } from './FloatManagement' import type { DetailRecord } from './FloatManagement' +import { useExcelSelection } from '../hooks/useExcelSelection' + +const NEW_TOTAL_TABLE_ID = 'tbl-safe-newtotal' +const NEW_TOTAL_COL = 3 function SafeCountAdjust() { const navigate = useNavigate() @@ -26,6 +30,7 @@ function SafeCountAdjust() { const [error, setError] = useState(null) const [savedRecord, setSavedRecord] = useState(null) const [reloadKey, setReloadKey] = useState(0) + const { onMouseDown: onTblDown, cs, stats, clearSel } = useExcelSelection() useEffect(() => { setLoading(true) @@ -133,9 +138,12 @@ function SafeCountAdjust() { )} +

+ Click a New Total cell, then Shift+Click to select a range. Ctrl+C to copy. +

- +
@@ -145,10 +153,11 @@ function SafeCountAdjust() { - {GBP_DENOMINATIONS.map(d => { + {GBP_DENOMINATIONS.map((d, i) => { const curr = current[d.value] || 0 const adj = adjust[d.value] || 0 const newT = newTotals[d.value] + const newTotalRow = i + 1 return ( @@ -172,10 +181,15 @@ function SafeCountAdjust() { }} /> - @@ -193,7 +207,14 @@ function SafeCountAdjust() { ? (adjustTotal > 0 ? '+' : '-') + fmtGBP(Math.abs(adjustTotal)) : '—'} - @@ -202,6 +223,17 @@ function SafeCountAdjust() { + {stats && ( +
+ Count: {stats.count} + {stats.numCount > 0 && <> + Sum: {fmtGBP(stats.sum)} + Average: {fmtGBP(stats.avg)} + } + +
+ )} +
Denomination
{d.label} 0 || adj < 0) ? 'var(--danger)' : undefined, - }}> + onTblDown(NEW_TOTAL_TABLE_ID, e)} + style={{ + padding: '0.3rem 0.75rem', textAlign: 'right', fontWeight: 600, + cursor: 'cell', userSelect: 'none', + color: newT === 0 && (curr > 0 || adj < 0) ? 'var(--danger)' : undefined, + ...cs(NEW_TOTAL_TABLE_ID, newTotalRow, NEW_TOTAL_COL), + }} + > {newT > 0 ? fmtGBP(newT) : (curr > 0 || adj !== 0) ? fmtGBP(0) : '—'}
+ onTblDown(NEW_TOTAL_TABLE_ID, e)} + style={{ + padding: '0.5rem 0.75rem', textAlign: 'right', fontSize: '1rem', + cursor: 'cell', userSelect: 'none', + ...cs(NEW_TOTAL_TABLE_ID, GBP_DENOMINATIONS.length + 1, NEW_TOTAL_COL), + }} + > {fmtGBP(newTotal)}