feat(admin): per-player stats drill-down from admin panel
This commit is contained in:
106
index.html
106
index.html
@@ -1228,20 +1228,14 @@ const GROUP_LABELS = {
|
||||
all: '🗺️ Totes',
|
||||
};
|
||||
|
||||
async function loadStats() {
|
||||
if (offlineMode || !currentPlayer) {
|
||||
document.getElementById('stats-content').innerHTML =
|
||||
'<div class="card" style="text-align:center;color:#aaa;">Estadístiques no disponibles en mode sense connexió.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await apiGet(`/api/players/${currentPlayer.id}/stats`);
|
||||
if (!data) {
|
||||
document.getElementById('stats-content').innerHTML =
|
||||
'<div class="card" style="text-align:center;color:var(--red);">Error en carregar les estadístiques.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure renderer: builds and returns the full stats HTML for a player.
|
||||
* Receives the API payload directly so it can be reused by loadStats()
|
||||
* and showAdminPlayerDetail() without coupling to fetch logic.
|
||||
* @param {Object} data Response from /api/players/:id/stats or /api/admin/player/:id/stats
|
||||
* @returns {string} HTML string ready to be injected into a container
|
||||
*/
|
||||
function renderPlayerStats(data) {
|
||||
const ls = data.levelStats || {};
|
||||
const streak = data.currentStreak || 0;
|
||||
const totalS = data.totalSeconds || 0;
|
||||
@@ -1320,7 +1314,7 @@ async function loadStats() {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
document.getElementById('stats-content').innerHTML = `
|
||||
return `
|
||||
<div class="stats-hero">
|
||||
<span class="stats-avatar">${data.player.avatar}</span>
|
||||
<div class="stats-name">${data.player.name}</div>
|
||||
@@ -1358,6 +1352,23 @@ async function loadStats() {
|
||||
</div>` : ''}`;
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
if (offlineMode || !currentPlayer) {
|
||||
document.getElementById('stats-content').innerHTML =
|
||||
'<div class="card" style="text-align:center;color:#aaa;">Estadístiques no disponibles en mode sense connexió.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await apiGet(`/api/players/${currentPlayer.id}/stats`);
|
||||
if (!data) {
|
||||
document.getElementById('stats-content').innerHTML =
|
||||
'<div class="card" style="text-align:center;color:var(--red);">Error en carregar les estadístiques.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('stats-content').innerHTML = renderPlayerStats(data);
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════
|
||||
ADMIN — GROUP FILTER CONFIG
|
||||
══════════════════════════════════════════════════════ */
|
||||
@@ -1470,12 +1481,19 @@ async function loadAdmin() {
|
||||
const lastSeen = p.lastActivity
|
||||
? new Date(p.lastActivity).toLocaleDateString('ca-ES')
|
||||
: '—';
|
||||
// Escape single quotes in name/avatar for safe inline onclick attribute
|
||||
const safeName = p.name.replace(/'/g, "\\'");
|
||||
const safeAvatar = p.avatar.replace(/'/g, "\\'");
|
||||
return `<tr>
|
||||
<td><span style="font-size:1.2rem">${p.avatar}</span> <strong>${p.name}</strong></td>
|
||||
<td>${p.totalSessions}</td>
|
||||
<td>⭐ ${p.totalStars}</td>
|
||||
${cells}
|
||||
<td style="font-size:.75rem;color:#aaa;">${lastSeen}</td>
|
||||
<td><button onclick="showAdminPlayerDetail(${p.id}, '${safeName}', '${safeAvatar}')"
|
||||
style="background:var(--orange);color:#fff;border:none;border-radius:8px;padding:4px 10px;cursor:pointer;font-size:.8rem;">
|
||||
📊 Detall
|
||||
</button></td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
@@ -1486,15 +1504,51 @@ async function loadAdmin() {
|
||||
<thead><tr>
|
||||
<th>Jugador</th><th>Sessions</th><th>⭐</th>
|
||||
<th>👀 N1</th><th>🧩 N2</th><th>🔗 N3</th><th>✏️ N4</th><th>🗺️ N5</th><th>🔮 N6</th>
|
||||
<th>Darrera activitat</th>
|
||||
<th>Darrera activitat</th><th>Detall</th>
|
||||
</tr></thead>
|
||||
<tbody>${rows || '<tr><td colspan="10" style="color:#aaa;text-align:center;padding:20px;">Sense jugadors</td></tr>'}</tbody>
|
||||
<tbody>${rows || '<tr><td colspan="11" style="color:#aaa;text-align:center;padding:20px;">Sense jugadors</td></tr>'}</tbody>
|
||||
</table>
|
||||
</div>`;
|
||||
// Restore chip active state after render
|
||||
syncChips();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and display full stats for a specific player inside the admin detail overlay.
|
||||
* Reuses renderPlayerStats() so the layout is identical to the player-facing stats screen.
|
||||
* @param {number} id Player id
|
||||
* @param {string} name Player display name (for fallback if API fails)
|
||||
* @param {string} avatar Player avatar emoji
|
||||
*/
|
||||
async function showAdminPlayerDetail(id, name, avatar) {
|
||||
const pin = document.getElementById('admin-pin').value;
|
||||
const overlay = document.getElementById('admin-detail-overlay');
|
||||
const body = document.getElementById('admin-detail-body');
|
||||
|
||||
// Show overlay immediately with a loading indicator
|
||||
body.innerHTML = `<div style="text-align:center;padding:40px;color:#aaa;">Carregant…</div>`;
|
||||
overlay.style.display = 'flex';
|
||||
|
||||
let data;
|
||||
try {
|
||||
const r = await fetch(`/api/admin/player/${id}/stats`, {
|
||||
headers: { 'x-admin-pin': pin },
|
||||
});
|
||||
if (!r.ok) {
|
||||
const err = await r.json().catch(() => ({}));
|
||||
body.innerHTML = `<div style="color:var(--red);padding:24px;text-align:center;">
|
||||
Error: ${err.error || r.statusText}</div>`;
|
||||
return;
|
||||
}
|
||||
data = await r.json();
|
||||
} catch (e) {
|
||||
body.innerHTML = `<div style="color:var(--red);padding:24px;text-align:center;">Error de connexió</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
body.innerHTML = renderPlayerStats(data);
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════
|
||||
FEEDBACK & SOUNDS
|
||||
══════════════════════════════════════════════════════ */
|
||||
@@ -2293,5 +2347,23 @@ function _updatePuzzleHUD() {
|
||||
puzzleWrong > 0 ? `❌ ${puzzleWrong} errors` : '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- ── Admin player detail overlay ──────────────────────────────────────── -->
|
||||
<div id="admin-detail-overlay"
|
||||
style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.72);
|
||||
z-index:9999;align-items:flex-start;justify-content:center;
|
||||
padding:16px;overflow-y:auto;">
|
||||
<div style="background:#fff;border-radius:16px;max-width:700px;width:100%;
|
||||
margin:auto;position:relative;padding:24px 20px 28px;">
|
||||
<!-- Close button -->
|
||||
<button onclick="document.getElementById('admin-detail-overlay').style.display='none'"
|
||||
aria-label="Tanca"
|
||||
style="position:absolute;top:12px;right:12px;background:#eee;border:none;
|
||||
border-radius:50%;width:32px;height:32px;font-size:1.1rem;
|
||||
cursor:pointer;line-height:32px;text-align:center;">✕</button>
|
||||
<div id="admin-detail-body"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user