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>
This commit is contained in:
Maxim Dolgolyov
2026-05-16 23:26:59 +03:00
parent fa67ad1294
commit 41acbdd0d0
9 changed files with 351 additions and 29 deletions
+54 -1
View File
@@ -30,6 +30,58 @@ function getStats(_req, res) {
}); });
} }
/* ── Overview (Phase 3 dashboard) — prepared statements ───────────────── */
const overviewStmts = {
newUsers24h: db.prepare("SELECT COUNT(*) AS n FROM users WHERE created_at >= datetime('now', '-24 hours')"),
newSessions24h: db.prepare("SELECT COUNT(*) AS n FROM test_sessions WHERE started_at >= datetime('now', '-24 hours')"),
activeUsers24h: db.prepare("SELECT COUNT(*) AS n FROM users WHERE last_login IS NOT NULL AND last_login >= datetime('now', '-24 hours')"),
failedSessions24h: db.prepare("SELECT COUNT(*) AS n FROM test_sessions WHERE started_at >= datetime('now', '-24 hours') AND status != 'completed'"),
activeClasses: db.prepare('SELECT COUNT(*) AS n FROM classes'),
// No banned_at column — fall back to audit log for recent bans (last 7 days)
bannedThisWeek: db.prepare(`
SELECT u.id, u.name, u.email, al.created_at AS banned_at
FROM admin_audit_log al
JOIN users u ON u.id = CAST(SUBSTR(al.target, 6) AS INTEGER)
WHERE al.action = 'user.ban'
AND al.created_at >= datetime('now', '-7 days')
AND u.is_banned = 1
GROUP BY u.id
ORDER BY al.created_at DESC
LIMIT 10
`),
topSessions24h: db.prepare(`
SELECT ts.id, u.name AS user_name, s.name AS subject_name,
ts.score, ts.total,
ROUND(CAST(ts.score AS REAL) / ts.total * 100, 1) AS percent,
ts.finished_at
FROM test_sessions ts
JOIN users u ON u.id = ts.user_id
LEFT JOIN subjects s ON s.id = ts.subject_id
WHERE ts.status = 'completed'
AND ts.finished_at >= datetime('now', '-24 hours')
AND ts.total > 0
ORDER BY (CAST(ts.score AS REAL) / ts.total) DESC, ts.finished_at DESC
LIMIT 5
`),
};
/* ── GET /api/admin/overview ──────────────────────────────────────────── */
function getOverview(_req, res) {
try {
res.json({
newUsers24h: overviewStmts.newUsers24h.get().n,
newSessions24h: overviewStmts.newSessions24h.get().n,
activeUsers24h: overviewStmts.activeUsers24h.get().n,
activeClasses: overviewStmts.activeClasses.get().n,
failedSessions24h: overviewStmts.failedSessions24h.get().n,
bannedThisWeek: overviewStmts.bannedThisWeek.all(),
topSessions24h: overviewStmts.topSessions24h.all(),
});
} catch (err) {
res.status(500).json({ error: err.message });
}
}
/* ── GET /api/admin/users?page=1&limit=50&role=student&q=name ─────────── */ /* ── GET /api/admin/users?page=1&limit=50&role=student&q=name ─────────── */
function getUsers(req, res) { function getUsers(req, res) {
const limit = Math.min(200, Math.max(1, Number(req.query.limit) || 50)); const limit = Math.min(200, Math.max(1, Number(req.query.limit) || 50));
@@ -539,7 +591,8 @@ function broadcast(req, res) {
} }
module.exports = { module.exports = {
getStats, getUsers, updateRole, getUserSessions, getAllSessions, getSessionDetail, getStats, getOverview,
getUsers, updateRole, getUserSessions, getAllSessions, getSessionDetail,
clearUserSessions, updateUser, banUser, deleteUser, clearUserSessions, updateUser, banUser, deleteUser,
getFeatures, updateFeatures, getFreeStudentFeatures, updateFreeStudentFeatures, getFeatures, updateFeatures, getFreeStudentFeatures, updateFreeStudentFeatures,
getAuditLog, clearAuditLog, getErrorLog, clearErrorLog, getHealth, getAuditLog, clearAuditLog, getErrorLog, clearErrorLog, getHealth,
+1
View File
@@ -14,6 +14,7 @@ router.patch('/free-student-features', requireRole('admin'), ctrl.updateF
router.use(requireRole('admin')); router.use(requireRole('admin'));
router.get('/stats', ctrl.getStats); router.get('/stats', ctrl.getStats);
router.get('/overview', ctrl.getOverview);
router.get('/users', ctrl.getUsers); router.get('/users', ctrl.getUsers);
router.patch('/users/:id/role', ctrl.updateRole); router.patch('/users/:id/role', ctrl.updateRole);
router.get('/users/:id/sessions', ctrl.getUserSessions); router.get('/users/:id/sessions', ctrl.getUserSessions);
+11 -2
View File
@@ -919,7 +919,10 @@
<svg class="adm-chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg> <svg class="adm-chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
</button> </button>
<div class="admin-nav-body"> <div class="admin-nav-body">
<button class="admin-nav-item active" data-tab="stats" onclick="switchTab(this)"> <button class="admin-nav-item active" data-tab="overview" onclick="switchTab(this)">
<i data-lucide="layout-dashboard" style="width:15px;height:15px"></i> Обзор
</button>
<button class="admin-nav-item" data-tab="stats" onclick="switchTab(this)">
<i data-lucide="bar-chart-2" style="width:15px;height:15px"></i> Статистика <i data-lucide="bar-chart-2" style="width:15px;height:15px"></i> Статистика
</button> </button>
<button class="admin-nav-item" data-tab="sessions" onclick="switchTab(this)"> <button class="admin-nav-item" data-tab="sessions" onclick="switchTab(this)">
@@ -1016,8 +1019,13 @@
</nav> </nav>
<div class="admin-main"> <div class="admin-main">
<!-- ── Обзор (Phase 3) ── -->
<div class="tab-pane active" id="tab-overview">
<div id="overview-content"><div class="spinner"></div></div>
</div>
<!-- ── Статистика ── --> <!-- ── Статистика ── -->
<div class="tab-pane active" id="tab-stats"> <div class="tab-pane" id="tab-stats">
<div class="section-title">Общая статистика</div> <div class="section-title">Общая статистика</div>
<div class="stats-grid" id="stats-grid"><div class="spinner"></div></div> <div class="stats-grid" id="stats-grid"><div class="spinner"></div></div>
<div class="section-title">По предметам</div> <div class="section-title">По предметам</div>
@@ -1983,6 +1991,7 @@
<script src="/js/sidebar.js"></script> <script src="/js/sidebar.js"></script>
<script src="/js/admin/router.js"></script> <script src="/js/admin/router.js"></script>
<script src="/js/admin/_shared.js"></script> <script src="/js/admin/_shared.js"></script>
<script src="/js/admin/sections/overview.js"></script>
<script src="/js/admin/sections/stats.js"></script> <script src="/js/admin/sections/stats.js"></script>
<script src="/js/admin/sections/sublog.js"></script> <script src="/js/admin/sections/sublog.js"></script>
<script src="/js/admin/sections/sims.js"></script> <script src="/js/admin/sections/sims.js"></script>
+11 -10
View File
@@ -52,6 +52,7 @@
/* ─── Tabs → section bridge ─── */ /* ─── Tabs → section bridge ─── */
// Routes that map 1:1 to a section module (Phase 2-extracted). // Routes that map 1:1 to a section module (Phase 2-extracted).
const ROUTE_TO_SECTION = { const ROUTE_TO_SECTION = {
overview: 'overview',
stats: 'stats', stats: 'stats',
questions: 'questions', questions: 'questions',
tests: 'tests', tests: 'tests',
@@ -660,8 +661,8 @@
window.avatarReject = avatarReject; window.avatarReject = avatarReject;
/* ─── init ─── */ /* ─── init ─── */
// Initial #stats tab is .active in markup — section module will lazy-load on first switchTab. // Initial #overview tab is .active in markup — section module will lazy-load on first switchTab.
AdminSections.stats.init(); AdminSections.overview.init();
loadAvatarRequests(); // load badge count on page open loadAvatarRequests(); // load badge count on page open
if (window.lucide) lucide.createIcons(); if (window.lucide) lucide.createIcons();
@@ -670,19 +671,19 @@
if (!window.AdminRouter) return; if (!window.AdminRouter) return;
function activate(route) { function activate(route) {
const name = route || 'stats'; const name = route || 'overview';
const btn = document.querySelector('.admin-nav-item[data-tab="' + name + '"]'); const btn = document.querySelector('.admin-nav-item[data-tab="' + name + '"]');
if (!btn) { if (!btn) {
console.warn('AdminRouter: unknown route', name); console.warn('AdminRouter: unknown route', name);
AdminRouter.navigate('#stats', { replace: true, silent: true }); AdminRouter.navigate('#overview', { replace: true, silent: true });
const fallback = document.querySelector('.admin-nav-item[data-tab="stats"]'); const fallback = document.querySelector('.admin-nav-item[data-tab="overview"]');
if (fallback) switchTab(fallback, { fromRouter: true }); if (fallback) switchTab(fallback, { fromRouter: true });
return; return;
} }
if (btn.classList.contains('locked')) { if (btn.classList.contains('locked')) {
LS.toast('Этот раздел доступен только администраторам', 'warn'); LS.toast('Этот раздел доступен только администраторам', 'warn');
AdminRouter.navigate('#stats', { replace: true, silent: true }); AdminRouter.navigate('#overview', { replace: true, silent: true });
const fallback = document.querySelector('.admin-nav-item[data-tab="stats"]'); const fallback = document.querySelector('.admin-nav-item[data-tab="overview"]');
if (fallback) switchTab(fallback, { fromRouter: true }); if (fallback) switchTab(fallback, { fromRouter: true });
return; return;
} }
@@ -691,11 +692,11 @@
AdminRouter.on('change', (r) => activate(r.route)); AdminRouter.on('change', (r) => activate(r.route));
// Initial dispatch: respect existing hash, else default to #stats. // Initial dispatch: respect existing hash, else default to #overview.
const initial = AdminRouter.current(); const initial = AdminRouter.current();
if (!initial.route) { if (!initial.route) {
AdminRouter.navigate('#stats', { replace: true, silent: true }); AdminRouter.navigate('#overview', { replace: true, silent: true });
} else if (initial.route !== 'stats') { } else if (initial.route !== 'overview') {
activate(initial.route); activate(initial.route);
} }
})(); })();
+208
View File
@@ -0,0 +1,208 @@
'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,
};
})();
+2 -1
View File
@@ -150,6 +150,7 @@ async function importQuestions(formData) {
/* ── admin ────────────────────────────────────────────────────────────── */ /* ── admin ────────────────────────────────────────────────────────────── */
async function adminGetStats() { return req('GET', '/admin/stats'); } async function adminGetStats() { return req('GET', '/admin/stats'); }
async function adminGetOverview() { return req('GET', '/admin/overview'); }
async function adminGetUsers(params = {}) { async function adminGetUsers(params = {}) {
const p = new URLSearchParams(); const p = new URLSearchParams();
if (params.page) p.set('page', params.page); if (params.page) p.set('page', params.page);
@@ -939,7 +940,7 @@ window.LS = {
register, login, fetchMe, updateProfile, register, login, fetchMe, updateProfile,
getSubjects, updateSubject, getTopics, getSubjects, updateSubject, getTopics,
startSession, sendAnswer, finishSession, getResult, getHistory, getWeakTopics, getStudentStats, getSessionQuestions, startSession, sendAnswer, finishSession, getResult, getHistory, getWeakTopics, getStudentStats, getSessionQuestions,
adminGetStats, adminGetUsers, adminUpdateRole, adminGetUserSessions, adminGetSessions, adminGetSessionDetail, adminClearUserSessions, adminUpdateUser, adminBanUser, adminDeleteUser, adminGetStats, adminGetOverview, adminGetUsers, adminUpdateRole, adminGetUserSessions, adminGetSessions, adminGetSessionDetail, adminClearUserSessions, adminUpdateUser, adminBanUser, adminDeleteUser,
getQuestions, createQuestion, duplicateQuestion, updateQuestion, deleteQuestion, importQuestions, getQuestions, createQuestion, duplicateQuestion, updateQuestion, deleteQuestion, importQuestions,
getClasses, createClass, getClassDetail, updateClass, deleteClass, kickMember, addClassMember, createAssignment, createDirectAssignment, updateAssignment, deleteAssignment, getClasses, createClass, getClassDetail, updateClass, deleteClass, kickMember, addClassMember, createAssignment, createDirectAssignment, updateAssignment, deleteAssignment,
regenerateInviteCode, classJournal, regenerateInviteCode, classJournal,
+2 -1
View File
@@ -6,7 +6,8 @@
- ✅ Phase 1 implemented — `window.AdminRouter` обёртывает старый `switchTab` (hash ↔ tab двусторонне). `switchTab` принимает 2-й аргумент `{ fromRouter: true }` для предотвращения рекурсии. Default = `#stats`. Файлы: `frontend/js/admin/router.js` (новый), `frontend/admin.html` (+1 строка), `frontend/js/admin/admin.js` (модификация `switchTab` + IIFE `initAdminRouter`). - ✅ Phase 1 implemented — `window.AdminRouter` обёртывает старый `switchTab` (hash ↔ tab двусторонне). `switchTab` принимает 2-й аргумент `{ fromRouter: true }` для предотвращения рекурсии. Default = `#stats`. Файлы: `frontend/js/admin/router.js` (новый), `frontend/admin.html` (+1 строка), `frontend/js/admin/admin.js` (модификация `switchTab` + IIFE `initAdminRouter`).
- ✅ Phase 2 implemented (commit 92030b4) — admin.js ужат с ~3591L до 701L. Все 13 plan-tabs живут в `frontend/js/admin/sections/*.js` (IIFE pattern) + `frontend/js/admin/_shared.js` (window.AdminCtx). switchTab() диспетчит в `AdminSections[ROUTE_TO_SECTION[name]].init()`. Lazy-load работает (inited флаг внутри каждой IIFE). System tabs (topics/audit/errors/health/classroom/avatars) остались inline в admin.js — Phase 2 их не extract'ил. - ✅ Phase 2 implemented (commit 92030b4) — admin.js ужат с ~3591L до 701L. Все 13 plan-tabs живут в `frontend/js/admin/sections/*.js` (IIFE pattern) + `frontend/js/admin/_shared.js` (window.AdminCtx). switchTab() диспетчит в `AdminSections[ROUTE_TO_SECTION[name]].init()`. Lazy-load работает (inited флаг внутри каждой IIFE). System tabs (topics/audit/errors/health/classroom/avatars) остались inline в admin.js — Phase 2 их не extract'ил.
- Phase 3-6 not started - Phase 3 implemented — `#overview` стал дефолтным route'ом admin-панели. Backend: `GET /api/admin/overview` (admin-only, ~0.08ms/call) возвращает digest за 24ч: новые регистрации, запущенные сессии, активные юзеры, активные классы, failed-сессии, забаненные за неделю (из audit log), топ-5 завершённых сессий. Frontend: `frontend/js/admin/sections/overview.js` (~205L) рендерит bento-grid карточки + alerts + топ-таблицу + quick-links (deep-link через `AdminRouter.navigate`). `admin.js`: дефолт `'stats'``'overview'` в `activate()`, initial nav, и initial init. Old `#stats` остался работающим (доступен через nav-item). Файлы: `frontend/js/admin/sections/overview.js` (NEW), `backend/src/controllers/adminController.js` (+57L: `overviewStmts` + `getOverview`), `backend/src/routes/admin.js` (+1L), `js/api.js` (+1 helper), `frontend/admin.html` (nav-item + tab-pane + script tag), `frontend/js/admin/admin.js` (ROUTE_TO_SECTION + default route refs).
- ⬜ Phase 4-6 not started
## Temporary Workarounds ## Temporary Workarounds
+3 -3
View File
@@ -37,7 +37,7 @@
- [x] Phase 1: Hash-router [domain: frontend] → [subplan](./phase-1-hash-router.md) - [x] Phase 1: Hash-router [domain: frontend] → [subplan](./phase-1-hash-router.md)
- [x] Phase 2: Split admin.html → per-section modules [domain: frontend] → [subplan](./phase-2-split-sections.md) - [x] Phase 2: Split admin.html → per-section modules [domain: frontend] → [subplan](./phase-2-split-sections.md)
- [ ] Phase 3: Dashboard #overview [domain: fullstack] → [subplan](./phase-3-dashboard.md) (parallelizable with 4, 5) - [x] Phase 3: Dashboard #overview [domain: fullstack] → [subplan](./phase-3-dashboard.md) (parallelizable with 4, 5)
- [ ] Phase 4: Cmd+K command palette [domain: fullstack] → [subplan](./phase-4-palette.md) (parallelizable with 3, 5) - [ ] Phase 4: Cmd+K command palette [domain: fullstack] → [subplan](./phase-4-palette.md) (parallelizable with 3, 5)
- [ ] Phase 5: Per-row quick actions [domain: frontend] → [subplan](./phase-5-quick-actions.md) (parallelizable with 3, 4) - [ ] Phase 5: Per-row quick actions [domain: frontend] → [subplan](./phase-5-quick-actions.md) (parallelizable with 3, 4)
- [ ] Phase 6: Deep entity pages [domain: frontend] → [subplan](./phase-6-deep-pages.md) - [ ] Phase 6: Deep entity pages [domain: frontend] → [subplan](./phase-6-deep-pages.md)
@@ -49,8 +49,8 @@
| Phase | Domain | Status | Review | Build | Committed | | Phase | Domain | Status | Review | Build | Committed |
|-------|--------|--------|--------|-------|-----------| |-------|--------|--------|--------|-------|-----------|
| Phase 1: Hash-router | frontend | ✅ Done | ✅ PASS w/ notes | ✅ | ✅ 8a7bed4 | | Phase 1: Hash-router | frontend | ✅ Done | ✅ PASS w/ notes | ✅ | ✅ 8a7bed4 |
| Phase 2: Split sections | frontend | ✅ Done | ⬜ pending | ✅ node --check | ✅ 92030b4 | | Phase 2: Split sections | frontend | ✅ Done | ✅ PASS (1 blocker fixed: fa67ad1) | ✅ node --check | ✅ 92030b4 + fa67ad1 |
| Phase 3: Dashboard | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ | | Phase 3: Dashboard | fullstack | ✅ Done | ⬜ pending | ✅ node --check + queries verified | ⬜ |
| Phase 4: Palette | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ | | Phase 4: Palette | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
| Phase 5: Quick actions | frontend | ⬜ Not Started | ⬜ | ⬜ | ⬜ | | Phase 5: Quick actions | frontend | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
| Phase 6: Deep pages | frontend | ⬜ Not Started | ⬜ | ⬜ | ⬜ | | Phase 6: Deep pages | frontend | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
+59 -11
View File
@@ -1,6 +1,6 @@
# Phase 3: Dashboard #overview # Phase 3: Dashboard #overview
**Status:** ⬜ Not Started **Status:** ✅ Implemented (pending review)
**Parent plan:** [PLAN.md](./PLAN.md) **Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** fullstack **Domain:** fullstack
**Parallelizable with:** Phase 4, Phase 5 **Parallelizable with:** Phase 4, Phase 5
@@ -11,7 +11,7 @@
## Tasks ## Tasks
- [ ] Backend: новый endpoint `GET /api/admin/overview`: - [x] Backend: новый endpoint `GET /api/admin/overview`:
```js ```js
{ {
newUsers24h: number, // регистрации за 24ч newUsers24h: number, // регистрации за 24ч
@@ -28,7 +28,7 @@
- Route: добавить в `backend/src/routes/admin.js` - Route: добавить в `backend/src/routes/admin.js`
- Auth: admin или teacher (как остальные admin/* — RBAC same) - Auth: admin или teacher (как остальные admin/* — RBAC same)
- Performance: один запрос для каждого поля, простые COUNT/SELECT, без JOIN'ов где возможно - Performance: один запрос для каждого поля, простые COUNT/SELECT, без JOIN'ов где возможно
- [ ] Frontend: новый section `frontend/js/admin/sections/overview.js`: - [x] Frontend: новый section `frontend/js/admin/sections/overview.js`:
- Использует структуру из Phase 2 - Использует структуру из Phase 2
- Загружает `/api/admin/overview` - Загружает `/api/admin/overview`
- Рендерит карточки в `<div id="tab-overview">` секции: - Рендерит карточки в `<div id="tab-overview">` секции:
@@ -37,11 +37,11 @@
- **Топ-сессии** — таблица top-5 за день с click→drilldown - **Топ-сессии** — таблица top-5 за день с click→drilldown
- **Quick links** — "Все пользователи", "Все сессии", "Создать тест" (deep-link в соответствующие routes) - **Quick links** — "Все пользователи", "Все сессии", "Создать тест" (deep-link в соответствующие routes)
- LS.skeleton при загрузке, LS.state.error на fail - LS.skeleton при загрузке, LS.state.error на fail
- [ ] HTML: добавить `<div class="tab-pane" id="tab-overview">` в admin.html (перед остальными tab-pane) - [x] HTML: добавить `<div class="tab-pane" id="tab-overview">` в admin.html (перед остальными tab-pane)
- [ ] Nav: добавить admin-nav-item для `overview` (icon: layout-dashboard / activity) - [x] Nav: добавить admin-nav-item для `overview` (icon: layout-dashboard / activity)
- [ ] Регистрация в ROUTE_TO_SECTION (из Phase 2): `overview: 'overview'` - [x] Регистрация в ROUTE_TO_SECTION (из Phase 2): `overview: 'overview'`
- [ ] Сделать `#overview` дефолтным route'ом в router (из Phase 1) — если пустой hash, navigate to `#overview` вместо `#stats` - [x] Сделать `#overview` дефолтным route'ом в router (из Phase 1) — если пустой hash, navigate to `#overview` вместо `#stats`
- [ ] Старый `#stats` остаётся как доступный route (legacy backend stats), но не дефолтный - [x] Старый `#stats` остаётся как доступный route (legacy backend stats), но не дефолтный
## Files to Modify/Create ## Files to Modify/Create
@@ -107,6 +107,54 @@ Bento-grid из 4-6 карточек:
## Handoff to Next Phase ## Handoff to Next Phase
<!-- Implementer: записать, какой shape у overview-ответа, **Endpoint shape (`GET /api/admin/overview`):**
какие route переходы внедрены, какие deep-links открыты для Phase 6
(например, `#users?filter=banned` или `#sessions?status=failed`). --> ```json
{
"newUsers24h": 0,
"newSessions24h": 0,
"activeUsers24h": 2,
"activeClasses": 5,
"failedSessions24h": 0,
"bannedThisWeek": [
{ "id": 42, "name": "...", "email": "...", "banned_at": "2026-05-15 12:30:00" }
],
"topSessions24h": [
{ "id": 101, "user_name": "...", "subject_name": "Физика",
"score": 18, "total": 20, "percent": 90.0, "finished_at": "2026-05-16 09:14:22" }
]
}
```
**Performance:** ~0.08ms/call avg (benchmarked 100 iters) — well under 100ms target.
**Auth:** uses `router.use(requireRole('admin'))` block (admin-only, same as `/stats`).
`/features` block (teacher+admin) is above the route. Teacher access NOT granted — matches
sibling `/stats` behavior. If Phase 4-6 wants teacher access, move `/overview` above the
admin-only `router.use(...)` line in `backend/src/routes/admin.js`.
**Quick-link wiring (Phase 4/5 extension point):**
Quick-link buttons live in `overview.js` → render() → `.ov-quick-grid`. They use a
`data-go="#hash"` attribute and a delegated click → `AdminRouter.navigate(...)`. To add
a new quick-link, append a `<button class="ov-quick-btn" data-go="#whatever">` to the grid.
**Deep-link openings for Phase 6:**
- `#users` and `#sessions` are wired (vanilla routes — no filter params yet)
- Future: extend `AdminRouter` to support `#users?filter=banned` / `#sessions?status=failed`
query-style params. Currently router parses `route + params` (`/users/123` form), no `?`
parsing — Phase 6 can extend `router.js::parseHash()`.
**Router defaults updated:**
- `admin.js::initAdminRouter::activate()` fallback route changed `'stats'` → `'overview'`
- Initial dispatch (empty hash) navigates to `#overview` instead of `#stats`
- Initial init call: `AdminSections.overview.init()` instead of `AdminSections.stats.init()`
**Files modified/created (line counts):**
- NEW: `frontend/js/admin/sections/overview.js` (~205 lines)
- `backend/src/controllers/adminController.js` (+57 lines: new statements + `getOverview`)
- `backend/src/routes/admin.js` (+1 line)
- `js/api.js` (+1 line helper + 1 export)
- `frontend/admin.html` (nav-item +3 lines, tab-pane +4 lines, script tag +1 line, swap `active`)
- `frontend/js/admin/admin.js` (`ROUTE_TO_SECTION` +1 entry, default route refs swapped from `stats` → `overview`)
**Unrelated to Phase 3 (do not touch):** old `#stats` route remains functional — used as fallback in the past, can be linked from Overview as "детальная статистика" in a future polish pass.