Общая статистика
По предметам
@@ -1983,6 +1991,7 @@
+
diff --git a/frontend/js/admin/admin.js b/frontend/js/admin/admin.js
index 1445d74..64737f4 100644
--- a/frontend/js/admin/admin.js
+++ b/frontend/js/admin/admin.js
@@ -52,6 +52,7 @@
/* ─── Tabs → section bridge ─── */
// Routes that map 1:1 to a section module (Phase 2-extracted).
const ROUTE_TO_SECTION = {
+ overview: 'overview',
stats: 'stats',
questions: 'questions',
tests: 'tests',
@@ -660,8 +661,8 @@
window.avatarReject = avatarReject;
/* ─── init ─── */
- // Initial #stats tab is .active in markup — section module will lazy-load on first switchTab.
- AdminSections.stats.init();
+ // Initial #overview tab is .active in markup — section module will lazy-load on first switchTab.
+ AdminSections.overview.init();
loadAvatarRequests(); // load badge count on page open
if (window.lucide) lucide.createIcons();
@@ -670,19 +671,19 @@
if (!window.AdminRouter) return;
function activate(route) {
- const name = route || 'stats';
+ const name = route || 'overview';
const btn = document.querySelector('.admin-nav-item[data-tab="' + name + '"]');
if (!btn) {
console.warn('AdminRouter: unknown route', name);
- AdminRouter.navigate('#stats', { replace: true, silent: true });
- const fallback = document.querySelector('.admin-nav-item[data-tab="stats"]');
+ AdminRouter.navigate('#overview', { replace: true, silent: true });
+ const fallback = document.querySelector('.admin-nav-item[data-tab="overview"]');
if (fallback) switchTab(fallback, { fromRouter: true });
return;
}
if (btn.classList.contains('locked')) {
LS.toast('Этот раздел доступен только администраторам', 'warn');
- AdminRouter.navigate('#stats', { replace: true, silent: true });
- const fallback = document.querySelector('.admin-nav-item[data-tab="stats"]');
+ AdminRouter.navigate('#overview', { replace: true, silent: true });
+ const fallback = document.querySelector('.admin-nav-item[data-tab="overview"]');
if (fallback) switchTab(fallback, { fromRouter: true });
return;
}
@@ -691,11 +692,11 @@
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();
if (!initial.route) {
- AdminRouter.navigate('#stats', { replace: true, silent: true });
- } else if (initial.route !== 'stats') {
+ AdminRouter.navigate('#overview', { replace: true, silent: true });
+ } else if (initial.route !== 'overview') {
activate(initial.route);
}
})();
diff --git a/frontend/js/admin/sections/overview.js b/frontend/js/admin/sections/overview.js
new file mode 100644
index 0000000..bf15825
--- /dev/null
+++ b/frontend/js/admin/sections/overview.js
@@ -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 ? `
+
+
+
${bannedCount}
+
Заблокированы за неделю
+
+ ${data.bannedThisWeek.map(u => `
+
+ ${e(u.name || '—')}
+ ${e(u.email || '')}
+ ${fmtBannedDate(u.banned_at)}
+
+ `).join('')}
+
+
` : '';
+
+ const failed = data.failedSessions24h > 0 ? `
+
+
+
${data.failedSessions24h}
+
Незавершённых сессий за 24ч
+
` : '';
+
+ alertsHtml = `
+
Требует внимания
+
${banned}${failed}
`;
+ }
+
+ const topRowsHtml = top.length ? `
+
+ | Ученик | Предмет | Счёт | % | Завершён |
+
+ ${top.map(s => `
+
+ | ${e(s.user_name || '—')} |
+ ${e(s.subject_name || '—')} |
+ ${s.score ?? 0} / ${s.total ?? 0} |
+ ${s.percent ?? '—'}% |
+ ${fmtFinished(s.finished_at)} |
+
+ `).join('')}
+
+
` : '
Нет завершённых сессий за последние 24 часа
';
+
+ el.innerHTML = `
+
Активность за 24 часа
+
+
+
+
${fmtNum(data.newUsers24h)}
+
Новых регистраций
+
+
+
+
${fmtNum(data.newSessions24h)}
+
Сессий запущено
+
+
+
+
${fmtNum(data.activeUsers24h)}
+
Активных юзеров
+
+
+
+
${fmtNum(data.activeClasses)}
+
Активных классов
+
+
+
+ ${alertsHtml}
+
+
Топ-5 сессий за день
+ ${topRowsHtml}
+
+
Быстрый переход
+
+
+
+
+
+
+ `;
+
+ // 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,
+ };
+})();
diff --git a/js/api.js b/js/api.js
index ba5d825..828155f 100644
--- a/js/api.js
+++ b/js/api.js
@@ -150,6 +150,7 @@ async function importQuestions(formData) {
/* ── admin ────────────────────────────────────────────────────────────── */
async function adminGetStats() { return req('GET', '/admin/stats'); }
+async function adminGetOverview() { return req('GET', '/admin/overview'); }
async function adminGetUsers(params = {}) {
const p = new URLSearchParams();
if (params.page) p.set('page', params.page);
@@ -939,7 +940,7 @@ window.LS = {
register, login, fetchMe, updateProfile,
getSubjects, updateSubject, getTopics,
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,
getClasses, createClass, getClassDetail, updateClass, deleteClass, kickMember, addClassMember, createAssignment, createDirectAssignment, updateAssignment, deleteAssignment,
regenerateInviteCode, classJournal,
diff --git a/plans/admin-redesign/CONTEXT.md b/plans/admin-redesign/CONTEXT.md
index 6054618..f4079fe 100644
--- a/plans/admin-redesign/CONTEXT.md
+++ b/plans/admin-redesign/CONTEXT.md
@@ -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 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
diff --git a/plans/admin-redesign/PLAN.md b/plans/admin-redesign/PLAN.md
index 8390235..67ea04e 100644
--- a/plans/admin-redesign/PLAN.md
+++ b/plans/admin-redesign/PLAN.md
@@ -37,7 +37,7 @@
- [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)
-- [ ] 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 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)
@@ -49,8 +49,8 @@
| Phase | Domain | Status | Review | Build | Committed |
|-------|--------|--------|--------|-------|-----------|
| Phase 1: Hash-router | frontend | ✅ Done | ✅ PASS w/ notes | ✅ | ✅ 8a7bed4 |
-| Phase 2: Split sections | frontend | ✅ Done | ⬜ pending | ✅ node --check | ✅ 92030b4 |
-| Phase 3: Dashboard | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
+| Phase 2: Split sections | frontend | ✅ Done | ✅ PASS (1 blocker fixed: fa67ad1) | ✅ node --check | ✅ 92030b4 + fa67ad1 |
+| Phase 3: Dashboard | fullstack | ✅ Done | ⬜ pending | ✅ node --check + queries verified | ⬜ |
| Phase 4: Palette | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
| Phase 5: Quick actions | frontend | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
| Phase 6: Deep pages | frontend | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
diff --git a/plans/admin-redesign/phase-3-dashboard.md b/plans/admin-redesign/phase-3-dashboard.md
index a37115e..fa9fcf5 100644
--- a/plans/admin-redesign/phase-3-dashboard.md
+++ b/plans/admin-redesign/phase-3-dashboard.md
@@ -1,6 +1,6 @@
# Phase 3: Dashboard #overview
-**Status:** ⬜ Not Started
+**Status:** ✅ Implemented (pending review)
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** fullstack
**Parallelizable with:** Phase 4, Phase 5
@@ -11,7 +11,7 @@
## Tasks
-- [ ] Backend: новый endpoint `GET /api/admin/overview`:
+- [x] Backend: новый endpoint `GET /api/admin/overview`:
```js
{
newUsers24h: number, // регистрации за 24ч
@@ -28,7 +28,7 @@
- Route: добавить в `backend/src/routes/admin.js`
- Auth: admin или teacher (как остальные admin/* — RBAC same)
- Performance: один запрос для каждого поля, простые COUNT/SELECT, без JOIN'ов где возможно
-- [ ] Frontend: новый section `frontend/js/admin/sections/overview.js`:
+- [x] Frontend: новый section `frontend/js/admin/sections/overview.js`:
- Использует структуру из Phase 2
- Загружает `/api/admin/overview`
- Рендерит карточки в `
` секции:
@@ -37,11 +37,11 @@
- **Топ-сессии** — таблица top-5 за день с click→drilldown
- **Quick links** — "Все пользователи", "Все сессии", "Создать тест" (deep-link в соответствующие routes)
- LS.skeleton при загрузке, LS.state.error на fail
-- [ ] HTML: добавить `
` в admin.html (перед остальными tab-pane)
-- [ ] Nav: добавить admin-nav-item для `overview` (icon: layout-dashboard / activity)
-- [ ] Регистрация в ROUTE_TO_SECTION (из Phase 2): `overview: 'overview'`
-- [ ] Сделать `#overview` дефолтным route'ом в router (из Phase 1) — если пустой hash, navigate to `#overview` вместо `#stats`
-- [ ] Старый `#stats` остаётся как доступный route (legacy backend stats), но не дефолтный
+- [x] HTML: добавить `
` в admin.html (перед остальными tab-pane)
+- [x] Nav: добавить admin-nav-item для `overview` (icon: layout-dashboard / activity)
+- [x] Регистрация в ROUTE_TO_SECTION (из Phase 2): `overview: 'overview'`
+- [x] Сделать `#overview` дефолтным route'ом в router (из Phase 1) — если пустой hash, navigate to `#overview` вместо `#stats`
+- [x] Старый `#stats` остаётся как доступный route (legacy backend stats), но не дефолтный
## Files to Modify/Create
@@ -107,6 +107,54 @@ Bento-grid из 4-6 карточек:
## Handoff to Next Phase
-
+**Endpoint shape (`GET /api/admin/overview`):**
+
+```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 `