mirror of
https://github.com/kbenestad/ClubLedger.git
synced 2026-06-18 09:44:33 +00:00
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:
parent
34b3e88fe2
commit
6c155d00bb
4 changed files with 230 additions and 90 deletions
57
main.py
57
main.py
|
|
@ -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:
|
||||
|
|
|
|||
184
static/app.js
184
static/app.js
|
|
@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue