`;
+ 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, добавить `