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() { function useExcelSelection() {
type Sel = { tableId: string; r1: number; c1: number; r2: number; c2: number } type Sel = { tableId: string; r1: number; c1: number; r2: number; c2: number }
const [sel, setSel] = useState<Sel | null>(null) 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 [stats, setStats] = useState<SelStats | null>(null)
const selRef = useRef(sel) const selRef = useRef<Sel | null>(null)
selRef.current = sel 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 } if (!s) { setStats(null); return }
const tbl = document.getElementById(s.tableId) as HTMLTableElement | null const tbl = document.getElementById(s.tableId) as HTMLTableElement | null
if (!tbl) { setStats(null); return } if (!tbl) { setStats(null); return }
@ -116,28 +136,33 @@ function useExcelSelection() {
setStats({ count, numCount: nums.length, sum, avg: nums.length ? sum / nums.length : 0 }) setStats({ count, numCount: nums.length, sum, avg: nums.length ? sum / nums.length : 0 })
} }
const onClick = useCallback((tableId: string, e: React.MouseEvent<HTMLTableElement>) => { const onMouseDown = useCallback((tableId: string, e: React.MouseEvent<HTMLTableElement>) => {
const cell = (e.target as Element).closest('td,th') as HTMLElement | null e.preventDefault() // stops browser text-selection highlight
if (!cell) return const pos = cellAt(tableId, e.clientX, e.clientY)
const tableEl = e.currentTarget if (!pos) return
const allRows = Array.from(tableEl.querySelectorAll('tr')) const { ri, ci } = pos
const row = cell.closest('tr') as HTMLTableRowElement if (e.shiftKey && anchorRef.current?.tableId === tableId) {
const ri = allRows.indexOf(row) applyExtend(tableId, ri, ci)
const ci = Array.from(row.querySelectorAll('td,th')).indexOf(cell as HTMLTableCellElement) requestAnimationFrame(() => readStats(selRef.current))
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) }
} else { } else {
newSel = { tableId, r1: ri, c1: ci, r2: ri, c2: ci } anchorRef.current = { tableId, row: ri, col: ci }
setAnchor({ 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(() => { 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) { function onKey(e: KeyboardEvent) {
if (!(e.ctrlKey || e.metaKey) || e.key !== 'c') return if (!(e.ctrlKey || e.metaKey) || e.key !== 'c') return
const s = selRef.current const s = selRef.current
@ -152,8 +177,14 @@ function useExcelSelection() {
} }
navigator.clipboard.writeText(lines.join('\n')).catch(() => {}) navigator.clipboard.writeText(lines.join('\n')).catch(() => {})
} }
document.addEventListener('mousemove', onMove)
document.addEventListener('mouseup', onUp)
document.addEventListener('keydown', onKey) 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 { 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' } 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 ─────────────────────────────────────────────────────────── // ── Occupancy stats ───────────────────────────────────────────────────────────
@ -300,7 +331,7 @@ export function MultiDayReport() {
const [error, setError] = useState('') const [error, setError] = useState('')
const [debtors, setDebtors] = useState<DebtorsResult | null>(null) const [debtors, setDebtors] = useState<DebtorsResult | null>(null)
const [debtorsLoading, setDebtorsLoading] = useState(false) const [debtorsLoading, setDebtorsLoading] = useState(false)
const { onClick, cs, stats, clearSel } = useExcelSelection() const { onMouseDown: onTblDown, cs, stats, clearSel } = useExcelSelection()
async function generate() { async function generate() {
setLoading(true); setError(''); setResult(null); setDebtors(null) setLoading(true); setError(''); setResult(null); setDebtors(null)
@ -368,7 +399,7 @@ export function MultiDayReport() {
<div style={{ display: 'flex', gap: '0.75rem', minWidth: '900px' }}> <div style={{ display: 'flex', gap: '0.75rem', minWidth: '900px' }}>
{/* BANKED table */} {/* 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> <thead>
<tr><th colSpan={8} style={{ ...th, background: '#e8f5e9', borderBottom: '1px solid #bbb', textAlign: 'center', textTransform: 'uppercase', letterSpacing: '0.5px' }}>BANKED</th></tr> <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' }}> <tr style={{ borderBottom: '2px solid #ccc' }}>
@ -429,7 +460,7 @@ export function MultiDayReport() {
</table> </table>
{/* REPORTED 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> <thead>
<tr><th colSpan={9} style={{ ...th, background: '#fff3e0', borderBottom: '1px solid #bbb', textAlign: 'center', textTransform: 'uppercase', letterSpacing: '0.5px' }}>REPORTED</th></tr> <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' }}> <tr style={{ borderBottom: '2px solid #ccc' }}>
@ -483,7 +514,7 @@ export function MultiDayReport() {
{/* Gross Sales */} {/* Gross Sales */}
<div style={{ marginTop: '1.5rem' }}> <div style={{ marginTop: '1.5rem' }}>
<h3 style={{ fontSize: '0.875rem', fontWeight: 700, marginBottom: '0.5rem' }}>Gross Sales (from Earned Revenue)</h3> <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> <thead>
<tr style={{ borderBottom: '2px solid #ccc' }}> <tr style={{ borderBottom: '2px solid #ccc' }}>
<th style={{ ...th, background: '#f1f1f1' }}>Date</th> <th style={{ ...th, background: '#f1f1f1' }}>Date</th>
@ -513,7 +544,7 @@ export function MultiDayReport() {
<Card style={{ marginBottom: '1.5rem', overflowX: 'auto' }}> <Card style={{ marginBottom: '1.5rem', overflowX: 'auto' }}>
<h2 style={sec}>Sales Breakdown (Net Values)</h2> <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> <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> <thead>
<tr style={{ borderBottom: '2px solid #ccc' }}> <tr style={{ borderBottom: '2px solid #ccc' }}>
<th style={{ ...th, textAlign: 'left' }}>Date</th> <th style={{ ...th, textAlign: 'left' }}>Date</th>
@ -572,7 +603,7 @@ export function MultiDayReport() {
<Card style={{ marginBottom: '1.5rem', overflowX: 'auto' }}> <Card style={{ marginBottom: '1.5rem', overflowX: 'auto' }}>
<h2 style={sec}>Occupancy Statistics</h2> <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>} {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> <thead>
<tr style={{ borderBottom: '2px solid #ccc' }}> <tr style={{ borderBottom: '2px solid #ccc' }}>
<th style={{ ...th, textAlign: 'left' }}>Date</th> <th style={{ ...th, textAlign: 'left' }}>Date</th>
@ -645,7 +676,7 @@ export function MultiDayReport() {
{debtorsLoading ? ( {debtorsLoading ? (
<p style={{ color: 'var(--text-mid)', fontSize: '0.875rem' }}>Loading balances</p> <p style={{ color: 'var(--text-mid)', fontSize: '0.875rem' }}>Loading balances</p>
) : debtors ? ( ) : 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> <thead>
<tr style={{ borderBottom: '2px solid #ccc' }}> <tr style={{ borderBottom: '2px solid #ccc' }}>
<th style={{ ...th, textAlign: 'left' }}>Date</th> <th style={{ ...th, textAlign: 'left' }}>Date</th>
@ -693,7 +724,7 @@ export function MultiDayReport() {
<h2 style={sec}>Copy/Paste Format (for Spreadsheet)</h2> <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> <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> <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> <thead>
<tr> <tr>
<th colSpan={7} style={{ ...th, background: '#e8f5e9', textAlign: 'center' }}>BANKED</th> <th colSpan={7} style={{ ...th, background: '#e8f5e9', textAlign: 'center' }}>BANKED</th>
@ -736,7 +767,7 @@ export function MultiDayReport() {
</table> </table>
<h3 style={{ fontSize: '0.875rem', fontWeight: 700, marginBottom: '0.5rem' }}>Gross Sales</h3> <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> <thead>
<tr style={{ borderBottom: '2px solid #ccc' }}> <tr style={{ borderBottom: '2px solid #ccc' }}>
{['Date','','Gross Sales'].map((l, i) => <th key={i} style={{ ...th, background: '#f1f1f1' }}>{l}</th>)} {['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 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 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 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 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' } const td: React.CSSProperties = { padding: '0.45rem 0.625rem', border: '1px solid #ddd' }