Files
Learn_System/frontend/js/admin/sections/overview.js
T
Maxim Dolgolyov 41acbdd0d0 feat(admin): phase 3 — dashboard #overview landing
GET /api/admin/overview returns 24h digest (~0.08ms/call).

- adminController.getOverview: 7 prepared statements (users 24h, sessions 24h, active users, classes count, failed sessions, banned this week, top-5 sessions)

- new section frontend/js/admin/sections/overview.js (~205L): bento-grid cards, alerts (only when >0), top-5 table, quick-links

- nav-item + tab-pane reordered: #overview is now default; #stats remains routable

Auth: admin-only (inside requireRole('admin') block, sibling of /stats).

Backward compat: all 13 existing routes unchanged.

Known follow-ups (post-merge polish):

- activeClasses counts all (label could be 'Всего классов')

- failedSessions24h includes in_progress (could tighten to abandoned only)

- topSessions24h drops NULL-score completed rows

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 23:26:59 +03:00

209 lines
11 KiB
JavaScript

'use strict';
/* admin → overview (Phase 3 dashboard) — landing page "что требует внимания".
* Lazy-init via AdminSections.overview.init(); reloads via .reload().
*/
(function () {
'use strict';
let inited = false;
/* ── one-time CSS injection (overview-specific bento layout) ────────── */
function ensureOvStyles() {
if (document.getElementById('ov-style')) return;
const s = document.createElement('style');
s.id = 'ov-style';
s.textContent = `
.ov-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-bottom: 28px; }
.ov-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-lg); padding: 22px 20px; position: relative; overflow: hidden; }
.ov-card::before { content: ''; position: absolute; top: 0; left: 0; right: 0; height: 3px; background: var(--ov-top, var(--violet)); opacity: 0.7; }
.ov-card-icon { width: 38px; height: 38px; border-radius: 12px; display: flex; align-items: center; justify-content: center; margin-bottom: 12px; background: rgba(155,93,229,0.1); color: var(--violet); }
.ov-card-val { font-family: 'Unbounded', sans-serif; font-size: 1.9rem; font-weight: 800; line-height: 1.1; margin-bottom: 4px; }
.ov-card-label { font-size: 0.82rem; color: var(--text-3); font-weight: 600; }
.ov-card.warn { border-color: rgba(255,179,71,0.4); }
.ov-card.warn::before { background: var(--amber); }
.ov-card.warn .ov-card-icon { background: rgba(255,179,71,0.12); color: var(--amber); }
.ov-card.danger { border-color: rgba(241,91,181,0.35); }
.ov-card.danger::before { background: var(--pink); }
.ov-card.danger .ov-card-icon { background: rgba(241,91,181,0.1); color: var(--pink); }
.ov-section-title { font-family: 'Unbounded', sans-serif; font-weight: 700; font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-3); margin: 28px 0 12px; }
.ov-banned-list { display: flex; flex-direction: column; gap: 6px; }
.ov-banned-row { display: flex; align-items: center; gap: 10px; padding: 10px 14px; background: rgba(241,91,181,0.06); border: 1px solid rgba(241,91,181,0.18); border-radius: 10px; font-size: 0.86rem; }
.ov-banned-row .ov-bn-name { font-weight: 600; }
.ov-banned-row .ov-bn-email { color: var(--text-3); font-size: 0.78rem; }
.ov-banned-row .ov-bn-date { margin-left: auto; color: var(--text-3); font-size: 0.76rem; }
.ov-top-table { width: 100%; border-collapse: collapse; }
.ov-top-table th { text-align: left; font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-3); font-weight: 700; padding: 8px 10px; border-bottom: 1px solid var(--border); }
.ov-top-table td { padding: 10px; font-size: 0.86rem; border-bottom: 1px solid var(--border); }
.ov-top-table tr:last-child td { border-bottom: none; }
.ov-pct { font-family: 'Unbounded', sans-serif; font-weight: 700; }
.ov-pct.hi { color: var(--green); }
.ov-pct.mid { color: var(--amber); }
.ov-pct.lo { color: var(--pink); }
.ov-quick-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; }
.ov-quick-btn { display: flex; align-items: center; gap: 10px; padding: 14px 16px; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; cursor: pointer; font-family: inherit; font-size: 0.88rem; font-weight: 600; color: var(--text); text-align: left; transition: background .12s, border-color .12s, transform .12s; }
.ov-quick-btn:hover { background: rgba(155,93,229,0.06); border-color: rgba(155,93,229,0.3); color: var(--violet); transform: translateY(-1px); }
.ov-quick-btn svg { width: 16px; height: 16px; flex-shrink: 0; }
.ov-empty { padding: 18px; text-align: center; color: var(--text-3); font-size: 0.85rem; }
`;
document.head.appendChild(s);
}
function pctClassNum(p) {
if (p === null || p === undefined) return '';
return p >= 75 ? 'hi' : p >= 50 ? 'mid' : 'lo';
}
function fmtNum(n) {
return (n === 0 || n === null || n === undefined) ? '—' : String(n);
}
function fmtBannedDate(s) {
if (!s) return '';
try {
const d = new Date(s.replace(' ', 'T') + 'Z');
return d.toLocaleDateString('ru', { day: 'numeric', month: 'short' });
} catch { return ''; }
}
function fmtFinished(s) {
if (!s) return '—';
try {
const d = new Date(s.replace(' ', 'T') + 'Z');
return d.toLocaleString('ru', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' });
} catch { return s; }
}
function navigateTo(hash) {
if (window.AdminRouter) AdminRouter.navigate(hash);
else window.location.hash = hash;
}
function render(data) {
const el = document.getElementById('overview-content');
if (!el) return;
ensureOvStyles();
const e = LS.esc;
const failedCls = data.failedSessions24h > 0 ? 'warn' : '';
const bannedCount = Array.isArray(data.bannedThisWeek) ? data.bannedThisWeek.length : 0;
const top = Array.isArray(data.topSessions24h) ? data.topSessions24h : [];
let alertsHtml = '';
if (bannedCount > 0 || data.failedSessions24h > 0) {
const banned = bannedCount > 0 ? `
<div class="ov-card danger" style="grid-column: span 2; padding-bottom: 14px">
<div class="ov-card-icon"><i data-lucide="user-x" style="width:18px;height:18px"></i></div>
<div class="ov-card-val">${bannedCount}</div>
<div class="ov-card-label" style="margin-bottom: 10px">Заблокированы за неделю</div>
<div class="ov-banned-list">
${data.bannedThisWeek.map(u => `
<div class="ov-banned-row">
<span class="ov-bn-name">${e(u.name || '—')}</span>
<span class="ov-bn-email">${e(u.email || '')}</span>
<span class="ov-bn-date">${fmtBannedDate(u.banned_at)}</span>
</div>
`).join('')}
</div>
</div>` : '';
const failed = data.failedSessions24h > 0 ? `
<div class="ov-card ${failedCls}">
<div class="ov-card-icon"><i data-lucide="alert-triangle" style="width:18px;height:18px"></i></div>
<div class="ov-card-val">${data.failedSessions24h}</div>
<div class="ov-card-label">Незавершённых сессий за 24ч</div>
</div>` : '';
alertsHtml = `
<div class="ov-section-title">Требует внимания</div>
<div class="ov-grid">${banned}${failed}</div>`;
}
const topRowsHtml = top.length ? `
<table class="ov-top-table">
<thead><tr><th>Ученик</th><th>Предмет</th><th>Счёт</th><th>%</th><th>Завершён</th></tr></thead>
<tbody>
${top.map(s => `
<tr>
<td>${e(s.user_name || '—')}</td>
<td>${e(s.subject_name || '—')}</td>
<td>${s.score ?? 0} / ${s.total ?? 0}</td>
<td><span class="ov-pct ${pctClassNum(s.percent)}">${s.percent ?? '—'}%</span></td>
<td style="color:var(--text-3);font-size:0.8rem">${fmtFinished(s.finished_at)}</td>
</tr>
`).join('')}
</tbody>
</table>` : '<div class="ov-empty">Нет завершённых сессий за последние 24 часа</div>';
el.innerHTML = `
<div class="ov-section-title">Активность за 24 часа</div>
<div class="ov-grid">
<div class="ov-card" style="--ov-top:var(--violet)">
<div class="ov-card-icon"><i data-lucide="user-plus" style="width:18px;height:18px"></i></div>
<div class="ov-card-val" style="color:var(--violet)">${fmtNum(data.newUsers24h)}</div>
<div class="ov-card-label">Новых регистраций</div>
</div>
<div class="ov-card" style="--ov-top:var(--cyan)">
<div class="ov-card-icon" style="background:rgba(6,214,224,0.1);color:var(--cyan)"><i data-lucide="play-circle" style="width:18px;height:18px"></i></div>
<div class="ov-card-val" style="color:var(--cyan)">${fmtNum(data.newSessions24h)}</div>
<div class="ov-card-label">Сессий запущено</div>
</div>
<div class="ov-card" style="--ov-top:var(--green)">
<div class="ov-card-icon" style="background:rgba(6,214,100,0.1);color:var(--green)"><i data-lucide="activity" style="width:18px;height:18px"></i></div>
<div class="ov-card-val" style="color:var(--green)">${fmtNum(data.activeUsers24h)}</div>
<div class="ov-card-label">Активных юзеров</div>
</div>
<div class="ov-card" style="--ov-top:var(--amber)">
<div class="ov-card-icon" style="background:rgba(255,179,71,0.12);color:var(--amber)"><i data-lucide="users" style="width:18px;height:18px"></i></div>
<div class="ov-card-val" style="color:var(--amber)">${fmtNum(data.activeClasses)}</div>
<div class="ov-card-label">Активных классов</div>
</div>
</div>
${alertsHtml}
<div class="ov-section-title">Топ-5 сессий за день</div>
${topRowsHtml}
<div class="ov-section-title">Быстрый переход</div>
<div class="ov-quick-grid">
<button class="ov-quick-btn" data-go="#users">
<i data-lucide="users"></i> Все пользователи
</button>
<button class="ov-quick-btn" data-go="#sessions">
<i data-lucide="clock"></i> Все сессии
</button>
<button class="ov-quick-btn" data-go="#tests">
<i data-lucide="clipboard-list"></i> Создать тест
</button>
<button class="ov-quick-btn" data-go="#sublog">
<i data-lucide="file-text"></i> Audit log
</button>
</div>
`;
// Wire quick-links via event delegation
el.querySelectorAll('.ov-quick-btn[data-go]').forEach(btn => {
btn.addEventListener('click', () => navigateTo(btn.dataset.go));
});
if (window.lucide) lucide.createIcons({ nodes: [el] });
}
async function load() {
const el = document.getElementById('overview-content');
if (!el) return;
LS.state.loading(el, 'Загружаю обзор…');
try {
const data = await LS.adminGetOverview();
render(data);
} catch (e) {
LS.state.error(el, e, () => load());
}
}
window.AdminSections = window.AdminSections || {};
window.AdminSections.overview = {
init: async () => { if (inited) return; inited = true; await load(); },
reload: load,
};
})();