/* ClubLedger – main SPA */ let currentUser = null; let cashierMember = null; let barMember = null; let editMemberId = null; let editAccountId = null; // --------------------------------------------------------------------------- // Boot – check session, then either show login or start the app // --------------------------------------------------------------------------- (async function boot() { // Load config first so the login page shows the club name await loadConfig(); document.getElementById('loginBrand').textContent = cfg.club_name; let me = null; try { me = await apiFetch('/auth/me'); } catch (e) { /* not logged in */ } if (!me) { showLogin(); return; } currentUser = me; await startApp(); })(); function showLogin() { document.getElementById('loginOverlay').classList.remove('hidden'); document.getElementById('loginUsername').focus(); document.getElementById('loginForm').addEventListener('submit', doLogin, { once: true }); } async function doLogin(e) { e.preventDefault(); const username = document.getElementById('loginUsername').value.trim(); const password = document.getElementById('loginPassword').value; try { currentUser = await apiFetch('/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }) }); document.getElementById('loginOverlay').classList.add('hidden'); await startApp(); } catch (err) { setMsg('loginMsg', err.message, 'err'); document.getElementById('loginForm').addEventListener('submit', doLogin, { once: true }); } } async function doLogout() { try { await fetch('/auth/logout', { method: 'POST' }); } catch (e) { /* ignore */ } currentUser = null; // Reset tab visibility for next login document.getElementById('adminTabBtn').classList.add('hidden'); document.querySelector('[data-view="cashier"]').classList.remove('hidden'); document.querySelector('[data-view="bar"]').classList.remove('hidden'); // Reset to members tab document.querySelectorAll('.nav-btn').forEach(b => b.classList.remove('active')); document.querySelector('[data-view="members"]').classList.add('active'); document.querySelectorAll('.view').forEach(v => v.classList.add('hidden')); document.getElementById('view-members').classList.remove('hidden'); showLogin(); } function populateTransferTypes() { const types = Array.isArray(cfg.transfer_types) ? cfg.transfer_types : []; ['cashierTransferType', 'withdrawalTransferType'].forEach(id => { const sel = document.getElementById(id); if (!sel) return; const prev = sel.value; sel.innerHTML = '' + types.map(t => ``).join(''); if (prev && types.includes(prev)) sel.value = prev; }); } async function startApp() { document.getElementById('loginOverlay').classList.add('hidden'); await loadConfig(); populateTransferTypes(); const brand = document.getElementById('navBrand'); if (brand) brand.textContent = cfg.club_name; document.getElementById('navUser').textContent = currentUser.name; // Role-based tab visibility if (currentUser.role === 'admin') { document.getElementById('adminTabBtn').classList.remove('hidden'); } if (currentUser.role === 'pos-staff') { document.querySelector('[data-view="cashier"]').classList.add('hidden'); } if (currentUser.role === 'cashier') { document.querySelector('[data-view="bar"]').classList.add('hidden'); } // Nav tab switching document.querySelectorAll('.nav-btn').forEach(btn => { btn.addEventListener('click', () => { document.querySelectorAll('.nav-btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); document.querySelectorAll('.view').forEach(v => v.classList.add('hidden')); document.getElementById('view-' + btn.dataset.view).classList.remove('hidden'); if (btn.dataset.view === 'admin') loadAdminView(); }); }); // Form submit handlers document.getElementById('registerForm').addEventListener('submit', e => { e.preventDefault(); registerMember(); }); document.getElementById('editForm').addEventListener('submit', e => { e.preventDefault(); saveEdit(); }); document.getElementById('editAccountForm').addEventListener('submit', e => { e.preventDefault(); saveEditAccount(); }); document.getElementById('settingsForm').addEventListener('submit', e => { e.preventDefault(); saveSettings(); }); document.getElementById('addAccountForm').addEventListener('submit', e => { e.preventDefault(); addAccount(); }); // Enter-key on search inputs document.getElementById('memberSearch').addEventListener('keydown', e => { if (e.key === 'Enter') searchMembers(); }); document.getElementById('cashierSearch').addEventListener('keydown', e => { if (e.key === 'Enter') cashierSearchMembers(); }); document.getElementById('barSearch').addEventListener('keydown', e => { if (e.key === 'Enter') barSearchMembers(); }); searchMembers(); } // --------------------------------------------------------------------------- // Amount helpers (users enter major units, we send minor units) // --------------------------------------------------------------------------- const ROLE_LABELS = { admin: 'Admin', cashier: 'Cashier', 'pos-staff': 'POS Staff' }; function fmtRole(role) { return ROLE_LABELS[role] || role; } function toMinor(inputId) { const v = parseFloat(document.getElementById(inputId).value); if (isNaN(v) || v <= 0) return null; return Math.round(v * cfg.currency_divisor); } // --------------------------------------------------------------------------- // Members view // --------------------------------------------------------------------------- async function registerMember() { const number = document.getElementById('reg-number').value.trim(); const name = document.getElementById('reg-name').value.trim(); const pin = document.getElementById('reg-pin').value; if (!number || !name || !pin) { setMsg('registerMsg', 'All fields required.', 'err'); return; } try { const m = await apiFetch('/members', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ member_number: number, name, pin }) }); setMsg('registerMsg', `Registered: ${m.name} (#${m.member_number})`, 'ok'); document.getElementById('registerForm').reset(); searchMembers(); } catch (err) { setMsg('registerMsg', err.message, 'err'); } } async function searchMembers() { const q = document.getElementById('memberSearch').value.trim(); try { const members = await apiFetch(q ? `/members?q=${encodeURIComponent(q)}` : '/members'); renderMemberTable(members); } catch (e) { console.error(e); } } function renderMemberTable(members) { const tbody = document.querySelector('#memberTable tbody'); if (!members.length) { tbody.innerHTML = 'No members found'; return; } tbody.innerHTML = members.map(m => ` ${esc(m.member_number)} ${esc(m.name)} ${esc(m.balance_display)} ${m.created_at ? m.created_at.slice(0, 10) : ''} Statement ${m.balance === 0 ? `` : ''} `).join(''); } // Edit member modal function openEditModal(id, name, number, overdraftOverride) { editMemberId = id; document.getElementById('edit-number').value = number; document.getElementById('edit-name').value = name; document.getElementById('edit-pin').value = ''; setMsg('editMsg', '', ''); const policy = cfg.overdraft_policy || 'never'; const overrideRow = document.getElementById('editOverdraftRow'); const overrideCheck = document.getElementById('edit-overdraft'); const overrideLabel = document.getElementById('editOverdraftLabel'); if (policy === 'staff_override' || (policy === 'admin_override' && currentUser.role === 'admin')) { overrideLabel.textContent = 'Allow overdraft for this member'; overrideCheck.checked = (overdraftOverride === 1); overrideRow.classList.remove('hidden'); } else if (policy === 'staff_block') { overrideLabel.textContent = 'Block overdraft for this member'; overrideCheck.checked = (overdraftOverride === 0); overrideRow.classList.remove('hidden'); } else { overrideRow.classList.add('hidden'); } document.getElementById('editModal').classList.remove('hidden'); document.getElementById('edit-name').focus(); } function closeEditModal() { editMemberId = null; document.getElementById('editModal').classList.add('hidden'); } async function saveEdit() { if (!editMemberId) return; const body = { member_number: document.getElementById('edit-number').value.trim(), name: document.getElementById('edit-name').value.trim(), }; const pin = document.getElementById('edit-pin').value; if (pin) body.pin = pin; const policy = cfg.overdraft_policy || 'never'; const overrideRow = document.getElementById('editOverdraftRow'); if (!overrideRow.classList.contains('hidden')) { const checked = document.getElementById('edit-overdraft').checked; if (policy === 'staff_block') { body.overdraft_override = checked ? 0 : null; } else { body.overdraft_override = checked ? 1 : null; } } try { await apiFetch(`/members/${editMemberId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); closeEditModal(); searchMembers(); } catch (err) { setMsg('editMsg', err.message, 'err'); } } async function deleteMember(id, name) { if (!confirm(`Delete member "${name}"?\n\nThis permanently removes their account and transaction history.`)) return; try { await apiFetch(`/members/${id}`, { method: 'DELETE' }); searchMembers(); } catch (err) { alert(err.message); } } // --------------------------------------------------------------------------- // Cashier view // --------------------------------------------------------------------------- async function cashierSearchMembers() { const q = document.getElementById('cashierSearch').value.trim(); try { const members = await apiFetch(q ? `/members?q=${encodeURIComponent(q)}` : '/members'); document.getElementById('cashierMemberList').innerHTML = members.map(m => `
${esc(m.name)}
#${esc(m.member_number)}
${esc(m.balance_display)}
`).join(''); } catch (e) { console.error(e); } } function selectCashierMember(id, name, number, balance, balanceDisplay) { cashierMember = { id, name, number }; document.getElementById('cashierMemberList').innerHTML = ''; document.getElementById('cashierSelected').innerHTML = `${esc(name)}   #${esc(number)}   Balance: ${esc(balanceDisplay)}`; document.getElementById('cashierForm').classList.remove('hidden'); setMsg('cashierTopupMsg', '', ''); setMsg('cashierWithdrawalMsg', '', ''); setMsg('cashierMsg', '', ''); } function clearCashierSelection() { cashierMember = null; document.getElementById('cashierForm').classList.add('hidden'); document.getElementById('cashierAmount').value = ''; document.getElementById('cashierTransferType').value = ''; document.getElementById('cashierTransferRef').value = ''; document.getElementById('cashierNote').value = ''; document.getElementById('withdrawalAmount').value = ''; document.getElementById('withdrawalPin').value = ''; document.getElementById('withdrawalTransferType').value = ''; document.getElementById('withdrawalTransferRef').value = ''; document.getElementById('withdrawalNote').value = ''; setMsg('cashierTopupMsg', '', ''); setMsg('cashierWithdrawalMsg', '', ''); setMsg('cashierMsg', '', ''); } async function doTopup() { if (!cashierMember) return; const amount = toMinor('cashierAmount'); const transferType = document.getElementById('cashierTransferType').value || null; const transferRef = document.getElementById('cashierTransferRef').value.trim() || null; const note = document.getElementById('cashierNote').value.trim() || null; if (!amount) { setMsg('cashierTopupMsg', 'Enter a valid amount.', 'err'); return; } try { const r = await apiFetch('/topup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ member_id: cashierMember.id, amount, note, transfer_type: transferType, transfer_ref: transferRef }) }); window.open(`/receipt/${r.entry_id}`, '_blank'); document.getElementById('cashierAmount').value = ''; document.getElementById('cashierTransferType').value = ''; document.getElementById('cashierTransferRef').value = ''; document.getElementById('cashierNote').value = ''; setMsg('cashierTopupMsg', `Top-up complete. New balance: ${r.new_balance_display}`, 'ok'); } catch (err) { setMsg('cashierTopupMsg', err.message, 'err'); } } async function doWithdrawal() { if (!cashierMember) return; const amount = toMinor('withdrawalAmount'); const pin = document.getElementById('withdrawalPin').value; const transferType = document.getElementById('withdrawalTransferType').value || null; const transferRef = document.getElementById('withdrawalTransferRef').value.trim() || null; const note = document.getElementById('withdrawalNote').value.trim() || null; if (!amount) { setMsg('cashierWithdrawalMsg', 'Enter a valid amount.', 'err'); return; } if (!pin) { setMsg('cashierWithdrawalMsg', 'PIN is required.', 'err'); return; } try { const r = await apiFetch('/withdrawal', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ member_id: cashierMember.id, amount, pin, note, transfer_type: transferType, transfer_ref: transferRef }) }); window.open(`/receipt/${r.entry_id}`, '_blank'); document.getElementById('withdrawalAmount').value = ''; document.getElementById('withdrawalPin').value = ''; document.getElementById('withdrawalTransferType').value = ''; document.getElementById('withdrawalTransferRef').value = ''; document.getElementById('withdrawalNote').value = ''; setMsg('cashierWithdrawalMsg', `Withdrawal complete. New balance: ${r.new_balance_display}`, 'ok'); } catch (err) { setMsg('cashierWithdrawalMsg', err.message, 'err'); } } // --------------------------------------------------------------------------- // Bar view // --------------------------------------------------------------------------- async function barSearchMembers() { const q = document.getElementById('barSearch').value.trim(); try { const members = await apiFetch(q ? `/members?q=${encodeURIComponent(q)}` : '/members'); document.getElementById('barMemberList').innerHTML = members.map(m => `
${esc(m.name)}
#${esc(m.member_number)}
${esc(m.balance_display)}
`).join(''); } catch (e) { console.error(e); } } function selectBarMember(id, name, number, balance, balanceDisplay) { barMember = { id, name, number }; document.getElementById('barMemberList').innerHTML = ''; document.getElementById('barSelected').innerHTML = `${esc(name)}   #${esc(number)}   Balance: ${esc(balanceDisplay)}`; document.getElementById('barForm').classList.remove('hidden'); setMsg('barMsg', '', ''); } function clearBarSelection() { barMember = null; document.getElementById('barForm').classList.add('hidden'); document.getElementById('barAmount').value = ''; document.getElementById('barNote').value = ''; hidePinOverlay(); setMsg('barMsg', '', ''); } function hidePinOverlay() { document.getElementById('pinOverlay').classList.add('hidden'); document.getElementById('barPin').value = ''; setMsg('pinMsg', '', ''); } function cancelPin() { hidePinOverlay(); // return focus to amount so staff can adjust document.getElementById('barAmount').focus(); } function prepareCharge() { if (!barMember) return; const amount = toMinor('barAmount'); if (!amount) { setMsg('barMsg', 'Enter a valid amount.', 'err'); return; } setMsg('barMsg', '', ''); // populate overlay document.getElementById('pinAmount').textContent = 'Charge: ' + fmtAmount(amount); document.getElementById('pinMember').textContent = barMember.name; document.getElementById('barPin').value = ''; setMsg('pinMsg', '', ''); document.getElementById('pinOverlay').classList.remove('hidden'); document.getElementById('barPin').focus(); } async function confirmCharge() { if (!barMember) return; const amount = toMinor('barAmount'); const pin = document.getElementById('barPin').value; const note = document.getElementById('barNote').value.trim(); if (!pin) { setMsg('pinMsg', 'PIN required.', 'err'); return; } try { const r = await apiFetch('/charge', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ member_id: barMember.id, amount, pin, note: note || null }) }); window.open(`/receipt/${r.entry_id}`, '_blank'); setMsg('barMsg', `Charge complete. New balance: ${r.new_balance_display}`, 'ok'); clearBarSelection(); } catch (err) { setMsg('pinMsg', err.message, 'err'); } } // --------------------------------------------------------------------------- // Admin view // --------------------------------------------------------------------------- async function loadAdminView() { await Promise.all([loadAdminSettings(), loadStaffAccounts()]); setupLogoUpload(); } let _logoUploadWired = false; function setupLogoUpload() { if (_logoUploadWired) return; const input = document.getElementById('s-logo-upload'); if (!input) return; _logoUploadWired = true; input.addEventListener('change', async function() { const file = this.files[0]; if (!file) return; const fd = new FormData(); fd.append('file', file); try { const r = await fetch('/admin/logo', { method: 'POST', body: fd }); const json = await r.json(); if (!r.ok) throw new Error(json.detail || 'Upload failed'); document.getElementById('s-logo-url').value = json.url; setMsg('logoUploadMsg', 'Logo uploaded.', 'ok'); } catch (e) { setMsg('logoUploadMsg', e.message, 'err'); } this.value = ''; }); } async function loadAdminSettings() { try { const s = await apiFetch('/admin/settings'); const div = s.currency_divisor || 100; const majorUnit = s.currency_major || 'major units'; // General document.getElementById('s-club-name').value = s.club_name || ''; document.getElementById('s-currency-symbol').value = s.currency_symbol || ''; document.getElementById('s-currency-major').value = s.currency_major || ''; document.getElementById('s-currency-minor').value = s.currency_minor || ''; document.getElementById('s-currency-divisor').value = div; document.getElementById('s-min-topup').value = ((s.min_topup || 0) / div).toFixed(2); document.getElementById('s-max-topup').value = ((s.max_topup || 0) / div).toFixed(2); document.getElementById('s-max-charge').value = ((s.max_charge || 0) / div).toFixed(2); document.getElementById('s-overdraft-policy').value = s.overdraft_policy || 'never'; document.getElementById('s-timezone').value = s.timezone || ''; document.getElementById('s-paper-size').value = s.paper_size || 'A4'; document.getElementById('s-min-hint').textContent = `in ${majorUnit}`; document.getElementById('s-max-hint').textContent = `in ${majorUnit}`; document.getElementById('s-charge-hint').textContent= `in ${majorUnit}`; // Business address document.getElementById('s-biz-address1').value = s.biz_address1 || ''; document.getElementById('s-biz-address2').value = s.biz_address2 || ''; document.getElementById('s-biz-address3').value = s.biz_address3 || ''; document.getElementById('s-biz-address4').value = s.biz_address4 || ''; document.getElementById('s-biz-country').value = s.biz_country || ''; document.getElementById('s-biz-phone').value = s.biz_phone || ''; document.getElementById('s-biz-email').value = s.biz_email || ''; document.getElementById('s-biz-website').value = s.biz_website || ''; // Branding document.getElementById('s-logo-url').value = s.logo_url || ''; document.getElementById('s-logo-align').value = s.logo_align || 'left'; document.getElementById('s-logo-max-width').value = s.logo_max_width || ''; document.getElementById('s-logo-max-height').value = s.logo_max_height || ''; document.getElementById('s-bar-name').value = s.bar_name || ''; document.getElementById('s-cashier-name').value = s.cashier_name || ''; // Transactions document.getElementById('s-txn-ref-prefix').value = s.txn_ref_prefix || ''; // transfer_types from /admin/settings is the raw comma-separated string const rawTT = Array.isArray(s.transfer_types) ? s.transfer_types.join(',') : (s.transfer_types || ''); document.getElementById('s-transfer-types').value = rawTT; // Receipt labels document.getElementById('s-lbl-receipt').value = s.lbl_receipt || ''; document.getElementById('s-lbl-topup-receipt').value = s.lbl_topup_receipt || ''; document.getElementById('s-lbl-withdrawal-receipt').value = s.lbl_withdrawal_receipt || ''; document.getElementById('s-lbl-staff').value = s.lbl_staff || ''; document.getElementById('s-lbl-transaction').value = s.lbl_transaction || ''; document.getElementById('s-lbl-charge').value = s.lbl_charge_venue || ''; document.getElementById('s-lbl-txn-time').value = s.lbl_txn_time || ''; document.getElementById('s-lbl-amount-charged').value = s.lbl_amount_charged || ''; document.getElementById('s-lbl-remaining-balance').value = s.lbl_remaining_balance || ''; document.getElementById('s-lbl-balance-transfer').value = s.lbl_balance_transfer || ''; document.getElementById('s-lbl-amount-topup').value = s.lbl_amount_topup || ''; document.getElementById('s-lbl-amount-withdrawal').value = s.lbl_amount_withdrawal || ''; document.getElementById('s-lbl-transfer-type').value = s.lbl_transfer_type || ''; document.getElementById('s-lbl-transfer-ref').value = s.lbl_transfer_ref || ''; // Footers document.getElementById('s-receipt-footer').value = s.receipt_footer || ''; document.getElementById('s-receipt-footer-charge').value = s.receipt_footer_charge || ''; document.getElementById('s-receipt-footer-cashier').value = s.receipt_footer_cashier || ''; } catch (err) { setMsg('settingsMsg', err.message, 'err'); } } function _sv(id) { return document.getElementById(id).value; } function _svt(id) { return _sv(id).trim(); } async function saveSettings() { const div = parseInt(_sv('s-currency-divisor'), 10) || 100; const body = { // General club_name: _svt('s-club-name'), currency_symbol: _svt('s-currency-symbol'), currency_major: _svt('s-currency-major'), currency_minor: _svt('s-currency-minor'), currency_divisor: div, min_topup: Math.round(parseFloat(_sv('s-min-topup')) * div), max_topup: Math.round(parseFloat(_sv('s-max-topup')) * div), max_charge: Math.round(parseFloat(_sv('s-max-charge')) * div), overdraft_policy: _sv('s-overdraft-policy'), timezone: _svt('s-timezone'), paper_size: _sv('s-paper-size'), // Business address biz_address1: _svt('s-biz-address1'), biz_address2: _svt('s-biz-address2'), biz_address3: _svt('s-biz-address3'), biz_address4: _svt('s-biz-address4'), biz_country: _svt('s-biz-country'), biz_phone: _svt('s-biz-phone'), biz_email: _svt('s-biz-email'), biz_website: _svt('s-biz-website'), // Branding logo_url: _svt('s-logo-url'), logo_align: _sv('s-logo-align'), logo_max_width: parseInt(_sv('s-logo-max-width'), 10) || null, logo_max_height: parseInt(_sv('s-logo-max-height'), 10) || null, bar_name: _svt('s-bar-name'), cashier_name: _svt('s-cashier-name'), // Transactions txn_ref_prefix: _svt('s-txn-ref-prefix'), transfer_types: _svt('s-transfer-types'), // Receipt labels lbl_receipt: _svt('s-lbl-receipt'), lbl_topup_receipt: _svt('s-lbl-topup-receipt'), lbl_withdrawal_receipt: _svt('s-lbl-withdrawal-receipt'), lbl_staff: _svt('s-lbl-staff'), lbl_transaction: _svt('s-lbl-transaction'), lbl_charge_venue: _svt('s-lbl-charge'), lbl_txn_time: _svt('s-lbl-txn-time'), lbl_amount_charged: _svt('s-lbl-amount-charged'), lbl_remaining_balance: _svt('s-lbl-remaining-balance'), lbl_balance_transfer: _svt('s-lbl-balance-transfer'), lbl_amount_topup: _svt('s-lbl-amount-topup'), lbl_amount_withdrawal: _svt('s-lbl-amount-withdrawal'), lbl_transfer_type: _svt('s-lbl-transfer-type'), lbl_transfer_ref: _svt('s-lbl-transfer-ref'), // Footers receipt_footer: _sv('s-receipt-footer'), receipt_footer_charge: _sv('s-receipt-footer-charge'), receipt_footer_cashier: _sv('s-receipt-footer-cashier'), }; try { await apiFetch('/admin/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); setMsg('settingsMsg', 'Settings saved.', 'ok'); await loadConfig(); populateTransferTypes(); document.querySelectorAll('.currency-unit').forEach(el => { el.textContent = cfg.currency_major || cfg.currency_unit; }); if (document.getElementById('navBrand')) document.getElementById('navBrand').textContent = cfg.club_name; } catch (err) { setMsg('settingsMsg', err.message, 'err'); } } // Staff accounts table async function loadStaffAccounts() { try { const accounts = await apiFetch('/admin/staff-accounts'); const tbody = document.querySelector('#staffAccountsTable tbody'); tbody.innerHTML = accounts.map(a => ` ${esc(a.name)} ${esc(a.username)} ${esc(fmtRole(a.role))} ${a.active ? 'Active' : 'Inactive'} `).join(''); } catch (err) { console.error(err); } } async function addAccount() { const body = { name: document.getElementById('acc-name').value.trim(), username: document.getElementById('acc-username').value.trim(), password: document.getElementById('acc-password').value, role: document.getElementById('acc-role').value, }; if (!body.name || !body.username || !body.password) { setMsg('accountMsg', 'All fields required.', 'err'); return; } try { await apiFetch('/admin/staff-accounts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); setMsg('accountMsg', `Account created for ${body.name}.`, 'ok'); document.getElementById('addAccountForm').reset(); loadStaffAccounts(); } catch (err) { setMsg('accountMsg', err.message, 'err'); } } function openEditAccountModal(id, name, username, role, active) { editAccountId = id; document.getElementById('eacc-name').value = name; document.getElementById('eacc-username').value = username; document.getElementById('eacc-password').value = ''; document.getElementById('eacc-role').value = role; document.getElementById('eacc-active').checked = !!active; setMsg('editAccountMsg', '', ''); document.getElementById('editAccountModal').classList.remove('hidden'); } function closeEditAccountModal() { editAccountId = null; document.getElementById('editAccountModal').classList.add('hidden'); } async function saveEditAccount() { if (!editAccountId) return; const body = { name: document.getElementById('eacc-name').value.trim(), username: document.getElementById('eacc-username').value.trim(), role: document.getElementById('eacc-role').value, active: document.getElementById('eacc-active').checked, }; const pw = document.getElementById('eacc-password').value; if (pw) body.password = pw; try { await apiFetch(`/admin/staff-accounts/${editAccountId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); closeEditAccountModal(); loadStaffAccounts(); } catch (err) { setMsg('editAccountMsg', err.message, 'err'); } } async function deleteAccount(id, name) { if (!confirm(`Delete account for "${name}"?`)) return; try { await apiFetch(`/admin/staff-accounts/${id}`, { method: 'DELETE' }); loadStaffAccounts(); } catch (err) { alert(err.message); } }