Add staff auth, admin area, currency decimal input

Auth system
- staff_accounts table: name, username, bcrypt password, role (staff|admin)
- Session tokens in memory (8-hour TTL), httpOnly cookie
- POST /auth/login, /auth/logout, GET /auth/me
- All API endpoints now require a valid session
- Default admin account seeded on first run (admin/admin), printed to console
- Staff name for transactions comes from the session, no more dropdown

Currency input fix
- Amount inputs are now decimal (step=0.01); users enter 1.00 not 100
- Frontend multiplies by cfg.currency_divisor before POSTing
- TopupRequest/ChargeRequest no longer include staff_name (from session)

Admin area (4th tab, admin role only)
- App Settings: club name, currency symbol, major/minor unit names,
  divisor, min/max topup, max charge, receipt footer, allow overdraft
- Settings persisted in app_settings DB table; merged with CONFIG defaults
  at startup and refreshed after each save
- Staff Accounts: list with edit modal (name, username, password, role,
  active flag) and delete; Add Account inline form
- /admin/settings GET/POST, /admin/staff-accounts CRUD
- /config endpoint exposes live settings to frontend on every page load

receipt_footer field rendered on both receipt and statement print views

https://claude.ai/code/session_01JuRTR5Xjx8emQsyerBgGU7
This commit is contained in:
Claude 2026-05-30 09:19:07 +00:00
parent 79d51973cd
commit a5c9af1ca6
No known key found for this signature in database
4 changed files with 876 additions and 462 deletions

806
main.py

File diff suppressed because it is too large Load diff

View file

