Restore three-view SPA; add member edit and delete

- / now serves index.html (three-view SPA: Members, Cashier, Bar)
- /cashier and /bar remain as standalone pages (unchanged)
- Members view: Edit button on every row opens a modal to update
  name, member number, and optionally PIN. Delete button only appears
  when balance is exactly 0; confirmation dialog before deletion removes
  the member and their ledger entries.
- PUT /members/{id}: updates any combination of name/member_number/pin;
  guards against duplicate member numbers.
- DELETE /members/{id}: rejects with 400 if balance != 0, otherwise
  deletes ledger entries then member row.
- Modal styles added to style.css; app.js rebuilt as combined SPA script
  (loads common.js for shared helpers).

https://claude.ai/code/session_01JuRTR5Xjx8emQsyerBgGU7
This commit is contained in:
Claude 2026-05-30 08:33:44 +00:00
parent 34b3e88fe2
commit 6c155d00bb
No known key found for this signature in database
4 changed files with 230 additions and 90 deletions

57
main.py
View file

@ -188,13 +188,18 @@ class ProductCreate(BaseModel):
class StaffAdd(BaseModel):
name: str
class MemberUpdate(BaseModel):
member_number: Optional[str] = None
name: Optional[str] = None
pin: Optional[str] = None
# ---------------------------------------------------------------------------
# Page routes
# ---------------------------------------------------------------------------
@app.get("/", response_class=RedirectResponse)
@app.get("/", response_class=HTMLResponse)
async def root():
return RedirectResponse(url="/cashier", status_code=302)
return (static_dir / "index.html").read_text()
@app.get("/cashier", response_class=HTMLResponse)
async def cashier_page():
@ -232,6 +237,54 @@ def create_member(body: MemberCreate):
"created_at": row["created_at"],
}
@app.put("/members/{member_id}")
def update_member(member_id: int, body: MemberUpdate):
with db_conn() as conn:
member = conn.execute("SELECT * FROM members WHERE id=?", (member_id,)).fetchone()
if not member:
raise HTTPException(404, "Member not found")
updates = {}
if body.name is not None:
name = body.name.strip()
if not name:
raise HTTPException(400, "Name cannot be empty")
updates["name"] = name
if body.member_number is not None:
mn = body.member_number.strip()
if not mn:
raise HTTPException(400, "Member number cannot be empty")
clash = conn.execute(
"SELECT id FROM members WHERE member_number=? AND id!=?", (mn, member_id)
).fetchone()
if clash:
raise HTTPException(400, "Member number already in use")
updates["member_number"] = mn
if body.pin is not None:
if len(body.pin) < 4:
raise HTTPException(400, "PIN must be at least 4 characters")
updates["pin_hash"] = hash_pin(body.pin)
if updates:
set_clause = ", ".join(f"{k}=?" for k in updates)
conn.execute(
f"UPDATE members SET {set_clause} WHERE id=?",
list(updates.values()) + [member_id]
)
row = conn.execute("SELECT * FROM members WHERE id=?", (member_id,)).fetchone()
return {"id": row["id"], "member_number": row["member_number"], "name": row["name"]}
@app.delete("/members/{member_id}")
def delete_member(member_id: int):
with db_conn() as conn:
member = conn.execute("SELECT * FROM members WHERE id=?", (member_id,)).fetchone()
if not member:
raise HTTPException(404, "Member not found")
balance = member_balance(conn, member_id)
if balance != 0:
raise HTTPException(400, f"Cannot delete: balance is {format_amount(balance)}")
conn.execute("DELETE FROM ledger_entries WHERE member_id=?", (member_id,))
conn.execute("DELETE FROM members WHERE id=?", (member_id,))
return {"ok": True}
@app.get("/members")
def list_members(q: Optional[str] = None):
with db_conn() as conn:

View file

