// 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 });