@ -1,48 +1,107 @@
/* ClubLedger main SPA */ /* ClubLedger main SPA */
let currentUser = null;
let cashierMember = null; let cashierMember = null;
let barMember = null; let barMember = null;
let editMemberId = null; let editMemberId = null;
let editAccountId = null;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Boot // Boot check session, then either show login or start the app
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
(async function init() { (async function boot() {
// Load config first so the login page shows the club name
await loadConfig(); await loadConfig();
await loadStaffInto('cashierStaff'); document.getElementById('loginBrand').textContent = cfg.club_name;
await loadStaffInto('barStaff');
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 { try {
const data = await apiFetch('/staff'); currentUser = await apiFetch('/auth/login', {
renderStaffChips(data.staff); method: 'POST',
} catch (e) { /* ignore */ } 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 });
}
}
// Nav async function doLogout() {
try { await fetch('/auth/logout', { method: 'POST' }); } catch (e) { /* ignore */ }
currentUser = null;
// 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();
}
async function startApp() {
await loadConfig();
const brand = document.getElementById('navBrand');
if (brand) brand.textContent = cfg.club_name;
document.getElementById('navUser').textContent = currentUser.name;
if (currentUser.role === 'admin') {
document.getElementById('adminTabBtn').classList.remove('hidden');
}
// Nav tab switching
document.querySelectorAll('.nav-btn').forEach(btn => { document.querySelectorAll('.nav-btn').forEach(btn => {
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
document.querySelectorAll('.nav-btn').forEach(b => b.classList.remove('active')); document.querySelectorAll('.nav-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active'); btn.classList.add('active');
document.querySelectorAll('.view').forEach(v => v.classList.add('hidden')); document.querySelectorAll('.view').forEach(v => v.classList.add('hidden'));
document.getElementById('view-' + btn.dataset.view).classList.remove('hidden'); document.getElementById('view-' + btn.dataset.view).classList.remove('hidden');
if (btn.dataset.view === 'admin') loadAdminView();
}); });
}); });
document.getElementById('registerForm').addEventListener('submit', async e => { // Form submit handlers
e.preventDefault(); document.getElementById('registerForm').addEventListener('submit', e => { e.preventDefault(); registerMember(); });
await registerMember(); document.getElementById('editForm').addEventListener('submit', e => { e.preventDefault(); saveEdit(); });
}); document.getElementById('editAccountForm').addEventListener('submit', e => { e.preventDefault(); saveEditAccount(); });
document.getElementById('editForm').addEventListener('submit', async e => { document.getElementById('settingsForm').addEventListener('submit', e => { e.preventDefault(); saveSettings(); });
e.preventDefault(); document.getElementById('addAccountForm').addEventListener('submit', e => { e.preventDefault(); addAccount(); });
await saveEdit();
});
// Enter-key on search inputs
document.getElementById('memberSearch').addEventListener('keydown', e => { if (e.key === 'Enter') searchMembers(); }); document.getElementById('memberSearch').addEventListener('keydown', e => { if (e.key === 'Enter') searchMembers(); });
document.getElementById('cashierSearch').addEventListener('keydown', e => { if (e.key === 'Enter') cashierSearchMembers(); }); document.getElementById('cashierSearch').addEventListener('keydown', e => { if (e.key === 'Enter') cashierSearchMembers(); });
document.getElementById('barSearch').addEventListener('keydown', e => { if (e.key === 'Enter') barSearchMembers(); }); document.getElementById('barSearch').addEventListener('keydown', e => { if (e.key === 'Enter') barSearchMembers(); });
document.getElementById('staffNameInput').addEventListener('keydown',e => { if (e.key === 'Enter') addStaff(); });
searchMembers(); searchMembers();
})(); }
// ---------------------------------------------------------------------------
// Amount helpers (users enter major units, we send minor units)
// ---------------------------------------------------------------------------
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 // Members view
@ -54,23 +113,19 @@ async function registerMember() {
if (!number || !name || !pin) { setMsg('registerMsg', 'All fields required.', 'err'); return; } if (!number || !name || !pin) { setMsg('registerMsg', 'All fields required.', 'err'); return; }
try { try {
const m = await apiFetch('/members', { const m = await apiFetch('/members', {
method: 'POST', method: 'POST', headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ member_number: number, name, pin }) body: JSON.stringify({ member_number: number, name, pin })
}); });
setMsg('registerMsg', `Registered: ${m.name} (#${m.member_number})`, 'ok'); setMsg('registerMsg', `Registered: ${m.name} (#${m.member_number})`, 'ok');
document.getElementById('registerForm').reset(); document.getElementById('registerForm').reset();
searchMembers(); searchMembers();
} catch (e) { } catch (err) { setMsg('registerMsg', err.message, 'err'); }
setMsg('registerMsg', e.message, 'err');
}
} }
async function searchMembers() { async function searchMembers() {
const q = document.getElementById('memberSearch').value.trim(); const q = document.getElementById('memberSearch').value.trim();
const url = q ? `/members?q=${encodeURIComponent(q)}` : '/members';
try { try {
const members = await apiFetch(url); const members = await apiFetch(q ? `/members?q=${encodeURIComponent(q)}` : '/members');
renderMemberTable(members); renderMemberTable(members);
} catch (e) { console.error(e); } } catch (e) { console.error(e); }
} }
@ -97,9 +152,7 @@ function renderMemberTable(members) {
</tr>`).join(''); </tr>`).join('');
} }
// --------------------------------------------------------------------------- // Edit member modal
// Edit member
// ---------------------------------------------------------------------------
function openEditModal(id, name, number) { function openEditModal(id, name, number) {
editMemberId = id; editMemberId = id;
document.getElementById('edit-number').value = number; document.getElementById('edit-number').value = number;
@ -117,35 +170,28 @@ function closeEditModal() {
async function saveEdit() { async function saveEdit() {
if (!editMemberId) return; if (!editMemberId) return;
const number = document.getElementById('edit-number').value.trim(); const body = {
const name = document.getElementById('edit-name').value.trim(); member_number: document.getElementById('edit-number').value.trim(),
const pin = document.getElementById('edit-pin').value; name: document.getElementById('edit-name').value.trim(),
const body = { member_number: number, name }; };
const pin = document.getElementById('edit-pin').value;
if (pin) body.pin = pin; if (pin) body.pin = pin;
try { try {
await apiFetch(`/members/${editMemberId}`, { await apiFetch(`/members/${editMemberId}`, {
method: 'PUT', method: 'PUT', headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body) body: JSON.stringify(body)
}); });
closeEditModal(); closeEditModal();
searchMembers(); searchMembers();
} catch (e) { } catch (err) { setMsg('editMsg', err.message, 'err'); }
setMsg('editMsg', e.message, 'err');
}
} }
// ---------------------------------------------------------------------------
// Delete member
// ---------------------------------------------------------------------------
async function deleteMember(id, name) { async function deleteMember(id, name) {
if (!confirm(`Delete member "${name}"?\n\nThis will permanently remove their account and transaction history.`)) return; if (!confirm(`Delete member "${name}"?\n\nThis permanently removes their account and transaction history.`)) return;
try { try {
await apiFetch(`/members/${id}`, { method: 'DELETE' }); await apiFetch(`/members/${id}`, { method: 'DELETE' });
searchMembers(); searchMembers();
} catch (e) { } catch (err) { alert(err.message); }
alert(e.message);
}
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -153,16 +199,11 @@ async function deleteMember(id, name) {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async function cashierSearchMembers() { async function cashierSearchMembers() {
const q = document.getElementById('cashierSearch').value.trim(); const q = document.getElementById('cashierSearch').value.trim();
const url = q ? `/members?q=${encodeURIComponent(q)}` : '/members';
try { try {
const members = await apiFetch(url); const members = await apiFetch(q ? `/members?q=${encodeURIComponent(q)}` : '/members');
const list = document.getElementById('cashierMemberList'); document.getElementById('cashierMemberList').innerHTML = members.map(m => `
list.innerHTML = members.map(m => `
<div class="member-pick-item" onclick="selectCashierMember(${m.id},'${esc(m.name)}','${esc(m.member_number)}',${m.balance},'${esc(m.balance_display)}')"> <div class="member-pick-item" onclick="selectCashierMember(${m.id},'${esc(m.name)}','${esc(m.member_number)}',${m.balance},'${esc(m.balance_display)}')">
<div> <div><div class="member-pick-name">${esc(m.name)}</div><div class="member-pick-sub">#${esc(m.member_number)}</div></div>
<div class="member-pick-name">${esc(m.name)}</div>
<div class="member-pick-sub">#${esc(m.member_number)}</div>
</div>
<div class="${balanceClass(m.balance)}">${esc(m.balance_display)}</div> <div class="${balanceClass(m.balance)}">${esc(m.balance_display)}</div>
</div>`).join(''); </div>`).join('');
} catch (e) { console.error(e); } } catch (e) { console.error(e); }
@ -187,23 +228,18 @@ function clearCashierSelection() {
async function doTopup() { async function doTopup() {
if (!cashierMember) return; if (!cashierMember) return;
const amount = parseInt(document.getElementById('cashierAmount').value, 10); const amount = toMinor('cashierAmount');
const staff = document.getElementById('cashierStaff').value;
const note = document.getElementById('cashierNote').value.trim(); const note = document.getElementById('cashierNote').value.trim();
if (!amount || isNaN(amount) || amount <= 0) { setMsg('cashierMsg', 'Enter a valid amount.', 'err'); return; } if (!amount) { setMsg('cashierMsg', 'Enter a valid amount.', 'err'); return; }
if (!staff) { setMsg('cashierMsg', 'Select a staff member.', 'err'); return; }
try { try {
const r = await apiFetch('/topup', { const r = await apiFetch('/topup', {
method: 'POST', method: 'POST', headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ member_id: cashierMember.id, amount, note: note || null })
body: JSON.stringify({ member_id: cashierMember.id, amount, staff_name: staff, note: note || null })
}); });
window.open(`/receipt/${r.entry_id}`, '_blank'); window.open(`/receipt/${r.entry_id}`, '_blank');
setMsg('cashierMsg', `Top-up complete. New balance: ${r.new_balance_display}`, 'ok'); setMsg('cashierMsg', `Top-up complete. New balance: ${r.new_balance_display}`, 'ok');
clearCashierSelection(); clearCashierSelection();
} catch (e) { } catch (err) { setMsg('cashierMsg', err.message, 'err'); }
setMsg('cashierMsg', e.message, 'err');
}
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -211,16 +247,11 @@ async function doTopup() {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async function barSearchMembers() { async function barSearchMembers() {
const q = document.getElementById('barSearch').value.trim(); const q = document.getElementById('barSearch').value.trim();
const url = q ? `/members?q=${encodeURIComponent(q)}` : '/members';
try { try {
const members = await apiFetch(url); const members = await apiFetch(q ? `/members?q=${encodeURIComponent(q)}` : '/members');
const list = document.getElementById('barMemberList'); document.getElementById('barMemberList').innerHTML = members.map(m => `
list.innerHTML = members.map(m => `
<div class="member-pick-item" onclick="selectBarMember(${m.id},'${esc(m.name)}','${esc(m.member_number)}',${m.balance},'${esc(m.balance_display)}')"> <div class="member-pick-item" onclick="selectBarMember(${m.id},'${esc(m.name)}','${esc(m.member_number)}',${m.balance},'${esc(m.balance_display)}')">
<div> <div><div class="member-pick-name">${esc(m.name)}</div><div class="member-pick-sub">#${esc(m.member_number)}</div></div>
<div class="member-pick-name">${esc(m.name)}</div>
<div class="member-pick-sub">#${esc(m.member_number)}</div>
</div>
<div class="${balanceClass(m.balance)}">${esc(m.balance_display)}</div> <div class="${balanceClass(m.balance)}">${esc(m.balance_display)}</div>
</div>`).join(''); </div>`).join('');
} catch (e) { console.error(e); } } catch (e) { console.error(e); }
@ -246,23 +277,157 @@ function clearBarSelection() {
async function doCharge() { async function doCharge() {
if (!barMember) return; if (!barMember) return;
const amount = parseInt(document.getElementById('barAmount').value, 10); const amount = toMinor('barAmount');
const pin = document.getElementById('barPin').value; const pin = document.getElementById('barPin').value;
const staff = document.getElementById('barStaff').value;
const note = document.getElementById('barNote').value.trim(); const note = document.getElementById('barNote').value.trim();
if (!amount || isNaN(amount) || amount <= 0) { setMsg('barMsg', 'Enter a valid amount.', 'err'); return; } if (!amount) { setMsg('barMsg', 'Enter a valid amount.', 'err'); return; }
if (!pin) { setMsg('barMsg', 'PIN required.', 'err'); return; } if (!pin) { setMsg('barMsg', 'PIN required.', 'err'); return; }
if (!staff) { setMsg('barMsg', 'Select a staff member.', 'err'); return; }
try { try {
const r = await apiFetch('/charge', { const r = await apiFetch('/charge', {
method: 'POST', method: 'POST', headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ member_id: barMember.id, amount, pin, note: note || null })
body: JSON.stringify({ member_id: barMember.id, amount, pin, staff_name: staff, note: note || null })
}); });
window.open(`/receipt/${r.entry_id}`, '_blank'); window.open(`/receipt/${r.entry_id}`, '_blank');
setMsg('barMsg', `Charge complete. New balance: ${r.new_balance_display}`, 'ok'); setMsg('barMsg', `Charge complete. New balance: ${r.new_balance_display}`, 'ok');
clearBarSelection(); clearBarSelection();
} catch (e) { } catch (err) { setMsg('barMsg', err.message, 'err'); }
setMsg('barMsg', e.message, 'err'); }
}
// ---------------------------------------------------------------------------
// Admin view
// ---------------------------------------------------------------------------
async function loadAdminView() {
await Promise.all([loadAdminSettings(), loadStaffAccounts()]);
}
async function loadAdminSettings() {
try {
const s = await apiFetch('/admin/settings');
const div = s.currency_divisor || 100;
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-receipt-footer').value = s.receipt_footer || '';
document.getElementById('s-allow-negative').checked = !!s.allow_negative_balance;
const sym = s.currency_symbol || '';
document.getElementById('s-min-hint').textContent = `in ${s.currency_major || 'major units'}`;
document.getElementById('s-max-hint').textContent = `in ${s.currency_major || 'major units'}`;
document.getElementById('s-charge-hint').textContent= `in ${s.currency_major || 'major units'}`;
} catch (err) { setMsg('settingsMsg', err.message, 'err'); }
}
async function saveSettings() {
const div = parseInt(document.getElementById('s-currency-divisor').value, 10) || 100;
const body = {
club_name: document.getElementById('s-club-name').value.trim(),
currency_symbol: document.getElementById('s-currency-symbol').value.trim(),
currency_major: document.getElementById('s-currency-major').value.trim(),
currency_minor: document.getElementById('s-currency-minor').value.trim(),
currency_divisor: div,
min_topup: Math.round(parseFloat(document.getElementById('s-min-topup').value) * div),
max_topup: Math.round(parseFloat(document.getElementById('s-max-topup').value) * div),
max_charge: Math.round(parseFloat(document.getElementById('s-max-charge').value) * div),
receipt_footer: document.getElementById('s-receipt-footer').value,
allow_negative_balance: document.getElementById('s-allow-negative').checked,
};
try {
await apiFetch('/admin/settings', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
setMsg('settingsMsg', 'Settings saved.', 'ok');
await loadConfig(); // refresh frontend cfg
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 => `
<tr>
<td>${esc(a.name)}</td>
<td>${esc(a.username)}</td>
<td style="text-transform:capitalize">${esc(a.role)}</td>
<td>${a.active ? '<span style="color:#080">Active</span>' : '<span style="color:#999">Inactive</span>'}</td>
<td class="row-actions">
<button class="btn row-btn" onclick="openEditAccountModal(${a.id},'${esc(a.name)}','${esc(a.username)}','${esc(a.role)}',${a.active})">Edit</button>
<button class="btn btn-danger row-btn" onclick="deleteAccount(${a.id},'${esc(a.name)}')">Delete</button>
</td>
</tr>`).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); }
} }

View file

@ -8,11 +8,37 @@
</head> </head>
<body> <body>
<!-- ===================== LOGIN OVERLAY ===================== -->
<div id="loginOverlay" class="login-overlay">
<div class="login-card">
<h1 id="loginBrand">ClubLedger</h1>
<p class="login-sub">Staff sign in</p>
<form id="loginForm">
<div class="form-row">
<label>Username</label>
<input type="text" id="loginUsername" autocomplete="username" required>
</div>
<div class="form-row">
<label>Password</label>
<input type="password" id="loginPassword" autocomplete="current-password" required>
</div>
<button type="submit" class="btn btn-primary" style="width:100%;margin-top:4px">Sign In</button>
</form>
<div id="loginMsg" class="msg"></div>
</div>
</div>
<!-- ===================== NAV ===================== -->
<nav> <nav>
<span class="brand" id="navBrand">ClubLedger</span> <span class="brand" id="navBrand">ClubLedger</span>
<button class="nav-btn active" data-view="members">Members</button> <button class="nav-btn active" data-view="members">Members</button>
<button class="nav-btn" data-view="cashier">Cashier</button> <button class="nav-btn" data-view="cashier">Cashier</button>
<button class="nav-btn" data-view="bar">Bar</button> <button class="nav-btn" data-view="bar">Bar</button>
<button class="nav-btn hidden" data-view="admin" id="adminTabBtn">Admin</button>
<div class="nav-right">
<span class="nav-user" id="navUser"></span>
<button class="nav-logout" onclick="doLogout()">Sign out</button>
</div>
</nav> </nav>
<!-- ===================== MEMBERS VIEW ===================== --> <!-- ===================== MEMBERS VIEW ===================== -->
@ -45,23 +71,11 @@
<button class="btn" onclick="searchMembers()">Search</button> <button class="btn" onclick="searchMembers()">Search</button>
</div> </div>
<table id="memberTable" class="data-table"> <table id="memberTable" class="data-table">
<thead> <thead><tr><th>#</th><th>Name</th><th>Balance</th><th>Joined</th><th class="actions-col"></th></tr></thead>
<tr><th>#</th><th>Name</th><th>Balance</th><th>Joined</th><th class="actions-col"></th></tr>
</thead>
<tbody></tbody> <tbody></tbody>
</table> </table>
</div> </div>
<div class="panel">
<h2>Staff</h2>
<div class="search-row">
<input type="text" id="staffNameInput" placeholder="Staff name">
<button class="btn btn-primary" onclick="addStaff()">Add</button>
</div>
<div id="staffChips" class="staff-chips"></div>
<div id="staffMsg" class="msg"></div>
</div>
</div> </div>
<!-- ===================== CASHIER VIEW ===================== --> <!-- ===================== CASHIER VIEW ===================== -->
@ -78,11 +92,7 @@
<div class="selected-member-box" id="cashierSelected"></div> <div class="selected-member-box" id="cashierSelected"></div>
<div class="form-row"> <div class="form-row">
<label>Amount (<span class="currency-unit"></span>)</label> <label>Amount (<span class="currency-unit"></span>)</label>
<input type="number" id="cashierAmount" placeholder="e.g. 1000" min="1" step="1"> <input type="number" id="cashierAmount" placeholder="e.g. 10.00" min="0.01" step="0.01">
</div>
<div class="form-row">
<label>Staff</label>
<select id="cashierStaff"></select>
</div> </div>
<div class="form-row"> <div class="form-row">
<label>Note (optional)</label> <label>Note (optional)</label>
@ -107,19 +117,14 @@
<div id="barForm" class="hidden"> <div id="barForm" class="hidden">
<div class="selected-member-box" id="barSelected"></div> <div class="selected-member-box" id="barSelected"></div>
<div class="form-row"> <div class="form-row">
<label>Amount (<span class="currency-unit"></span>)</label> <label>Amount (<span class="currency-unit"></span>)</label>
<input type="number" id="barAmount" placeholder="e.g. 350" min="1" step="1"> <input type="number" id="barAmount" placeholder="e.g. 3.50" min="0.01" step="0.01">
</div> </div>
<div class="form-row"> <div class="form-row">
<label>PIN</label> <label>Member PIN</label>
<input type="password" id="barPin" placeholder="Member PIN" maxlength="20"> <input type="password" id="barPin" placeholder="Member PIN" maxlength="20">
</div> </div>
<div class="form-row">
<label>Staff</label>
<select id="barStaff"></select>
</div>
<div class="form-row"> <div class="form-row">
<label>Note (optional)</label> <label>Note (optional)</label>
<input type="text" id="barNote" placeholder=""> <input type="text" id="barNote" placeholder="">
@ -131,21 +136,70 @@
</div> </div>
</div> </div>
<!-- ===================== EDIT MODAL ===================== --> <!-- ===================== ADMIN VIEW ===================== -->
<div id="view-admin" class="view hidden">
<div class="panel">
<h2>App Settings</h2>
<form id="settingsForm">
<div class="form-row"><label>Club Name</label>
<input type="text" id="s-club-name"></div>
<div class="form-row"><label>Currency Symbol</label>
<input type="text" id="s-currency-symbol" style="max-width:80px"></div>
<div class="form-row"><label>Currency Name <span class="label-hint">(major unit, e.g. pounds)</span></label>
<input type="text" id="s-currency-major" placeholder="pounds"></div>
<div class="form-row"><label>Subunit Name <span class="label-hint">(minor unit, e.g. pence)</span></label>
<input type="text" id="s-currency-minor" placeholder="pence"></div>
<div class="form-row"><label>Subunits per unit <span class="label-hint">(e.g. 100)</span></label>
<input type="number" id="s-currency-divisor" min="1" step="1" style="max-width:100px"></div>
<div class="form-row"><label>Minimum top-up <span class="label-hint" id="s-min-hint"></span></label>
<input type="number" id="s-min-topup" step="0.01" min="0.01"></div>
<div class="form-row"><label>Maximum top-up <span class="label-hint" id="s-max-hint"></span></label>
<input type="number" id="s-max-topup" step="0.01"></div>
<div class="form-row"><label>Maximum single charge <span class="label-hint" id="s-charge-hint"></span></label>
<input type="number" id="s-max-charge" step="0.01"></div>
<div class="form-row"><label>Receipt footer text <span class="label-hint">(optional)</span></label>
<textarea id="s-receipt-footer" rows="2" placeholder="Printed at the bottom of every receipt and statement"></textarea></div>
<div class="form-row form-row-check">
<input type="checkbox" id="s-allow-negative">
<label for="s-allow-negative">Allow negative balance (overdraft at bar)</label>
</div>
<button type="submit" class="btn btn-primary">Save Settings</button>
</form>
<div id="settingsMsg" class="msg"></div>
</div>
<div class="panel">
<h2>Staff Accounts</h2>
<table id="staffAccountsTable" class="data-table">
<thead><tr><th>Name</th><th>Username</th><th>Role</th><th>Status</th><th></th></tr></thead>
<tbody></tbody>
</table>
<div class="panel-divider"></div>
<h3 class="sub-heading">Add Account</h3>
<form id="addAccountForm">
<div class="form-row"><label>Name</label><input type="text" id="acc-name" required></div>
<div class="form-row"><label>Username</label><input type="text" id="acc-username" required autocomplete="off"></div>
<div class="form-row"><label>Password</label><input type="password" id="acc-password" required autocomplete="new-password"></div>
<div class="form-row"><label>Role</label>
<select id="acc-role"><option value="staff">Staff</option><option value="admin">Admin</option></select>
</div>
<button type="submit" class="btn btn-primary">Add Account</button>
</form>
<div id="accountMsg" class="msg"></div>
</div>
</div>
<!-- ===================== EDIT MEMBER MODAL ===================== -->
<div id="editModal" class="modal-overlay hidden" onclick="if(event.target===this)closeEditModal()"> <div id="editModal" class="modal-overlay hidden" onclick="if(event.target===this)closeEditModal()">
<div class="modal"> <div class="modal">
<h3>Edit Member</h3> <h3>Edit Member</h3>
<form id="editForm"> <form id="editForm">
<div class="form-row"><label>Member Number</label><input type="text" id="edit-number" required></div>
<div class="form-row"><label>Full Name</label><input type="text" id="edit-name" required></div>
<div class="form-row"> <div class="form-row">
<label>Member Number</label> <label>New PIN <span class="label-hint">(leave blank to keep current)</span></label>
<input type="text" id="edit-number" required>
</div>
<div class="form-row">
<label>Full Name</label>
<input type="text" id="edit-name" required>
</div>
<div class="form-row">
<label>New PIN <span style="font-weight:400;color:#aaa">(leave blank to keep current)</span></label>
<input type="password" id="edit-pin" placeholder="Leave blank to keep"> <input type="password" id="edit-pin" placeholder="Leave blank to keep">
</div> </div>
<div class="modal-actions"> <div class="modal-actions">
@ -157,6 +211,33 @@
</div> </div>
</div> </div>
<!-- ===================== EDIT ACCOUNT MODAL ===================== -->
<div id="editAccountModal" class="modal-overlay hidden" onclick="if(event.target===this)closeEditAccountModal()">
<div class="modal">
<h3>Edit Account</h3>
<form id="editAccountForm">
<div class="form-row"><label>Name</label><input type="text" id="eacc-name"></div>
<div class="form-row"><label>Username</label><input type="text" id="eacc-username" autocomplete="off"></div>
<div class="form-row">
<label>New Password <span class="label-hint">(leave blank to keep)</span></label>
<input type="password" id="eacc-password" placeholder="Leave blank to keep" autocomplete="new-password">
</div>
<div class="form-row"><label>Role</label>
<select id="eacc-role"><option value="staff">Staff</option><option value="admin">Admin</option></select>
</div>
<div class="form-row form-row-check">
<input type="checkbox" id="eacc-active">
<label for="eacc-active">Active (can log in)</label>
</div>
<div class="modal-actions">
<button type="submit" class="btn btn-primary">Save</button>
<button type="button" class="btn" onclick="closeEditAccountModal()">Cancel</button>
</div>
</form>
<div id="editAccountMsg" class="msg"></div>
</div>
</div>
<script src="/static/common.js"></script> <script src="/static/common.js"></script>
<script src="/static/app.js"></script> <script src="/static/app.js"></script>
</body> </body>

View file

@ -219,3 +219,57 @@ nav {
.msg:empty { display: none; } .msg:empty { display: none; }
.msg.ok { background: #d1fae5; color: #065f46; border: 1px solid #6ee7b7; } .msg.ok { background: #d1fae5; color: #065f46; border: 1px solid #6ee7b7; }
.msg.err { background: #fee2e2; color: #991b1b; border: 1px solid #fca5a5; } .msg.err { background: #fee2e2; color: #991b1b; border: 1px solid #fca5a5; }
/* ---- Login overlay ---- */
.login-overlay {
position: fixed;
inset: 0;
background: #1a1a2e;
display: flex;
align-items: center;
justify-content: center;
z-index: 500;
}
.login-card {
background: #fff;
border-radius: 12px;
padding: 40px;
width: 380px;
max-width: calc(100vw - 32px);
box-shadow: 0 20px 60px rgba(0,0,0,.4);
}
.login-card h1 { font-size: 1.6rem; margin-bottom: 4px; text-align: center; }
.login-sub { text-align: center; color: var(--muted); font-size: .9rem; margin-bottom: 24px; }
/* ---- Nav right (user + logout) ---- */
.nav-right { margin-left: auto; display: flex; align-items: center; gap: 12px; }
.nav-user { color: #aaa; font-size: .88rem; }
.nav-logout {
background: transparent;
border: 1px solid #555;
color: #ccc;
padding: 4px 12px;
border-radius: 4px;
cursor: pointer;
font-size: .85rem;
transition: background .15s;
}
.nav-logout:hover { background: rgba(255,255,255,.1); }
/* ---- Admin form extras ---- */
.label-hint { font-weight: 400; color: #aaa; font-size: .8rem; }
.form-row textarea {
padding: 9px 12px;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 1rem;
outline: none;
resize: vertical;
font-family: inherit;
transition: border-color .15s;
}
.form-row textarea:focus { border-color: var(--primary); }
.form-row-check { flex-direction: row !important; align-items: center; gap: 8px; }
.form-row-check label { font-weight: 400; color: var(--text); font-size: 1rem; }
.panel-divider { border: none; border-top: 1px solid var(--border); margin: 20px 0; }
.sub-heading { font-size: 1rem; margin-bottom: 14px; color: var(--text); }