// Shared UI components const { useState, useEffect, useRef, useMemo } = React; const { CURRENCIES, fmt, fmtDate, sumCurr, ymKey, ymLabel, todayISO, uid } = window.TF; // ===== Icons (inline SVG, no emoji) ===== const Icon = { menu: (p) => ( ), close: (p) => ( ), home: (p) => ( ), inflow: (p) => ( ), outflow: (p) => ( ), report: (p) => ( ), plus: (p) => ( ), trash: (p) => ( ), temple: (p) => ( ), vault: (p) => ( ), edit: (p) => ( ), excel: (p) => ( ), search: (p) => ( ), }; // ===== Currency strip ===== function CurrencyStrip({ totals, subLabel }) { return (
{CURRENCIES.map(c => (
{c.symbol} {c.label}
{fmt(totals[c.code])}
{subLabel || c.full}
))}
); } // ===== Formatted currency input (live comma formatting while typing) ===== function CurrencyInput({ value, onChange }) { const toDisplay = (v) => { const s = String(v || '').replace(/[^\d.]/g, ''); if (!s) return ''; const [int, dec] = s.split('.'); const grouped = int.replace(/\B(?=(\d{3})+(?!\d))/g, ','); return dec !== undefined ? `${grouped}.${dec}` : grouped; }; const handleChange = (e) => { const el = e.target; const cursor = el.selectionStart; const rawInput = el.value; // How many commas are left of the cursor in what user just typed const commasBefore = (rawInput.slice(0, cursor).match(/,/g) || []).length; const digitPos = cursor - commasBefore; // Strip everything non-numeric (commas, stray chars), keep one dot const stripped = rawInput.replace(/,/g, '').replace(/[^\d.]/g, ''); const dotIdx = stripped.indexOf('.'); const sanitized = dotIdx === -1 ? stripped : stripped.slice(0, dotIdx + 1) + stripped.slice(dotIdx + 1).replace(/\./g, ''); const formatted = toDisplay(sanitized); // Find cursor position in the new formatted string let newCursor = formatted.length; let digits = 0; for (let i = 0; i < formatted.length; i++) { if (digits === digitPos) { newCursor = i; break; } if (formatted[i] !== ',') digits++; } onChange(sanitized); // Restore cursor after React re-render requestAnimationFrame(() => { if (el === document.activeElement) { el.setSelectionRange(newCursor, newCursor); } }); }; return ( ); } // ===== Amount field (4 currencies) ===== function AmountFields({ values, onChange }) { return (
{CURRENCIES.map(c => (
onChange({ ...values, [c.code]: v })} /> {c.symbol}
))}
); } // ===== Empty state ===== function Empty({ text = 'ຍັງບໍ່ມີລາຍການ' }) { return (
{text}
); } // ===== Toast ===== function useToast() { const [t, setT] = useState(null); useEffect(() => { if (!t) return; const id = setTimeout(() => setT(null), 1800); return () => clearTimeout(id); }, [t]); const node = t ?
{t}
: null; return [node, setT]; } // ===== Pagination ===== const PER_PAGE = 10; function Pager({ total, page, onPage }) { const pages = Math.ceil(total / PER_PAGE); if (pages <= 1) return null; return (
ໜ້າ {page} / {pages} ({total} ລາຍການ)
); } // ===== Excel export ===== function exportXLSX(filename, sheets) { const XLSX = window.XLSX; if (!XLSX) { alert('ໂຫຼດ SheetJS ບໍ່ສຳເລັດ — ກວດສອບການເຊື່ອມຕໍ່ອິນເຕີເນັດ'); return; } const wb = XLSX.utils.book_new(); sheets.forEach(({ name, headers, rows, totalsRow }) => { const data = [headers, ...rows]; if (totalsRow) data.push(totalsRow); const ws = XLSX.utils.aoa_to_sheet(data); ws['!cols'] = headers.map(h => ({ wch: Math.max(String(h).length + 6, 14) })); XLSX.utils.book_append_sheet(wb, ws, name); }); XLSX.writeFile(wb, filename); } Object.assign(window, { Icon, CurrencyStrip, CurrencyInput, AmountFields, Empty, useToast, Pager, PER_PAGE, exportXLSX });