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:
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 | ⬜ | ⬜ | ⬜ |
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user