Weekly report: proper Excel drag-select (no text highlight)

- Switch from onClick to onMouseDown + e.preventDefault() so the
  browser never starts its own text-selection on click or drag
- Add document-level mousemove/mouseup listeners for drag-to-select:
  hold mouse button and sweep across cells to highlight a range
- Shift+click still extends the rectangle from the anchor cell
- userSelect: none on tables prevents any residual text highlighting
- Stats bar (Count/Sum/Avg) now appears after mouseup so it reads the
  final selection, not mid-drag state

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-02 00:21:25 +00:00
parent f62aa6f347
commit 460e0c3d77

View file

@ -91,12 +91,32 @@ 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<Sel | null>(null)
const [anchor, setAnchor] = useState<{ tableId: string; row: number; col: number } | null>(null)
const [stats, setStats] = useState<SelStats | null>(null)
const selRef = useRef(sel)
selRef.current = sel
const selRef = useRef<Sel | null>(null)
const anchorRef = useRef<{ tableId: string; row: number; col: number } | null>(null)
const dragging = useRef(false)
function computeStats(s: Sel | null) {
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 }
@ -116,28 +136,33 @@ function useExcelSelection() {
setStats({ count, numCount: nums.length, sum, avg: nums.length ? sum / nums.length : 0 })
}
const onClick = useCallback((tableId: string, e: React.MouseEvent<HTMLTableElement>) => {
const cell = (e.target as Element).closest('td,th') as HTMLElement | null
if (!cell) return
const tableEl = e.currentTarget
const allRows = Array.from(tableEl.querySelectorAll('tr'))
const row = cell.closest('tr') as HTMLTableRowElement
const ri = allRows.indexOf(row)
const ci = Array.from(row.querySelectorAll('td,th')).indexOf(cell as HTMLTableCellElement)
if (ri < 0 || ci < 0) return
let newSel: Sel
if (e.shiftKey && anchor?.tableId === tableId) {
newSel = { tableId, r1: Math.min(anchor.row, ri), c1: Math.min(anchor.col, ci), r2: Math.max(anchor.row, ri), c2: Math.max(anchor.col, ci) }
const onMouseDown = useCallback((tableId: string, e: React.MouseEvent<HTMLTableElement>) => {
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 {
newSel = { tableId, r1: ri, c1: ci, r2: ri, c2: ci }
setAnchor({ tableId, row: ri, col: ci })
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
}
setSel(newSel)
// compute stats after next paint so DOM is settled
requestAnimationFrame(() => computeStats(newSel))
}, [anchor])
}, [])
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
@ -152,8 +177,14 @@ function useExcelSelection() {
}
navigator.clipboard.writeText(lines.join('\n')).catch(() => {})
}
document.addEventListener('mousemove', onMove)
document.addEventListener('mouseup', onUp)
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('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 {
@ -161,9 +192,9 @@ function useExcelSelection() {
return { outline: '2px solid #0078d4', outlineOffset: '-2px', background: '#cce4ff' }
}
function clearSel() { setSel(null); setStats(null) }
function clearSel() { setSel(null); selRef.current = null; setStats(null) }
return { onClick, cs, stats, clearSel }
return { onMouseDown, cs, stats, clearSel }
}
// ── Occupancy stats ───────────────────────────────────────────────────────────
@ -300,7 +331,7 @@ export function MultiDayReport() {
const [error, setError] = useState('')
const [debtors, setDebtors] = useState<DebtorsResult | null>(null)
const [debtorsLoading, setDebtorsLoading] = useState(false)
const { onClick, cs, stats, clearSel } = useExcelSelection()
const { onMouseDown: onTblDown, cs, stats, clearSel } = useExcelSelection()
async function generate() {
setLoading(true); setError(''); setResult(null); setDebtors(null)
@ -368,7 +399,7 @@ export function MultiDayReport() {
<div style={{ display: 'flex', gap: '0.75rem', minWidth: '900px' }}>
{/* BANKED table */}
<table id="tbl-banked" style={tblSt} onClick={e => onClick('tbl-banked', e)}>
<table id="tbl-banked" style={tblSt} onMouseDown={e => onTblDown('tbl-banked', e)}>
<thead>
<tr><th colSpan={8} style={{ ...th, background: '#e8f5e9', borderBottom: '1px solid #bbb', textAlign: 'center', textTransform: 'uppercase', letterSpacing: '0.5px' }}>BANKED</th></tr>
<tr style={{ borderBottom: '2px solid #ccc' }}>
@ -429,7 +460,7 @@ export function MultiDayReport() {
</table>
{/* REPORTED table */}
<table id="tbl-reported" style={tblSt} onClick={e => onClick('tbl-reported', e)}>
<table id="tbl-reported" style={tblSt} onMouseDown={e => onTblDown('tbl-reported', e)}>
<thead>
<tr><th colSpan={9} style={{ ...th, background: '#fff3e0', borderBottom: '1px solid #bbb', textAlign: 'center', textTransform: 'uppercase', letterSpacing: '0.5px' }}>REPORTED</th></tr>
<tr style={{ borderBottom: '2px solid #ccc' }}>
@ -483,7 +514,7 @@ export function MultiDayReport() {
{/* Gross Sales */}
<div style={{ marginTop: '1.5rem' }}>
<h3 style={{ fontSize: '0.875rem', fontWeight: 700, marginBottom: '0.5rem' }}>Gross Sales (from Earned Revenue)</h3>
<table id="tbl-grosssales" style={{ ...tblSt, width: '40%', minWidth: '220px' }} onClick={e => onClick('tbl-grosssales', e)}>
<table id="tbl-grosssales" style={{ ...tblSt, width: '40%', minWidth: '220px' }} onMouseDown={e => onTblDown('tbl-grosssales', e)}>
<thead>
<tr style={{ borderBottom: '2px solid #ccc' }}>
<th style={{ ...th, background: '#f1f1f1' }}>Date</th>
@ -513,7 +544,7 @@ export function MultiDayReport() {
<Card style={{ marginBottom: '1.5rem', overflowX: 'auto' }}>
<h2 style={sec}>Sales Breakdown (Net Values)</h2>
<p style={{ fontSize: '0.75rem', color: 'var(--text-mid)', marginBottom: '0.75rem' }}>Dates on rows, GL categories on columns. Click/Shift+Click to select, Ctrl+C to copy.</p>
<table id="tbl-sales" style={tblSt} onClick={e => onClick('tbl-sales', e)}>
<table id="tbl-sales" style={tblSt} onMouseDown={e => onTblDown('tbl-sales', e)}>
<thead>
<tr style={{ borderBottom: '2px solid #ccc' }}>
<th style={{ ...th, textAlign: 'left' }}>Date</th>
@ -572,7 +603,7 @@ export function MultiDayReport() {
<Card style={{ marginBottom: '1.5rem', overflowX: 'auto' }}>
<h2 style={sec}>Occupancy Statistics</h2>
{occStats.totalRooms > 0 && <p style={{ fontSize: '0.75rem', color: 'var(--text-mid)', marginBottom: '0.75rem' }}>Total rooms: {occStats.totalRooms} (excluding overflow)</p>}
<table id="tbl-occ" style={tblSt} onClick={e => onClick('tbl-occ', e)}>
<table id="tbl-occ" style={tblSt} onMouseDown={e => onTblDown('tbl-occ', e)}>
<thead>
<tr style={{ borderBottom: '2px solid #ccc' }}>
<th style={{ ...th, textAlign: 'left' }}>Date</th>
@ -645,7 +676,7 @@ export function MultiDayReport() {
{debtorsLoading ? (
<p style={{ color: 'var(--text-mid)', fontSize: '0.875rem' }}>Loading balances</p>
) : debtors ? (
<table id="tbl-balances" style={tblSt} onClick={e => onClick('tbl-balances', e)}>
<table id="tbl-balances" style={tblSt} onMouseDown={e => onTblDown('tbl-balances', e)}>
<thead>
<tr style={{ borderBottom: '2px solid #ccc' }}>
<th style={{ ...th, textAlign: 'left' }}>Date</th>
@ -693,7 +724,7 @@ export function MultiDayReport() {
<h2 style={sec}>Copy/Paste Format (for Spreadsheet)</h2>
<p style={{ fontSize: '0.75rem', color: 'var(--text-mid)', marginBottom: '0.75rem' }}>2-row format: Gateway Amex on second row. Select all and Ctrl+C.</p>
<h3 style={{ fontSize: '0.875rem', fontWeight: 700, marginBottom: '0.5rem' }}>Cash Up &amp; Reconciliation</h3>
<table id="tbl-paste-recon" style={{ ...tblSt, marginBottom: '1.5rem' }} onClick={e => onClick('tbl-paste-recon', e)}>
<table id="tbl-paste-recon" style={{ ...tblSt, marginBottom: '1.5rem' }} onMouseDown={e => onTblDown('tbl-paste-recon', e)}>
<thead>
<tr>
<th colSpan={7} style={{ ...th, background: '#e8f5e9', textAlign: 'center' }}>BANKED</th>
@ -736,7 +767,7 @@ export function MultiDayReport() {
</table>
<h3 style={{ fontSize: '0.875rem', fontWeight: 700, marginBottom: '0.5rem' }}>Gross Sales</h3>
<table id="tbl-paste-gross" style={{ ...tblSt, width: '40%', minWidth: '220px' }} onClick={e => onClick('tbl-paste-gross', e)}>
<table id="tbl-paste-gross" style={{ ...tblSt, width: '40%', minWidth: '220px' }} onMouseDown={e => onTblDown('tbl-paste-gross', e)}>
<thead>
<tr style={{ borderBottom: '2px solid #ccc' }}>
{['Date','','Gross Sales'].map((l, i) => <th key={i} style={{ ...th, background: '#f1f1f1' }}>{l}</th>)}
@ -778,6 +809,6 @@ export function MultiDayReport() {
const lbl: React.CSSProperties = { fontSize: '0.75rem', color: 'var(--text-mid)', display: 'block', marginBottom: '0.25rem' }
const inp: React.CSSProperties = { border: '1px solid var(--card-border)', borderRadius: '6px', padding: '0.4rem 0.6rem', fontSize: '0.875rem' }
const sec: React.CSSProperties = { fontSize: '1rem', fontWeight: 700, marginBottom: '0.75rem' }
const tblSt: React.CSSProperties = { borderCollapse: 'collapse', fontSize: '0.8rem', width: '100%', cursor: 'cell', userSelect: 'text' }
const tblSt: React.CSSProperties = { borderCollapse: 'collapse', fontSize: '0.8rem', width: '100%', cursor: 'cell', userSelect: 'none' }
const th: React.CSSProperties = { padding: '0.5rem 0.625rem', textAlign: 'right', fontWeight: 600, color: '#333', whiteSpace: 'nowrap', border: '1px solid #ccc' }
const td: React.CSSProperties = { padding: '0.45rem 0.625rem', border: '1px solid #ddd' }