@ -1,22 +1,21 @@
/* ClubLedger frontend */
/* ClubLedger main SPA */
let cfg = { currency_unit: 'pence', currency_symbol: '£', currency_divisor: 100, club_name: 'ClubLedger' };
let cashierMember = null;
let barMember = null;
let barMember = null;
let editMemberId = null;
// ---------------------------------------------------------------------------
// Boot
// ---------------------------------------------------------------------------
(async function init() {
await loadConfig();
await loadStaffInto('cashierStaff');
await loadStaffInto('barStaff');
try {
const r = await fetch('/config');
cfg = await r.json();
document.getElementById('navBrand').textContent = cfg.club_name;
document.title = cfg.club_name;
document.querySelectorAll('.currency-unit').forEach(el => {
el.textContent = cfg.currency_unit;
});
} catch (e) { /* use defaults */ }
const data = await apiFetch('/staff');
renderStaffChips(data.staff);
} catch (e) { /* ignore */ }
// Nav
document.querySelectorAll('.nav-btn').forEach(btn => {
@ -28,51 +27,30 @@ let barMember = null;
});
});
// Register form
document.getElementById('registerForm').addEventListener('submit', async e => {
e.preventDefault();
await registerMember();
});
document.getElementById('editForm').addEventListener('submit', async e => {
e.preventDefault();
await saveEdit();
});
// Enter key on search fields
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('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();
})();
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function fmtAmount(pence) {
return cfg.currency_symbol + (pence / cfg.currency_divisor).toFixed(2);
}
function balanceClass(v) {
return v < 0 ? 'balance-neg' : 'balance-pos';
}
function setMsg(id, text, type) {
const el = document.getElementById(id);
el.textContent = text;
el.className = 'msg ' + (type || '');
}
async function apiFetch(url, opts) {
const r = await fetch(url, opts);
const json = await r.json();
if (!r.ok) throw new Error(json.detail || 'Server error');
return json;
}
// ---------------------------------------------------------------------------
// 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;
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', {
@ -94,26 +72,82 @@ async function searchMembers() {
try {
const members = await apiFetch(url);
renderMemberTable(members);
} catch (e) {
console.error(e);
}
} catch (e) { console.error(e); }
}
function renderMemberTable(members) {
const tbody = document.querySelector('#memberTable tbody');
if (!members.length) { tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;color:#888">No members found</td></tr>'; return; }
if (!members.length) {
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;color:#888">No members found</td></tr>';
return;
}
tbody.innerHTML = members.map(m => `
<tr>
<td>${esc(m.member_number)}</td>
<td>${esc(m.name)}</td>
<td class="num ${balanceClass(m.balance)}">${esc(m.balance_display)}</td>
<td>${m.created_at ? m.created_at.slice(0,10) : ''}</td>
<td>
<a href="/members/${m.id}/statement" target="_blank" class="btn" style="padding:4px 10px;font-size:.82rem">Statement</a>
<td>${m.created_at ? m.created_at.slice(0, 10) : ''}</td>
<td class="row-actions">
<a href="/members/${m.id}/statement" target="_blank" class="btn row-btn">Statement</a>
<button class="btn row-btn" onclick="openEditModal(${m.id},'${esc(m.name)}','${esc(m.member_number)}')">Edit</button>
${m.balance === 0
? `<button class="btn btn-danger row-btn" onclick="deleteMember(${m.id},'${esc(m.name)}')">Delete</button>`
: ''}
</td>
</tr>`).join('');
}
// ---------------------------------------------------------------------------
// Edit member
// ---------------------------------------------------------------------------
function openEditModal(id, name, number) {
editMemberId = id;
document.getElementById('edit-number').value = number;
document.getElementById('edit-name').value = name;
document.getElementById('edit-pin').value = '';
setMsg('editMsg', '', '');
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 number = document.getElementById('edit-number').value.trim();
const name = document.getElementById('edit-name').value.trim();
const pin = document.getElementById('edit-pin').value;
const body = { member_number: number, name };
if (pin) body.pin = pin;
try {
await apiFetch(`/members/${editMemberId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
closeEditModal();
searchMembers();
} catch (e) {
setMsg('editMsg', e.message, 'err');
}
}
// ---------------------------------------------------------------------------
// Delete member
// ---------------------------------------------------------------------------
async function deleteMember(id, name) {
if (!confirm(`Delete member "${name}"?\n\nThis will permanently remove their account and transaction history.`)) return;
try {
await apiFetch(`/members/${id}`, { method: 'DELETE' });
searchMembers();
} catch (e) {
alert(e.message);
}
}
// ---------------------------------------------------------------------------
// Cashier view
// ---------------------------------------------------------------------------
@ -124,7 +158,7 @@ async function cashierSearchMembers() {
const members = await apiFetch(url);
const list = document.getElementById('cashierMemberList');
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 class="member-pick-name">${esc(m.name)}</div>
<div class="member-pick-sub">#${esc(m.member_number)}</div>
@ -147,27 +181,26 @@ function clearCashierSelection() {
cashierMember = null;
document.getElementById('cashierForm').classList.add('hidden');
document.getElementById('cashierAmount').value = '';
document.getElementById('cashierStaff').value = '';
document.getElementById('cashierNote').value = '';
document.getElementById('cashierNote').value = '';
setMsg('cashierMsg', '', '');
}
async function doTopup() {
if (!cashierMember) return;
const amount = parseInt(document.getElementById('cashierAmount').value, 10);
const staff = document.getElementById('cashierStaff').value.trim();
const note = document.getElementById('cashierNote').value.trim();
const staff = document.getElementById('cashierStaff').value;
const note = document.getElementById('cashierNote').value.trim();
if (!amount || isNaN(amount) || amount <= 0) { setMsg('cashierMsg', 'Enter a valid amount.', 'err'); return; }
if (!staff) { setMsg('cashierMsg', 'Staff name required.', 'err'); return; }
if (!staff) { setMsg('cashierMsg', 'Select a staff member.', 'err'); return; }
try {
const r = await apiFetch('/topup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ member_id: cashierMember.id, amount, staff_name: staff, note: note || null })
});
window.open(`/receipt/${r.entry_id}`, '_blank');
setMsg('cashierMsg', `Top-up complete. New balance: ${r.new_balance_display}`, 'ok');
clearCashierSelection();
searchMembers();
} catch (e) {
setMsg('cashierMsg', e.message, 'err');
}
@ -183,7 +216,7 @@ async function barSearchMembers() {
const members = await apiFetch(url);
const list = document.getElementById('barMemberList');
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 class="member-pick-name">${esc(m.name)}</div>
<div class="member-pick-sub">#${esc(m.member_number)}</div>
@ -208,9 +241,8 @@ function clearBarSelection() {
barMember = null;
document.getElementById('barForm').classList.add('hidden');
document.getElementById('barAmount').value = '';
document.getElementById('barPin').value = '';
document.getElementById('barStaff').value = '';
document.getElementById('barNote').value = '';
document.getElementById('barPin').value = '';
document.getElementById('barNote').value = '';
document.getElementById('barProductSearch').value = '';
document.getElementById('barProductResults').innerHTML = '';
setMsg('barMsg', '', '');
@ -225,9 +257,12 @@ async function barProductLookup() {
try {
const products = await apiFetch(`/products?q=${encodeURIComponent(q)}`);
const div = document.getElementById('barProductResults');
if (!products.length) { div.innerHTML = '<div style="color:#888;font-size:.88rem;padding:4px">No products found</div>'; return; }
if (!products.length) {
div.innerHTML = '<div style="color:#888;font-size:.88rem;padding:4px">No products found</div>';
return;
}
div.innerHTML = products.map(p => `
<div class="product-item" onclick="selectProduct(${p.price}, ${p.member_price || p.price}, '${esc(p.name)}${p.brand ? ' '+esc(p.brand) : ''}')">
<div class="product-item" onclick="selectProduct(${p.price},${p.member_price || p.price},'${esc(p.name)}${p.brand ? ' ' + esc(p.brand) : ''}')">
<div>
<strong>${esc(p.name)}</strong>${p.brand ? ` <span style="color:#888"> ${esc(p.brand)}</span>` : ''}
${p.search_tags ? `<div style="font-size:.78rem;color:#aaa">${esc(p.search_tags)}</div>` : ''}
@ -243,7 +278,7 @@ async function barProductLookup() {
function selectProduct(price, memberPrice, label) {
document.getElementById('barAmount').value = memberPrice;
document.getElementById('barNote').value = label;
document.getElementById('barNote').value = label;
document.getElementById('barProductResults').innerHTML = '';
document.getElementById('barProductSearch').value = '';
}
@ -251,35 +286,22 @@ function selectProduct(price, memberPrice, label) {
async function doCharge() {
if (!barMember) return;
const amount = parseInt(document.getElementById('barAmount').value, 10);
const pin = document.getElementById('barPin').value;
const staff = document.getElementById('barStaff').value.trim();
const note = document.getElementById('barNote').value.trim();
const pin = document.getElementById('barPin').value;
const staff = document.getElementById('barStaff').value;
const note = document.getElementById('barNote').value.trim();
if (!amount || isNaN(amount) || amount <= 0) { setMsg('barMsg', 'Enter a valid amount.', 'err'); return; }
if (!pin) { setMsg('barMsg', 'PIN required.', 'err'); return; }
if (!staff) { setMsg('barMsg', 'Staff name required.', 'err'); return; }
if (!pin) { setMsg('barMsg', 'PIN required.', 'err'); return; }
if (!staff) { setMsg('barMsg', 'Select a staff member.', 'err'); return; }
try {
const r = await apiFetch('/charge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ member_id: barMember.id, amount, pin, staff_name: staff, note: note || null })
});
window.open(`/receipt/${r.entry_id}`, '_blank');
setMsg('barMsg', `Charge complete. New balance: ${r.new_balance_display}`, 'ok');
clearBarSelection();
searchMembers();
} catch (e) {
setMsg('barMsg', e.message, 'err');
}
}
// ---------------------------------------------------------------------------
// XSS-safe escape
// ---------------------------------------------------------------------------
function esc(str) {
if (str == null) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}

View file

@ -17,6 +17,7 @@
<!-- ===================== MEMBERS VIEW ===================== -->
<div id="view-members" class="view">
<div class="panel">
<h2>Register New Member</h2>
<form id="registerForm">
@ -38,16 +39,29 @@
</div>
<div class="panel">
<h2>Member Search</h2>
<h2>Members</h2>
<div class="search-row">
<input type="text" id="memberSearch" placeholder="Search name or number…">
<button class="btn" onclick="searchMembers()">Search</button>
</div>
<table id="memberTable" class="data-table">
<thead><tr><th>#</th><th>Name</th><th>Balance</th><th>Joined</th><th></th></tr></thead>
<thead>
<tr><th>#</th><th>Name</th><th>Balance</th><th>Joined</th><th class="actions-col"></th></tr>
</thead>
<tbody></tbody>
</table>
</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>
<!-- ===================== CASHIER VIEW ===================== -->
@ -67,8 +81,8 @@
<input type="number" id="cashierAmount" placeholder="e.g. 1000" min="1" step="1">
</div>
<div class="form-row">
<label>Staff Name</label>
<input type="text" id="cashierStaff" placeholder="Your name">
<label>Staff</label>
<select id="cashierStaff"></select>
</div>
<div class="form-row">
<label>Note (optional)</label>
@ -94,7 +108,6 @@
<div id="barForm" class="hidden">
<div class="selected-member-box" id="barSelected"></div>
<!-- Product search -->
<div class="form-row">
<label>Product Search</label>
<input type="text" id="barProductSearch" placeholder="Search products…" oninput="barProductLookup()">
@ -110,8 +123,8 @@
<input type="password" id="barPin" placeholder="Member PIN" maxlength="20">
</div>
<div class="form-row">
<label>Staff Name</label>
<input type="text" id="barStaff" placeholder="Your name">
<label>Staff</label>
<select id="barStaff"></select>
</div>
<div class="form-row">
<label>Note (optional)</label>
@ -124,6 +137,33 @@
</div>
</div>
<!-- ===================== EDIT MODAL ===================== -->
<div id="editModal" class="modal-overlay hidden" onclick="if(event.target===this)closeEditModal()">
<div class="modal">
<h3>Edit Member</h3>
<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">
<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">
</div>
<div class="modal-actions">
<button type="submit" class="btn btn-primary">Save</button>
<button type="button" class="btn" onclick="closeEditModal()">Cancel</button>
</div>
</form>
<div id="editMsg" class="msg"></div>
</div>
</div>
<script src="/static/common.js"></script>
<script src="/static/app.js"></script>
</body>
</html>

View file

@ -189,6 +189,31 @@ nav {
}
.form-row select:focus { border-color: var(--primary); }
/* ---- Row action buttons ---- */
.row-actions { white-space: nowrap; }
.row-btn { padding: 4px 10px !important; font-size: .82rem !important; margin-right: 4px !important; }
/* ---- Edit modal ---- */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
}
.modal {
background: #fff;
border-radius: 10px;
padding: 28px 32px;
width: 420px;
max-width: calc(100vw - 32px);
box-shadow: 0 20px 60px rgba(0,0,0,.3);
}
.modal h3 { font-size: 1.1rem; margin-bottom: 18px; }
.modal-actions { display: flex; gap: 8px; margin-top: 18px; }
/* ---- Messages ---- */
.msg { margin-top: 12px; padding: 10px 14px; border-radius: 6px; font-size: .93rem; }
.msg:empty { display: none; }