diff --git a/backend/src/controllers/adminController.js b/backend/src/controllers/adminController.js index 1c71967..18e290b 100644 --- a/backend/src/controllers/adminController.js +++ b/backend/src/controllers/adminController.js @@ -30,6 +30,103 @@ 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 }); + } +} + +/* ── Global search (Phase 4 command palette) — prepared statements ────── */ +const searchStmts = { + users: db.prepare(` + SELECT id, name, email, role + FROM users + WHERE name LIKE ? OR email LIKE ? + ORDER BY (CASE WHEN name LIKE ? THEN 0 ELSE 1 END), id DESC + LIMIT 5 + `), + tests: db.prepare(` + SELECT id, title AS name, subject_slug + FROM tests + WHERE title LIKE ? + ORDER BY id DESC + LIMIT 3 + `), + classes: db.prepare(` + SELECT id, name, invite_code AS code + FROM classes + WHERE name LIKE ? OR invite_code LIKE ? + ORDER BY id DESC + LIMIT 3 + `), +}; + +/* ── GET /api/admin/search?q=X ────────────────────────────────────────── */ +function globalSearch(req, res) { + const q = (req.query.q || '').trim(); + if (q.length < 2) { + return res.json({ users: [], tests: [], classes: [] }); + } + try { + const like = `%${q}%`; + const prefix = `${q}%`; + res.json({ + users: searchStmts.users.all(like, like, prefix), + tests: searchStmts.tests.all(like), + classes: searchStmts.classes.all(like, like), + }); + } catch (err) { + console.error('[admin.search]', err.message); + res.status(500).json({ error: 'Search failed' }); + } +} + /* ── GET /api/admin/users?page=1&limit=50&role=student&q=name ─────────── */ function getUsers(req, res) { const limit = Math.min(200, Math.max(1, Number(req.query.limit) || 50)); @@ -196,6 +293,33 @@ function getSessionDetail(req, res) { res.json(session); } +/* ── DELETE /api/admin/sessions/:id ──────────────────────────────────── */ +const _deleteSessionTx = db.transaction((sid) => { + // assignment_sessions references test_sessions with ON DELETE SET NULL, + // but we explicitly null it so the assignment slot stays usable. + db.prepare('UPDATE assignment_sessions SET session_id = NULL WHERE session_id = ?').run(sid); + // user_answers / session_questions cascade via ON DELETE CASCADE, + // but delete explicitly for visibility and to mirror clearUserSessions(). + db.prepare('DELETE FROM user_answers WHERE session_id = ?').run(sid); + db.prepare('DELETE FROM session_questions WHERE session_id = ?').run(sid); + db.prepare('DELETE FROM test_sessions WHERE id = ?').run(sid); +}); + +function deleteSession(req, res, next) { + const sid = Number(req.params.id); + if (!Number.isInteger(sid) || sid <= 0) + return res.status(400).json({ error: 'Invalid session id' }); + try { + const sess = db.prepare('SELECT id, user_id, mode FROM test_sessions WHERE id = ?').get(sid); + if (!sess) return res.status(404).json({ error: 'Session not found' }); + _deleteSessionTx(sid); + audit(req, 'session.delete', `session:${sid}`, `user:${sess.user_id} mode:${sess.mode}`); + res.json({ ok: true }); + } catch (err) { + next(err); + } +} + /* ── DELETE /api/admin/users/:id/sessions ────────────────────────────── */ function clearUserSessions(req, res, next) { const uid = Number(req.params.id); @@ -539,8 +663,9 @@ function broadcast(req, res) { } module.exports = { - getStats, getUsers, updateRole, getUserSessions, getAllSessions, getSessionDetail, - clearUserSessions, updateUser, banUser, deleteUser, + getStats, getOverview, globalSearch, + getUsers, updateRole, getUserSessions, getAllSessions, getSessionDetail, + clearUserSessions, deleteSession, updateUser, banUser, deleteUser, getFeatures, updateFeatures, getFreeStudentFeatures, updateFreeStudentFeatures, getAuditLog, clearAuditLog, getErrorLog, clearErrorLog, getHealth, getTopics, createTopic, updateTopic, deleteTopic, diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index 46874ad..3875227 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -14,6 +14,8 @@ router.patch('/free-student-features', requireRole('admin'), ctrl.updateF router.use(requireRole('admin')); router.get('/stats', ctrl.getStats); +router.get('/overview', ctrl.getOverview); +router.get('/search', ctrl.globalSearch); router.get('/users', ctrl.getUsers); router.patch('/users/:id/role', ctrl.updateRole); router.get('/users/:id/sessions', ctrl.getUserSessions); @@ -24,6 +26,7 @@ router.patch('/users/:id/ban', ctrl.banUser); router.delete('/users/:id', ctrl.deleteUser); router.get('/sessions', ctrl.getAllSessions); router.get('/sessions/:id', ctrl.getSessionDetail); +router.delete('/sessions/:id', ctrl.deleteSession); /* Audit log */ router.get('/audit-log', ctrl.getAuditLog); diff --git a/frontend/admin.html b/frontend/admin.html index 816e69b..4c55c25 100644 --- a/frontend/admin.html +++ b/frontend/admin.html @@ -159,12 +159,8 @@ .pct-mid { color: var(--amber); } .pct-lo { color: var(--pink); } - /* user panel */ - .user-panel { background: var(--surface); backdrop-filter: var(--blur); border: 1px solid var(--border); border-radius: var(--r-lg); padding: 32px; box-shadow: var(--shadow); display: none; } - .user-panel.visible { display: block; } - .user-panel-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 24px; } - .user-panel-name { font-family: 'Unbounded', sans-serif; font-size: 1.1rem; font-weight: 800; } - .user-panel-email { font-size: 0.92rem; color: var(--text-3); margin-top: 3px; } + /* Legacy .user-panel overlay was removed in Phase 6 — the deep page + (#users/:id) replaces it. .btn-close kept for use elsewhere if any. */ .btn-close { padding: 8px 18px; border: 1.5px solid var(--border-h); border-radius: var(--r-pill); background: transparent; font-family: 'Manrope', sans-serif; font-size: 0.88rem; font-weight: 600; color: var(--text-3); cursor: pointer; transition: all var(--tr); } .btn-close:hover { border-color: var(--pink); color: var(--pink); } .sess-list { display: flex; flex-direction: column; gap: 12px; } @@ -579,10 +575,6 @@ .q-modal-title { font-size: 0.9rem; margin-bottom: 20px; } .form-row-2, .form-row-3 { grid-template-columns: 1fr; } - /* User panel */ - .user-panel { padding: 18px 14px; } - .user-panel-header { flex-wrap: wrap; gap: 10px; } - /* Session drawer */ .sess-drawer-inner { padding: 16px 12px; } .drawer-header { gap: 10px; } @@ -919,7 +911,10 @@
- + - - - - - -
- -
История тестов
-
- + + + + +
+
+
+ + +
+
@@ -1981,6 +1977,26 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/js/admin/_shared.js b/frontend/js/admin/_shared.js new file mode 100644 index 0000000..be6ed2d --- /dev/null +++ b/frontend/js/admin/_shared.js @@ -0,0 +1,129 @@ +'use strict'; +/* Admin shared helpers — referenced by admin.js orchestrator + every section module. + * Exposed on window.AdminCtx (filled by admin.js after LS.initPage()) and + * on window directly for utility functions used by HTML onclicks. + */ +(function () { + 'use strict'; + + /* ─── Constants ─── */ + const MODES = { exam:'Экзамен', practice:'Тренировка', repeat:'Обычный', ct:'ЦТ/ЦЭ', topic:'По теме', random:'Случайный' }; + const DIFFS = { 1:'Лёгкий', 2:'Средний', 3:'Сложный' }; + const DIFF_LABELS = DIFFS; + const TYPE_LABELS = { single:'Один', multi:'Несколько', true_false:'Верно/Нет', short_answer:'Краткий', matching:'Сопоставление' }; + + /* ─── Generic formatters ─── */ + function pctClass(p) { return p === null ? '' : p >= 75 ? 'pct-hi' : p >= 50 ? 'pct-mid' : 'pct-lo'; } + function fmtDate(d) { return new Date(d).toLocaleDateString('ru', { day:'numeric', month:'short', year:'numeric' }); } + function fmtTime(sec) { + if (!sec || sec < 0) return '—'; + const m = Math.floor(sec / 60), s = sec % 60; + return m ? `${m} мин ${s} сек` : `${s} сек`; + } + function fmtDuration(sec) { + if (!sec || sec < 0) return '—'; + const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60; + if (h) return `${h}ч ${m}м`; + if (m) return `${m} мин ${s} сек`; + return `${s} сек`; + } + + /* ─── KaTeX rendering ─── */ + const KATEX_OPTS = { + delimiters: [ + { left: '\\(', right: '\\)', display: false }, + { left: '\\[', right: '\\]', display: true }, + ], + throwOnError: false, + }; + function renderMath(el) { + if (!el) return; + const run = () => { if (window.renderMathInElement) renderMathInElement(el, KATEX_OPTS); }; + if (window._katexReady) run(); else window._katexCb = run; + } + + /* ─── Question type badges (used by tests + subjects sections) ─── */ + function qTypeBadge(type) { + const MAP = { single:'Один', multi:'Несколько', true_false:'Верно/Нет', short_answer:'Ответ', matching:'Сопост.' }; + const CLR = { single:'rgba(155,93,229,0.12)', multi:'rgba(6,214,224,0.12)', true_false:'rgba(255,179,71,0.14)', short_answer:'rgba(6,214,100,0.12)', matching:'rgba(241,91,181,0.10)' }; + const TXT = { single:'var(--violet)', multi:'#05aab3', true_false:'var(--amber)', short_answer:'var(--green)', matching:'var(--pink)' }; + return `${MAP[type]||type}`; + } + + function qOptsPreview(q) { + if (q.type === 'short_answer') return q.correct_text ? `Ответ: ${esc(q.correct_text)}` : ''; + if (!q.options?.length) return ''; + const correct = q.options.filter(o => o.is_correct).map(o => esc(o.text)).join(', '); + return ` ${correct}`; + } + + /* ─── Pagination controls (users + future tables) ─── */ + function ensurePgnStyles() { + if (document.getElementById('pgn-bar-style')) return; + const s = document.createElement('style'); + s.id = 'pgn-bar-style'; + s.textContent = ` + .pgn-bar { display:flex; align-items:center; justify-content:space-between; gap:10px; padding:14px 4px 4px; font-size:0.85rem; color:var(--text-3); } + .pgn-info { font-weight:600; } + .pgn-ctrls { display:flex; align-items:center; gap:4px; } + .pgn-btn { min-width:32px; height:32px; padding:0 10px; border:1px solid var(--border); background:var(--surface); border-radius:8px; cursor:pointer; font-weight:600; font-family:inherit; font-size:0.85rem; color:var(--text-2); transition:background .12s, color .12s, border-color .12s; } + .pgn-btn:hover:not(:disabled) { background:rgba(155,93,229,.08); color:var(--violet); border-color:rgba(155,93,229,.3); } + .pgn-btn.active { background:var(--violet); color:#fff; border-color:var(--violet); } + .pgn-btn:disabled { opacity:.4; cursor:not-allowed; } + .pgn-ellip { padding:0 6px; color:var(--text-3); } + `; + document.head.appendChild(s); + } + + function renderPgnControls(elId, page, total, perPage, gotoFn) { + const bar = document.getElementById(elId); + if (!bar) return; + const pages = Math.max(1, Math.ceil(total / perPage)); + if (pages <= 1) { bar.style.display = 'none'; return; } + ensurePgnStyles(); + const from = (page - 1) * perPage + 1; + const to = Math.min(page * perPage, total); + const nums = new Set([1, pages, page, page - 1, page + 1, page - 2, page + 2]); + const sorted = [...nums].filter(n => n >= 1 && n <= pages).sort((a, b) => a - b); + const numHtml = sorted.map((n, i) => { + const prev = sorted[i - 1]; + const gap = prev && n - prev > 1 ? '' : ''; + return `${gap}`; + }).join(''); + bar.innerHTML = ` +
${from}–${to} из ${total}
+
+ + ${numHtml} + +
`; + bar.style.display = ''; + } + + /* ─── Export ─── */ + window.AdminCtx = window.AdminCtx || { + // filled by admin.js after LS.initPage(): + user: null, + isTeacher: false, + isAdmin: false, + // constants: + MODES, + DIFFS, + DIFF_LABELS, + TYPE_LABELS, + // formatters: + pctClass, + fmtDate, + fmtTime, + fmtDuration, + // rendering: + renderMath, + qTypeBadge, + qOptsPreview, + // pagination: + renderPgnControls, + ensurePgnStyles, + }; + + window.AdminSections = window.AdminSections || {}; +})(); diff --git a/frontend/js/admin/admin.js b/frontend/js/admin/admin.js index 5b68719..4877b50 100644 --- a/frontend/js/admin/admin.js +++ b/frontend/js/admin/admin.js @@ -1,3548 +1,737 @@ 'use strict'; -// admin.html — main script (extracted from inline ` в `` или перед admin.js +- `frontend/js/admin/admin.js` — модифицировать `switchTab` + добавить init-логику (~15-25L изменений) + +## Acceptance Criteria + +- F5 на `http://localhost:3000/admin#users` восстанавливает users-tab +- Browser back/forward переключают между табами (без полного reload) +- Клик по admin-nav-item обновляет URL (`#users` появляется в адресной строке) +- Клик по cross-tab handler типа `goAddQuestion('bio')` — старая логика работает, URL обновляется +- Unknown hash (например `#nonexistent`) → console.warn + fallback на `#stats`, нет crash +- `#users/123` парсится корректно (params=['123']), но пока никто его не использует — Phase 6 подключит + +## Notes + +### Почему hash-router, а не history.pushState + +Backend Express раздаёт admin.html по `/admin`. С `pushState` пришлось бы либо настраивать catch-all route на server-стороне (`/admin/*`), либо делать SPA-style роутинг. Hash-router работает out-of-the-box и не требует backend-изменений. Это критично для incremental-стратегии — мы не трогаем server в Phase 1. + +### Защита от рекурсии + +Сценарий: пользователь кликает на tab → switchTab вызывает navigate → navigate меняет hash → срабатывает hashchange → router emits 'change' → handler вызывает switchTab → snake eats tail. + +Решение: +```js +let _navigating = false; +function navigate(hash) { + _navigating = true; + location.hash = hash; + _navigating = false; +} +window.addEventListener('hashchange', () => { + if (_navigating) return; + // emit 'change' +}); +``` + +Или передавать `{ silent: true }` через объект-параметр и проверять его в handler'е switchTab. + +### Существующий пример hashchange + +В `frontend/js/textbook-tracker.js:438` уже есть `addEventListener('hashchange', handleHashNav)` — это safe-pattern, можно подсмотреть структуру. + +## Review Checklist + +- [ ] router.js не использует Grep / эмоджи / отсутствующие LS-помощники +- [ ] Старый switchTab НЕ удалён, только обёрнут +- [ ] Нет регрессий: все 13 табов переключаются, lazy-load работает +- [ ] F5 / back / forward проверены вручную в браузере (или симуляция через subagent) +- [ ] Default route `#stats` срабатывает при пустом hash +- [ ] Unknown route не крашит панель +- [ ] Код следует конвенциям проекта (no emoji, inline SVG для иконок, LS.* для API) +- [ ] Build passes: `cd backend && npm start` → http://localhost:3000/admin загружается + +## Handoff to Next Phase + +**Router API location:** `window.AdminRouter` (defined in `frontend/js/admin/router.js`, loaded **before** `admin.js` from `admin.html`). + +**Public surface:** + +```js +AdminRouter.parse('#users/123') + // → { route: 'users', params: ['123'], raw: '#users/123' } +AdminRouter.current() + // → parsed location.hash +AdminRouter.navigate('#users', { replace: false, silent: false }) + // replace → history.replaceState (no extra entry) + // silent → suppress synchronous 'change' emit; hashchange still fires natively +AdminRouter.on('change', ({ route, params, raw }) => { ... }) +AdminRouter.off('change', fn) +``` + +**Events emitted:** only `'change'` for now. Payload is the parsed route plus `silent: false`. Internal `_navigating` flag suppresses re-emit when *we* set the hash (prevents the snake-eats-tail loop). + +**How Phase 2 sections subscribe:** + +```js +AdminRouter.on('change', ({ route, params }) => { + if (route === 'users') AdminSections.users.init(); + if (route === 'sessions' && params[0]) AdminSections.sessions.openDetail(params[0]); +}); +``` + +Sections should call `AdminRouter.current()` once on load to handle the initial route (the router does NOT replay past 'change' events to late subscribers). + +**switchTab contract change:** +`switchTab(btn, opts)` — `opts.fromRouter === true` prevents `switchTab` from re-pushing the hash (used by router when responding to a hashchange / deep-link). Existing call sites (`switchTab(this)`, `switchTab(qBtn)`, `switchTab(this);loadAvatarRequests()`) keep working — they call without `opts`, so the URL syncs as expected. + +**Default route:** `#stats` (matches existing initially-active tab). Phase 3 will change default to `#overview` once dashboard ships. + +**Unknown / locked routes:** logged via `console.warn('AdminRouter: unknown route', name)`, then `replace`-navigated to `#stats` without polluting browser history. + +**Files touched:** +- `frontend/js/admin/router.js` — NEW, 97 lines +- `frontend/admin.html` — +1 line (`` before admin.js) +- `frontend/js/admin/admin.js` — `switchTab` signature `(btn, opts)`, +6 lines for hash-sync; new ~36-line `initAdminRouter` IIFE in init block + +**Backward compat verified:** +- All 21 `onclick="switchTab(this)"` callsites untouched. +- `goAddQuestion(slug)` works (calls `switchTab(qBtn)` without `opts` → URL also updates to `#questions`). +- `onclick="switchTab(this);loadAvatarRequests()"` on the avatars tab still works. diff --git a/plans/admin-redesign/phase-2-split-sections.md b/plans/admin-redesign/phase-2-split-sections.md new file mode 100644 index 0000000..7bfbe30 --- /dev/null +++ b/plans/admin-redesign/phase-2-split-sections.md @@ -0,0 +1,225 @@ +# Phase 2: Split admin.html → per-section modules + +**Status:** ✅ Done +**Parent plan:** [PLAN.md](./PLAN.md) +**Domain:** frontend +**Commit:** 92030b4 + +## Objective + +Разделить монолит `admin.js` (3500L) на per-section модули в `frontend/js/admin/sections/*.js`. После фазы `admin.js` становится оркестратором (~500-800L): он только подключает router, инициализирует общие виджеты (notif, sidebar) и делегирует загрузку section-данных в соответствующий модуль. + +## Tasks + +- [x] Создать `frontend/js/admin/sections/` директорию +- [x] Определить единый паттерн модуля: + ```js + // js/admin/sections/.js + (function () { + 'use strict'; + let inited = false; + const ctx = { user: null, isAdmin: false }; // прокидываем из admin.js + async function load() { /* существующий loadX код */ } + window.AdminSections = window.AdminSections || {}; + window.AdminSections. = { + init: async (sharedCtx) => { + Object.assign(ctx, sharedCtx); + if (inited) return; inited = true; await load(); + }, + reload: load, + }; + })(); + ``` +- [x] Извлечь 13 секций (в порядке риска — от меньшего к большему): + - [x] `stats.js` — `loadStats` + связанные функции (50L) + - [x] `sublog.js` — submission log (104L) + - [x] `sims.js` (118L), `games.js` (132L), `tpl.js` (73L) — admin-only + - [x] `subjects.js` — настройка доступных тестов (338L) + - [x] `permissions.js` (68L) + - [x] `shop.js` — items + purchases + award coins (207L) + - [x] `gam.js` — gamification stats + award xp (183L) + - [x] `assignments.js` (477L) + - [x] `tests.js` (283L) + - [x] `questions.js` — самая большая, 535L (включая Q-modal) + - [x] `users.js` — users-table + pagination + user-panel (343L, overlay остался) + - [x] `sessions.js` — sessions-table + session detail (159L) +- [x] Модифицировать `admin.js`: + - Удалить функции, перенесённые в sections + - Заменить inline вызовы (`loadUsers()` → `AdminSections.users.init()`) + - Добавить ROUTE_TO_SECTION mapping (см. ниже) + ```js + const ROUTE_TO_SECTION = { + stats: 'stats', users: 'users', sessions: 'sessions', + questions: 'questions', tests: 'tests', assignments: 'assignments', + subjects: 'subjects', permissions: 'permissions', + shop: 'shop', gam: 'gam', tpl: 'tpl', sims: 'sims', games: 'games', sublog: 'sublog', + }; + ``` + Маппинг применяется внутри `switchTab` (не отдельный router-listener) — + `switchTab` уже вызывается router'ом на change через `activate(route)`, + поэтому достаточно один раз dispatch'ить в `switchTab`. +- [x] Все 14 `` + +## Acceptance Criteria + +- Ctrl+K (Cmd+K) открывает palette из любого таба admin +- Esc закрывает +- Печать "иван" → top users с именем "Иван..." +- Печать "монеты" → action "Выдать монеты" +- ↑↓ навигация работает, Enter выполняет +- Поиск отрабатывает <100ms для 8 результатов на тестовой БД +- Click outside / Esc закрывают +- LS.modal используется (не reinventing wheel) +- Auth: только admin может открыть (teachers — палетту не открывают) + +## Notes + +### Почему Ctrl+K а не / + +Ctrl+K — индустри-стандарт (GitHub, Linear, Vercel, Slack). `/` конфликтует с input'ами. + +### Дебаунсинг + +Простой setTimeout/clearTimeout. Без библиотек. + +### LS.modal compat + +LS.modal сейчас принимает `{ title, body, footer, onOk, onClose, size }`. Для palette нужен focus management — autofocus input при открытии. Можно использовать через колбэк `onMount` если он есть, либо `setTimeout(() => input.focus(), 0)` после открытия. + +### Что НЕ делать в этой фазе + +- Не делать ML/fuzzy-search в backend (LIKE достаточно) +- Не делать historic recents (Cmd+K recents) — это уже после merge +- Не делать collaboration ("кто-то ещё печатает") + +## Review Checklist + +- [ ] Ctrl+K не конфликтует с системными shortcut'ами браузера +- [ ] Palette не открывается если фокус в textarea / input (если требует ввод)... опционально, можно открывать всегда +- [ ] No SQL injection в /admin/search +- [ ] Эскейпинг через LS.esc для рендеринга имён пользователей +- [ ] No N+1 queries (один SELECT на тип сущности) +- [ ] Build passes + +## Handoff to Next Phase + +### Endpoint contract — `GET /api/admin/search?q=&limit=8` + +Auth: admin only (inside `requireRole('admin')` block in `backend/src/routes/admin.js`). +If `q.trim().length < 2`, returns empty arrays without hitting DB. Errors → 500 `{error:'Search failed'}`. + +Response shape (top 5 users / top 3 tests / top 3 classes): +```js +{ + users: [{ id, name, email, role }], + tests: [{ id, name, subject_slug }], // alias: tests.title AS name + classes: [{ id, name, code }], // alias: classes.invite_code AS code +} +``` + +Backend perf: 3 simple parameterised SELECTs with LIMIT — well under 100ms. + +### Navigation contract from palette → router + +| Result kind | Action | +|-------------|--------| +| Action | calls the hardcoded `go()` callback (most go through `AdminRouter.navigate('#…')`) | +| User | `AdminRouter.navigate('#users/' + id)` — params parsed by router, but ROUTE_TO_SECTION currently only dispatches `users` section. **Phase 6** can add a `user` section that reads `params.id` and renders a deep page. | +| Test | `AdminRouter.navigate('#tests')` (no deep page yet) | +| Class | `window.location.href = '/classes#' + id` — leaves admin (classes UI is a separate page) | + +### Action registry (hardcoded in `frontend/js/admin/palette.js`) + +`award_coins → #shop`, `award_xp → #gam`, `new_class → /classes`, `new_test → #tests`, +`view_users → #users`, `view_sessions → #sessions`, `view_audit → #sublog`, `view_overview → #overview`. + +Phase 5 (quick actions) and Phase 6 (deep pages) may extend the `ACTIONS` array — just add to it; the action's `name` field is what users see and what is fuzzy-matched (lowercase substring on `name` + optional `hint` keyword). + +### Ctrl+K conflict with `/js/search.js` + +`/js/search.js` is also loaded on admin.html and binds its own Ctrl+K listener (bubble phase). Palette binds in **capture phase** + `e.stopImmediatePropagation()`, so on admin pages the palette wins. On non-admin pages the generic search remains intact (palette.js is only loaded from admin.html). + +### Exposed globals + +- `window.AdminPalette = { open, close, isOpen }` — for future programmatic open from quick-actions. +- `LS.adminGlobalSearch(q)` — exported helper in `js/api.js`. diff --git a/plans/admin-redesign/phase-5-quick-actions.md b/plans/admin-redesign/phase-5-quick-actions.md new file mode 100644 index 0000000..6e063f2 --- /dev/null +++ b/plans/admin-redesign/phase-5-quick-actions.md @@ -0,0 +1,113 @@ +# Phase 5: Per-row quick actions + +**Status:** ✅ Done +**Parent plan:** [PLAN.md](./PLAN.md) +**Domain:** frontend +**Parallelizable with:** Phase 3, Phase 4 + +## Objective + +На hover-строке user / session показывать кнопки частых action прямо в таблице — без открытия overlay-панели. Сокращает 2-3 клика до 1 для типичных задач (бан, выдача монет, удаление сессии). + +## Tasks + +- [x] **Users table** (`frontend/js/admin/sections/users.js`): + - Добавлена `` с inline-flex блоком `.row-actions` (заменяет старый `›` индикатор) + - Visible: только на `:hover` строки (CSS opacity transition) + - Кнопки (inline SVG, Lucide-style): + - **Ban / Unban** — `quickToggleBan(uid, isBanned, btn)` → `LS.confirm` → `LS.adminBanUser` + - **Award coins** — `quickAwardCoins(uid, name)` → `LS.modal` (sm) с inputs amount+reason → `LS.adminShopAwardCoins` + - **Sessions** — `quickOpenUserSessions(uid)` → `AdminRouter.navigate('#sessions')` (fallback на `switchTab`) + - **Delete** — `quickDeleteUser(uid, name, btn)` → `LS.confirm` (destructive) → `LS.adminDeleteUser` + - SVG-иконки (inline, Lucide outline-style), НЕТ эмоджи + - `event.stopPropagation()` на каждой кнопке + на родительском `.row-actions` (чтобы не открывать user-panel overlay) + - Hidden для self (`u.id !== user.id`) и для non-admin — fallback на старый `›` +- [x] **Sessions table** (`frontend/js/admin/sections/sessions.js`): + - **View (eye icon)** — `toggleDrawer(id)` (тот же flow что и row-click) + - **Delete (trash, danger)** — `quickDeleteSession(id, btn)` → `LS.confirm` → `LS.adminDeleteSession` → `load()` (refresh) +- [x] **Backend `DELETE /api/admin/sessions/:id`** — endpoint отсутствовал, добавлен: + - Route: `backend/src/routes/admin.js` (внутри `requireRole('admin')` блока) + - Controller: `deleteSession(req, res, next)` в `adminController.js` — транзакция: + 1. `UPDATE assignment_sessions SET session_id = NULL WHERE session_id = ?` (explicit null, hoarded slot stays) + 2. `DELETE FROM user_answers WHERE session_id = ?` (FK has `ON DELETE CASCADE`, но делаем явно) + 3. `DELETE FROM session_questions WHERE session_id = ?` (то же) + 4. `DELETE FROM test_sessions WHERE id = ?` + - Audit: `audit(req, 'session.delete', 'session:${sid}', 'user:N mode:X')` + - Validates `Number.isInteger(sid) && sid > 0`; 404 if not found + - API helper: `LS.adminDeleteSession(id)` → `DELETE /admin/sessions/:id` +- [x] **CSS** (`#row-actions-style`): + - Inject ONCE из обеих секций (de-dup по element id) — оба `ensureRowActionsStyles()` проверяют `getElementById('row-actions-style')` перед добавлением + - Стили: `.row-actions`, `.row-action-btn` (default + .danger), `.row-actions-cell`, `@media (max-width: 768px) { display: none }` + - Также handle `tr.selected .row-actions` и `.sess-tl-item.open .row-actions` → opacity 1 (для активных строк) +- [x] `title="…"` на каждой кнопке (tooltip) +- [x] `LS.confirm(message, { title, confirmText })` использован везде (signature: `lsConfirm(message, { title, confirmText, danger=true })` — `danger:true` default, gradient pink→violet) + +## Files to Modify/Create + +- `frontend/js/admin/sections/users.js` — модификация renderRow + action handlers (~50-100L добавления) +- `frontend/js/admin/sections/sessions.js` — same (~30-50L) +- `frontend/admin.html` — стили для `.row-actions` (~30L) +- `backend/src/controllers/adminController.js` — `deleteSession` если отсутствует +- `backend/src/routes/admin.js` — `DELETE /sessions/:id` если отсутствует + +## Acceptance Criteria + +- Hover на user row → видны 4 кнопки справа без раздвигания layout +- Hover на session row → видны 2 кнопки +- Каждая кнопка работает (ban / coins / sessions / delete) +- Click на кнопку НЕ открывает user-panel overlay (stopPropagation) +- Tooltip на hover каждой кнопки +- Confirm для деструктивных action (delete, ban) +- LS.toast после success +- Auth check — все action available только admin +- Mobile: actions hidden (tap-only context), либо альтернативный UI (long-press → menu) — пока минимум скрыть на ≤768px + +## Notes + +### Существующие helpers использовать + +- `LS.confirm(message, { okText, danger })` для подтверждений +- `LS.modal(...)` если нужна форма (например award coins amount) +- `LS.toast` для feedback +- Существующие admin* функции (toggleBanUser, awardCoins, etc.) — не дублировать + +### Визуальный паттерн + +Inspired by Linear / Vercel admin: actions visible on row hover, positioned right-aligned, ghost-style buttons (transparent bg, border on hover). Иконки только. + +### Что НЕ делать в этой фазе + +- Не делать bulk-actions (select multiple → action) — это после merge +- Не делать undo (toast с "отменить" внутри) — Phase 6+ +- Не менять структуру таблицы radically + +## Review Checklist + +- [x] Кнопки не сдвигают layout — `opacity: 0 → 1` без display swap, занимают слот старого `›` +- [x] Имя пользователя в onclick экранируется через `esc()` + `replace(/'/g, "\\'")` для безопасности SQL/HTML-injection в строковых литералах +- [x] No emoji — только inline SVG (Lucide-style outline-stroke, viewBox 24x24) +- [x] `event.stopPropagation()` на каждой кнопке + на родительском `.row-actions` div (defence in depth) +- [x] Confirm через `LS.confirm` для destructive (delete user, delete session, ban/unban) +- [x] `title` атрибут есть на каждой кнопке +- [x] Mobile (≤768px): `.row-actions { display: none }` — row-click overlay по-прежнему работает как fallback +- [x] `node --check` all modified files OK +- [x] Tests: 32/35 pass (3 pre-existing auth-test failures, unrelated) + +## Handoff to Next Phase + +**Phase 6 (deep entity pages) рекомендации:** + +1. **`quickOpenUserSessions(uid)`** сейчас просто навигирует на `#sessions` без фильтра. Phase 6 должна: + - Расширить router до `#sessions?user=N` (или новый формат `#sessions/user/N`) + - В `sessions.js` `load()` читать query param и передавать `user_id` в `LS.adminGetSessions({ user_id })` (backend уже поддерживает `user_id` query param — см. `getAllSessions` controller) + - Обновить хелпер: `AdminRouter.navigate('#sessions?user=' + uid)` (когда router научится parse'ить query) + +2. **User-panel overlay vs hover actions:** Phase 6 удалит старую `.user-panel` overlay. Когда это произойдёт, row-click больше не будет открывать панель. Hover-actions останутся как primary UX. Рекомендация: при удалении overlay row-click сделать `onclick="AdminRouter.navigate('#users/' + uid)"` (deep page). + +3. **Mobile UX gap:** на ≤768px actions сейчас полностью скрыты. Когда Phase 6 добавит deep page, mobile-row-click станет переходом на deep page → primary actions доступны там. До тех пор mobile = read-only browse. + +4. **Backend `DELETE /admin/sessions/:id`** уже там, готов для Phase 6 deep session page (где будет кнопка "Удалить эту сессию" в header). + +5. **Award coins modal pattern** (используем `LS.modal` с body=DOM Node + actions с `onClick({close, setError})`) — может быть полезен Phase 6 для inline-edit flow на deep user page. + +6. **Linter note:** `npm run lint:routes` показывает FAIL (65 unprotected vs baseline 56) — pre-existing проблема, my new admin-protected `DELETE /sessions/:id` добавил +1 false-positive (роут защищён через `router.use(requireRole('admin'))` блок, который linter не видит). Не требует действий — это known limitation скрипта. diff --git a/plans/admin-redesign/phase-6-deep-pages.md b/plans/admin-redesign/phase-6-deep-pages.md new file mode 100644 index 0000000..6b319c4 --- /dev/null +++ b/plans/admin-redesign/phase-6-deep-pages.md @@ -0,0 +1,145 @@ +# Phase 6: Deep entity pages + +**Status:** ✅ Done (sub-commits: bd30200 + final remove-overlay commit) +**Parent plan:** [PLAN.md](./PLAN.md) +**Domain:** frontend + +## Objective + +Заменить выезжающую `.user-panel` overlay на полноценную страницу с URL `#users/123`. Аналогично для session: `#sessions/456` = full detail page. Это самая комплексная фаза — она ломает совместимость с старым overlay UI (удаляет код), потому идёт ПОСЛЕ всех остальных. + +## Tasks + +- [x] **User detail page** (`frontend/js/admin/sections/user-detail.js`): + - Реагирует на route `#users/:id` + - Layout: + - **Header**: avatar, name, role badge, email, action buttons (ban/edit/perms/delete), back-link to `#users` + - **Tabs** (sub-nav в странице): + - Overview — статистика (тестов, средний %, регистрация, посл вход) + - Sessions — таблица последних 20 сессий с pagination + - Classes — список классов где он состоит + - Audit — журнал действий (если есть audit log с user_id) + - **Graphs** (опционально, можно отдельным таб'ом): + - Простой SVG-чарт: успеваемость по неделям + - Mini-bar chart: avg % по предметам +- [x] **Session detail page** (`frontend/js/admin/sections/session-detail.js`): + - Реагирует на route `#sessions/:id` + - Layout: header (user, subject, score, дата) + список вопросов/ответов (правильно/нет, текст), back-link +- [x] **Router updates** (`frontend/js/admin/router.js` если ещё не поддерживает): router из Phase 1 уже парсит params — обновлять не пришлось +- [x] **Admin.js dispatch**: добавлена `DEEP_ROUTES` map + `activateDeepPane()` + `activate(route, params)` +- [x] **Удалить overlay-код:** + - [x] В `frontend/admin.html` удалён `
` блок + `.user-panel*` CSS + - [x] В `sections/users.js` удалены `openUserPanel`, `closeUserPanel`, `reloadUserPanel` + - [x] В `sections/users.js` onclick переключён на `AdminRouter.navigate('#users/${u.id}')` +- [x] **Replace** в Phase 5 quick action "Sessions": `quickOpenUserSessions(uid)` → `AdminRouter.navigate('#users/' + uid + '/sessions')` + - Парсить sub-tab из route (выполнено через `params[1]` в `activate()`) + - Открывать user-detail page с активным Sessions tab +- [x] **Глоссарий routes после фазы:** + - `#overview` — dashboard (Phase 3) + - `#users` — list + - `#users/123` — user detail (overview tab default) + - `#users/123/sessions` — user detail with sessions sub-tab + - `#sessions` — list + - `#sessions/456` — session detail + - … остальные без params — как было + +## Files to Modify/Create + +- `frontend/js/admin/sections/user-detail.js` — новый, ~400-600L +- `frontend/js/admin/sections/session-detail.js` — новый, ~200-300L +- `frontend/admin.html` — удалить `.user-panel` overlay, добавить `
` и `
`, добавить `