Greg abb3f47a57 feat: add admin page for date and player management
- Add active flag to Player model with idempotent ALTER TABLE migration
- Filter inactive players from main attendance table and API
- New /admin page with date generation (preview + bulk add) and player management (add/deactivate/reactivate)
- New API endpoints: GET/POST /api/players, PATCH /api/players/<id>
- Add Admin nav link on main page

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 12:55:41 +01:00

457 lines
17 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link rel="icon" type="image/png" href="/static/favicon.png">
<link rel="icon" type="image/x-icon" href="/static/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Admin — Padel Nivelles</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 2em;
background: #222831;
color: #DFD0B8;
}
h1 { margin: 0 0 0.2em 0; }
h2 { color: #948979; margin: 0 0 0.7em 0; font-size: 1.1em; text-transform: uppercase; letter-spacing: 0.05em; }
.nav { display: flex; gap: 1em; margin-bottom: 1.5em; }
.nav a {
background: #393E46;
color: #DFD0B8;
border: 1px solid #948979;
padding: 0.5em 1.2em;
border-radius: 18px;
font-weight: bold;
font-size: 0.95em;
text-decoration: none;
transition: background 0.2s;
}
.nav a:hover { background: #948979; color: #222831; }
.section {
background: #393E46;
border-radius: 18px;
padding: 1.5em;
margin-bottom: 1.5em;
}
.warning {
background: #4a3a00;
border: 1px solid #948979;
border-radius: 10px;
padding: 0.7em 1em;
margin-bottom: 1em;
font-size: 0.95em;
}
label { display: block; margin-bottom: 0.3em; font-size: 0.9em; color: #948979; }
input[type="text"], input[type="number"], select {
background: #222831;
color: #DFD0B8;
border: 1px solid #948979;
border-radius: 10px;
padding: 0.4em 0.8em;
font-size: 1em;
margin-bottom: 0.8em;
}
input[type="text"]:focus, input[type="number"]:focus, select:focus {
outline: none;
border-color: #DFD0B8;
}
.form-row { display: flex; gap: 1em; flex-wrap: wrap; align-items: flex-end; }
.form-group { display: flex; flex-direction: column; }
.btn {
background: #948979;
color: #222831;
border: none;
padding: 0.5em 1.2em;
border-radius: 18px;
font-weight: bold;
font-size: 0.95em;
cursor: pointer;
transition: background 0.2s;
margin-bottom: 0.8em;
}
.btn:hover { background: #7c765c; }
.btn-secondary {
background: #393E46;
color: #DFD0B8;
border: 1px solid #948979;
}
.btn-secondary:hover { background: #948979; color: #222831; }
.btn-danger {
background: transparent;
color: #948979;
border: 1px solid #948979;
padding: 0.3em 0.8em;
border-radius: 12px;
font-size: 0.85em;
cursor: pointer;
transition: background 0.15s, color 0.15s;
margin-bottom: 0;
}
.btn-danger:hover { background: #948979; color: #222831; }
.btn-success {
background: transparent;
color: #6aab69;
border: 1px solid #6aab69;
padding: 0.3em 0.8em;
border-radius: 12px;
font-size: 0.85em;
cursor: pointer;
transition: background 0.15s, color 0.15s;
margin-bottom: 0;
}
.btn-success:hover { background: #6aab69; color: #222831; }
#preview-box {
background: #222831;
border-radius: 10px;
padding: 1em;
margin: 0.8em 0;
font-size: 0.9em;
display: none;
}
#preview-dates {
display: flex;
flex-wrap: wrap;
gap: 0.4em;
margin-bottom: 0.5em;
}
.date-chip {
background: #393E46;
border-radius: 8px;
padding: 0.2em 0.5em;
font-size: 0.85em;
}
.date-chip.new { border: 1px solid #6aab69; }
.date-chip.dup { border: 1px solid #555; color: #888; }
#preview-summary { color: #948979; margin-bottom: 0.5em; }
.player-list { list-style: none; padding: 0; margin: 0.5em 0 1em 0; }
.player-list li {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.4em 0;
border-bottom: 1px solid #22283133;
}
.player-list li:last-child { border-bottom: none; }
.player-name { font-size: 0.95em; }
.add-player-row { display: flex; gap: 0.7em; align-items: center; margin-top: 0.5em; flex-wrap: wrap; }
#add-player-name { margin-bottom: 0; }
.msg { margin-top: 0.5em; font-size: 0.9em; min-height: 1.2em; }
.msg.ok { color: #6aab69; }
.msg.err { color: #e07070; }
@media (max-width: 600px) {
body { margin: 0.7em; }
.form-row { flex-direction: column; }
}
</style>
</head>
<body>
<div style="margin-bottom: 1em;">
<h1>Padel Nivelles — Admin</h1>
</div>
<div class="nav">
<a href="/">&#8592; Attendance</a>
<a href="/reports">Reports</a>
</div>
<!-- DATE MANAGEMENT -->
<div class="section">
<h2>Date Management</h2>
<div class="form-row">
<div class="form-group">
<label for="start-date">Start date (DD/MM/YY)</label>
<input type="text" id="start-date" placeholder="02/04/26" maxlength="8" style="width: 110px;">
</div>
<div class="form-group">
<label for="weeks">Weeks</label>
<input type="number" id="weeks" value="52" min="1" max="104" style="width: 70px;">
</div>
<div class="form-group">
<label for="day-of-week">Day</label>
<select id="day-of-week">
<option value="0">Monday</option>
<option value="1">Tuesday</option>
<option value="2">Wednesday</option>
<option value="3" selected>Thursday</option>
<option value="4">Friday</option>
<option value="5">Saturday</option>
<option value="6">Sunday</option>
</select>
</div>
<div class="form-group" style="justify-content: flex-end;">
<button class="btn btn-secondary" onclick="previewDates()">Preview Dates</button>
</div>
</div>
<div id="preview-box">
<div id="preview-summary"></div>
<div id="preview-dates"></div>
<button class="btn" id="add-dates-btn" onclick="addDates()" style="display:none;"></button>
<div class="msg" id="dates-msg"></div>
</div>
</div>
<!-- PLAYER MANAGEMENT -->
<div class="section">
<h2>Player Management</h2>
<div class="warning">
&#9888; Player changes should only be made at season start (April). Mid-season changes may cause inconsistencies in historical data display.
</div>
<div>
<strong>Active Players</strong>
<ul class="player-list" id="active-players"></ul>
</div>
<div style="margin-top: 1em;">
<strong>Add New Player</strong>
<div class="add-player-row">
<input type="text" id="add-player-name" placeholder="Player name" maxlength="50" style="width: 180px;">
<button class="btn" onclick="addPlayer()">Add Player</button>
</div>
<div class="msg" id="add-player-msg"></div>
</div>
<div id="inactive-section" style="margin-top: 1.2em; display:none;">
<strong>Inactive Players</strong>
<ul class="player-list" id="inactive-players"></ul>
</div>
</div>
<script>
let existingDates = [];
let allPlayers = [];
let previewResult = null;
// Block XSS chars in text inputs
document.querySelectorAll('input[type="text"]').forEach(inp => {
inp.addEventListener('input', () => {
inp.value = inp.value.replace(/[<>"'`]/g, '');
});
});
async function init() {
const [dataRes, playersRes] = await Promise.all([
fetch('/api/data'),
fetch('/api/players')
]);
const data = await dataRes.json();
existingDates = data.dates || [];
allPlayers = await playersRes.json();
renderPlayers();
}
function renderPlayers() {
const active = allPlayers.filter(p => p.active);
const inactive = allPlayers.filter(p => !p.active);
const activeList = document.getElementById('active-players');
activeList.innerHTML = active.map(p => `
<li>
<span class="player-name">${escHtml(p.name)}</span>
<button class="btn-danger" onclick="togglePlayer(${p.id}, false)">Deactivate</button>
</li>
`).join('') || '<li style="color:#888;font-size:0.9em;">No active players</li>';
const inactiveSection = document.getElementById('inactive-section');
const inactiveList = document.getElementById('inactive-players');
if (inactive.length > 0) {
inactiveSection.style.display = '';
inactiveList.innerHTML = inactive.map(p => `
<li>
<span class="player-name" style="color:#888;">${escHtml(p.name)}</span>
<button class="btn-success" onclick="togglePlayer(${p.id}, true)">Reactivate</button>
</li>
`).join('');
} else {
inactiveSection.style.display = 'none';
}
}
function escHtml(str) {
return str.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function parseDate(str) {
// DD/MM/YY
const parts = str.split('/');
if (parts.length !== 3) return null;
const year = parseInt(parts[2]) + 2000;
return new Date(year, parseInt(parts[1]) - 1, parseInt(parts[0]));
}
function formatDate(d) {
const dd = String(d.getDate()).padStart(2, '0');
const mm = String(d.getMonth() + 1).padStart(2, '0');
const yy = String(d.getFullYear()).slice(-2);
return `${dd}/${mm}/${yy}`;
}
function generateDates(startStr, weeks, dayOfWeek) {
const start = parseDate(startStr);
if (!start || isNaN(start)) return null;
// Advance to the first occurrence of dayOfWeek on or after start
let current = new Date(start);
while (current.getDay() !== dayOfWeek) {
current.setDate(current.getDate() + 1);
}
const dates = [];
for (let i = 0; i < weeks; i++) {
dates.push(formatDate(new Date(current)));
current.setDate(current.getDate() + 7);
}
return dates;
}
function previewDates() {
const startStr = document.getElementById('start-date').value.trim();
const weeks = parseInt(document.getElementById('weeks').value);
const dayOfWeek = parseInt(document.getElementById('day-of-week').value);
const datesMsg = document.getElementById('dates-msg');
datesMsg.textContent = '';
if (!/^\d{2}\/\d{2}\/\d{2}$/.test(startStr)) {
datesMsg.className = 'msg err';
datesMsg.textContent = 'Invalid start date. Use DD/MM/YY format.';
return;
}
if (!weeks || weeks < 1 || weeks > 104) {
datesMsg.className = 'msg err';
datesMsg.textContent = 'Weeks must be between 1 and 104.';
return;
}
const generated = generateDates(startStr, weeks, dayOfWeek);
if (!generated) {
datesMsg.className = 'msg err';
datesMsg.textContent = 'Could not parse start date.';
return;
}
const existingSet = new Set(existingDates);
const newDates = generated.filter(d => !existingSet.has(d));
const dupDates = generated.filter(d => existingSet.has(d));
previewResult = { generated, newDates };
const summaryEl = document.getElementById('preview-summary');
summaryEl.textContent = `${generated.length} dates total: ${newDates.length} new, ${dupDates.length} already in database (skipped).`;
const datesEl = document.getElementById('preview-dates');
datesEl.innerHTML = generated.map(d => {
const isNew = !existingSet.has(d);
return `<span class="date-chip ${isNew ? 'new' : 'dup'}">${d}</span>`;
}).join('');
const addBtn = document.getElementById('add-dates-btn');
if (newDates.length > 0) {
addBtn.style.display = '';
addBtn.textContent = `Add ${newDates.length} New Date${newDates.length !== 1 ? 's' : ''} to Database`;
} else {
addBtn.style.display = 'none';
}
document.getElementById('preview-box').style.display = '';
}
async function addDates() {
if (!previewResult || previewResult.newDates.length === 0) return;
const datesMsg = document.getElementById('dates-msg');
datesMsg.textContent = '';
// Fetch current full data, merge new dates, POST back
const res = await fetch('/api/data');
const data = await res.json();
const mergedDates = [...(data.dates || []), ...previewResult.newDates];
const payload = {
players: data.players || [],
guest: data.guest || 'Guest',
dates: mergedDates,
attendance: data.attendance || {},
guestNames: data.guestNames || {}
};
const postRes = await fetch('/api/data', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload)
});
const result = await postRes.json();
if (result.status === 'success') {
datesMsg.className = 'msg ok';
datesMsg.textContent = `${previewResult.newDates.length} date${previewResult.newDates.length !== 1 ? 's' : ''} added successfully.`;
existingDates = mergedDates;
previewResult = null;
document.getElementById('add-dates-btn').style.display = 'none';
// Re-mark chips as duplicates
previewDates();
} else {
datesMsg.className = 'msg err';
datesMsg.textContent = result.message || 'Error adding dates.';
}
}
async function addPlayer() {
const nameInput = document.getElementById('add-player-name');
const msg = document.getElementById('add-player-msg');
const name = nameInput.value.trim();
msg.textContent = '';
if (!/^[a-zA-Z0-9 .\-]{1,50}$/.test(name)) {
msg.className = 'msg err';
msg.textContent = 'Invalid name. Use letters, numbers, spaces, hyphens, or periods (max 50 chars).';
return;
}
const res = await fetch('/api/players', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name})
});
const result = await res.json();
if (result.status === 'success') {
msg.className = 'msg ok';
msg.textContent = `Player "${name}" added.`;
nameInput.value = '';
await refreshPlayers();
} else {
msg.className = 'msg err';
msg.textContent = result.message || 'Error adding player.';
}
}
async function togglePlayer(id, active) {
const res = await fetch(`/api/players/${id}`, {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({active})
});
const result = await res.json();
if (result.status === 'success') {
await refreshPlayers();
}
}
async function refreshPlayers() {
const res = await fetch('/api/players');
allPlayers = await res.json();
renderPlayers();
}
// Allow Enter key on add-player-name
document.getElementById('add-player-name').addEventListener('keydown', e => {
if (e.key === 'Enter') addPlayer();
});
// Allow Enter key on start-date and weeks
document.getElementById('start-date').addEventListener('keydown', e => {
if (e.key === 'Enter') previewDates();
});
init();
</script>
</body>
</html>