@@ -146,10 +182,28 @@
if (window.lucide) lucide.createIcons();
}
+ async function quickDeleteSession(id, btn) {
+ if (!await LS.confirm(
+ 'Удалить эту сессию? Все ответы и связанные данные будут удалены.\nЭто действие нельзя отменить.',
+ { title: 'Удалить сессию', confirmText: 'Удалить' }
+ )) return;
+ btn.disabled = true;
+ try {
+ await LS.adminDeleteSession(id);
+ LS.toast('Сессия удалена', 'success');
+ // Refresh from server — keeps grouped layout consistent.
+ await load();
+ } catch (e) {
+ LS.toast('Ошибка: ' + e.message, 'error');
+ btn.disabled = false;
+ }
+ }
+
// Expose handlers
window.loadSessions = load;
window.renderSessions = renderSessions;
window.toggleDrawer = toggleDrawer;
+ window.quickDeleteSession = quickDeleteSession;
window.AdminSections = window.AdminSections || {};
window.AdminSections.sessions = {
diff --git a/frontend/js/admin/sections/users.js b/frontend/js/admin/sections/users.js
index 2cb46a1..f97c65b 100644
--- a/frontend/js/admin/sections/users.js
+++ b/frontend/js/admin/sections/users.js
@@ -7,6 +7,39 @@
let _usersPage = 1;
const _USERS_PER_PAGE = 50;
+ /* ── one-time CSS injection for hover row-actions (shared with sessions) ── */
+ function ensureRowActionsStyles() {
+ if (document.getElementById('row-actions-style')) return;
+ const s = document.createElement('style');
+ s.id = 'row-actions-style';
+ s.textContent = `
+ .row-actions { opacity: 0; transition: opacity .15s ease; display: inline-flex; gap: 4px; vertical-align: middle; }
+ tr:hover .row-actions, .sess-tl-item:hover .row-actions { opacity: 1; }
+ tr.selected .row-actions, .sess-tl-item.open .row-actions { opacity: 1; }
+ .row-action-btn { width: 28px; height: 28px; border-radius: 6px; border: 1px solid var(--border); background: transparent; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; color: var(--text-3); transition: background .12s ease, border-color .12s ease, color .12s ease; padding: 0; }
+ .row-action-btn:hover { background: rgba(155,93,229,.08); border-color: var(--violet); color: var(--violet); }
+ .row-action-btn:focus-visible { outline: 2px solid var(--violet); outline-offset: 1px; }
+ .row-action-btn.danger:hover { background: rgba(239,68,68,.08); border-color: var(--red, #EF4444); color: var(--red, #EF4444); }
+ .row-action-btn svg { width: 14px; height: 14px; pointer-events: none; }
+ .row-action-btn:disabled { opacity: .5; cursor: wait; }
+ .row-actions-cell { text-align: right; white-space: nowrap; padding-right: 12px; }
+ @media (max-width: 768px) {
+ .row-actions { display: none; }
+ }
+ `;
+ document.head.appendChild(s);
+ }
+
+ /* SVG icons (Lucide-style, 24x24 viewBox) */
+ const ICONS = {
+ ban: '
',
+ unlock: '
',
+ coins: '
',
+ history: '
',
+ trash: '
',
+ eye: '
',
+ };
+
// user-panel + edit modal + perms modal state
let activeTr = null;
let activeUid = null;
@@ -19,6 +52,7 @@
const isAdmin = AdminCtx.isAdmin;
const user = AdminCtx.user;
if (page) _usersPage = page;
+ ensureRowActionsStyles();
try {
const r = await LS.adminGetUsers({ page: _usersPage, limit: _USERS_PER_PAGE });
const users = r.users || [];
@@ -58,7 +92,7 @@
${fmtDate(u.created_at)} |
${u.last_login ? new Date(u.last_login).toLocaleDateString('ru',{day:'numeric',month:'short'}) : '—'} |
-
› |
+
${renderUserRowActions(u, isAdmin && u.id !== user.id)} |
`;
}).join('');
renderPgnControls('users-pagination', _usersPage, r.total || users.length, _USERS_PER_PAGE, 'gotoUsersPage');
@@ -68,6 +102,106 @@
}
}
+ /* ─── Per-row hover actions (Phase 5) ─── */
+ function renderUserRowActions(u, canAct) {
+ if (!canAct) {
+ // Hide actions for non-admins or current user; keep arrow indicator as before
+ return '
›';
+ }
+ const banIcon = u.is_banned ? ICONS.unlock : ICONS.ban;
+ const banLabel = u.is_banned ? 'Разблокировать' : 'Заблокировать';
+ return `
+
+
+
+
+
`;
+ }
+
+ async function quickToggleBan(uid, isBanned, btn) {
+ const action = isBanned ? 'Разблокировать' : 'Заблокировать';
+ const msg = isBanned
+ ? 'Разблокировать пользователя? Он снова сможет войти в систему.'
+ : 'Заблокировать пользователя? Он не сможет войти в систему.';
+ if (!await LS.confirm(msg, { title: action, confirmText: action })) return;
+ btn.disabled = true;
+ try {
+ await LS.adminBanUser(uid, !isBanned);
+ LS.toast(isBanned ? 'Пользователь разблокирован' : 'Пользователь заблокирован', isBanned ? 'success' : 'warning');
+ await load();
+ if (activeUid === uid) await reloadUserPanel(uid);
+ } catch (e) {
+ LS.toast('Ошибка: ' + e.message, 'error');
+ btn.disabled = false;
+ }
+ }
+
+ function quickAwardCoins(uid, name) {
+ const body = document.createElement('div');
+ body.innerHTML = `
+
Начислить монеты пользователю ${esc(name)}:
+
+
+
+
`;
+ const m = LS.modal({
+ title: 'Начислить монеты',
+ content: body,
+ size: 'sm',
+ actions: [
+ { label: 'Отмена', onClick: ({ close }) => close() },
+ { label: 'Начислить', primary: true, onClick: async ({ close, setError }) => {
+ const amt = parseInt(body.querySelector('#qa-coins-amt').value, 10);
+ const reason = body.querySelector('#qa-coins-reason').value.trim();
+ if (!Number.isFinite(amt) || amt <= 0) { setError('Введите положительное количество монет'); return; }
+ try {
+ const r = await LS.adminShopAwardCoins({ userId: uid, amount: amt, reason });
+ LS.toast(`Начислено ${amt} монет. Баланс: ${r.coins ?? '?'}`, 'success');
+ close();
+ } catch (e) { setError('Ошибка: ' + e.message); }
+ } },
+ ],
+ });
+ setTimeout(() => body.querySelector('#qa-coins-amt')?.focus(), 80);
+ }
+
+ function quickOpenUserSessions(uid) {
+ // Phase 6 may extend to `#sessions?user=${uid}` (deep-link with prefilter);
+ // for now just navigate to sessions tab.
+ if (window.AdminRouter) AdminRouter.navigate('#sessions');
+ else if (typeof window.switchTab === 'function') {
+ const btn = document.querySelector('.admin-nav-item[onclick*="sessions"]');
+ if (btn) window.switchTab(btn);
+ }
+ }
+
+ async function quickDeleteUser(uid, name, btn) {
+ if (!await LS.confirm(
+ `Удалить пользователя «${name}» навсегда?\nВсе его данные, тесты и прогресс будут удалены. Это действие нельзя отменить.`,
+ { title: 'Удалить пользователя', confirmText: 'Удалить навсегда' }
+ )) return;
+ btn.disabled = true;
+ try {
+ await LS.adminDeleteUser(uid);
+ LS.toast('Пользователь удалён', 'success');
+ if (activeUid === uid) closeUserPanel();
+ await load();
+ } catch (e) {
+ LS.toast('Ошибка: ' + e.message, 'error');
+ btn.disabled = false;
+ }
+ }
+
function gotoUsersPage(n) {
_usersPage = n;
load();
@@ -334,6 +468,11 @@
window.doSetUserPerm = doSetUserPerm;
window.doResetOneUserPerm = doResetOneUserPerm;
window.doResetAllUserPerms = doResetAllUserPerms;
+ // Phase 5 quick actions
+ window.quickToggleBan = quickToggleBan;
+ window.quickAwardCoins = quickAwardCoins;
+ window.quickOpenUserSessions = quickOpenUserSessions;
+ window.quickDeleteUser = quickDeleteUser;
window.AdminSections = window.AdminSections || {};
window.AdminSections.users = {
diff --git a/js/api.js b/js/api.js
index 9a4beeb..4bdbbbf 100644
--- a/js/api.js
+++ b/js/api.js
@@ -175,6 +175,7 @@ async function adminGetSessions(params = {}) {
return req('GET', `/admin/sessions?${p}`);
}
async function adminGetSessionDetail(id) { return req('GET', `/admin/sessions/${id}`); }
+async function adminDeleteSession(id) { return req('DELETE',`/admin/sessions/${id}`); }
async function adminClearUserSessions(id) { return req('POST', `/admin/users/${id}/sessions/clear`); }
async function adminUpdateUser(id, data) { return req('PATCH', `/admin/users/${id}`, data); }
async function adminBanUser(id, banned) { return req('PATCH', `/admin/users/${id}/ban`, { banned }); }
@@ -944,7 +945,7 @@ window.LS = {
register, login, fetchMe, updateProfile,
getSubjects, updateSubject, getTopics,
startSession, sendAnswer, finishSession, getResult, getHistory, getWeakTopics, getStudentStats, getSessionQuestions,
- adminGetStats, adminGetOverview, adminGlobalSearch, adminGetUsers, adminUpdateRole, adminGetUserSessions, adminGetSessions, adminGetSessionDetail, adminClearUserSessions, adminUpdateUser, adminBanUser, adminDeleteUser,
+ adminGetStats, adminGetOverview, adminGlobalSearch, adminGetUsers, adminUpdateRole, adminGetUserSessions, adminGetSessions, adminGetSessionDetail, adminDeleteSession, 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 ec35607..b78ea2d 100644
--- a/plans/admin-redesign/CONTEXT.md
+++ b/plans/admin-redesign/CONTEXT.md
@@ -8,7 +8,8 @@
- ✅ 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 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 implemented — Cmd+K (Ctrl+K) global command palette. Backend: `GET /api/admin/search?q=X` (admin-only) returns `{users[5], tests[3], classes[3]}` via 3 prepared LIKE queries (`title AS name` for tests, `invite_code AS code` for classes). Frontend: `frontend/js/admin/palette.js` (~320L) — custom modal (NOT LS.modal) with capture-phase Ctrl+K listener that `stopImmediatePropagation`'s to override `/js/search.js`. Debounced 150ms, ↑↓ Enter Esc keyboard nav, click-outside close. Action registry (8 entries) is hardcoded — extend by appending to `ACTIONS` const. Result interactions: user → `AdminRouter.navigate('#users/' + id)` (Phase 6 deep page hook), test → `#tests`, class → `/classes#id`. Exposed: `window.AdminPalette = { open, close, isOpen }`, `LS.adminGlobalSearch(q)`. Files: `frontend/js/admin/palette.js` (NEW), `backend/src/controllers/adminController.js` (+50L: `searchStmts` + `globalSearch`), `backend/src/routes/admin.js` (+1L), `js/api.js` (+4L helper + export), `frontend/admin.html` (+1 script tag).
-- ⬜ Phase 5-6 not started
+- ✅ Phase 5 implemented — per-row hover quick actions для users + sessions tables. Users row (admin && uid !== self): 4 кнопки (Ban/Unban toggle, Award coins via LS.modal с amount+reason, Sessions → AdminRouter.navigate('#sessions'), Delete). Sessions row: 2 кнопки (View → toggleDrawer, Delete). Все `event.stopPropagation()` чтобы не триггерить row-click overlay/drawer. CSS injected ONCE через `ensureRowActionsStyles()` (de-dup по `#row-actions-style` id, обе секции проверяют existence). Mobile ≤768px: actions hidden (row-click overlay остаётся fallback'ом). Backend: NEW `DELETE /api/admin/sessions/:id` (admin-only) → `_deleteSessionTx` транзакция: nullify `assignment_sessions.session_id`, delete `user_answers` + `session_questions` (FK CASCADE но делаем explicit для visibility), delete `test_sessions`. Audit log: `'session.delete'`. Файлы: `frontend/js/admin/sections/users.js` (343→469L, +126), `frontend/js/admin/sections/sessions.js` (159→210L, +51), `backend/src/controllers/adminController.js` (+27L: `_deleteSessionTx` + `deleteSession`), `backend/src/routes/admin.js` (+1L), `js/api.js` (+1 helper + export). NO эмоджи, inline SVG (Lucide outline-style 24x24 viewBox), Lucide уже доступен через CDN. User-panel overlay НЕ удалена — оставлена для Phase 6.
+- ⬜ Phase 6 not started
## Temporary Workarounds
diff --git a/plans/admin-redesign/PLAN.md b/plans/admin-redesign/PLAN.md
index 1460a90..807081a 100644
--- a/plans/admin-redesign/PLAN.md
+++ b/plans/admin-redesign/PLAN.md
@@ -39,7 +39,7 @@
- [x] Phase 2: Split admin.html → per-section modules [domain: frontend] → [subplan](./phase-2-split-sections.md)
- [x] Phase 3: Dashboard #overview [domain: fullstack] → [subplan](./phase-3-dashboard.md) (parallelizable with 4, 5)
- [x] 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)
+- [x] 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)
**Параллелизация:** фазы 3, 4, 5 независимы (touch different files, no shared state) — выполняются параллельно после завершения фазы 2.
@@ -51,8 +51,8 @@
| Phase 1: Hash-router | frontend | ✅ Done | ✅ PASS w/ notes | ✅ | ✅ 8a7bed4 |
| Phase 2: Split sections | frontend | ✅ Done | ✅ PASS (1 blocker fixed: fa67ad1) | ✅ node --check | ✅ 92030b4 + fa67ad1 |
| Phase 3: Dashboard | fullstack | ✅ Done | ✅ PASS w/ 3 SQL warnings (post-merge polish) | ✅ | ✅ 41acbdd |
-| Phase 4: Palette | fullstack | ✅ Done | ⬜ | ✅ node --check | ⬜ |
-| Phase 5: Quick actions | frontend | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
+| Phase 4: Palette | fullstack | ✅ Done | ✅ PASS w/ notes (limit param cleanup applied) | ✅ | ✅ f562fe4 |
+| Phase 5: Quick actions | frontend | ✅ Done | ⬜ | ✅ node --check + tests 32/35 (3 pre-existing auth fails) | ⬜ |
| Phase 6: Deep pages | frontend | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
## Final Review
diff --git a/plans/admin-redesign/phase-5-quick-actions.md b/plans/admin-redesign/phase-5-quick-actions.md
index 52add13..6e063f2 100644
--- a/plans/admin-redesign/phase-5-quick-actions.md
+++ b/plans/admin-redesign/phase-5-quick-actions.md
@@ -1,6 +1,6 @@
# Phase 5: Per-row quick actions
-**Status:** ⬜ Not Started
+**Status:** ✅ Done
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** frontend
**Parallelizable with:** Phase 3, Phase 4
@@ -11,31 +11,36 @@
## Tasks
-- [ ] **Users table** (`frontend/js/admin/sections/users.js`):
- - Добавить в каждый `
` дополнительную ячейку или абсолютно-позиционированный блок с action-кнопками
- - Visible: только на `:hover` строки (via CSS)
- - Кнопки:
- - **🔒 Ban / Unban** — открывает confirm modal, на confirm вызывает существующий `toggleBanUser()` (или его эквивалент с userId)
- - **🪙 Award coins** — открывает быстрый prompt-modal "Сколько монет?", вызывает существующий `shopAdminAwardCoins` без перехода в shop tab
- - **📜 Sessions** — навигирует через `AdminRouter.navigate('#sessions?user=' + uid)` (param Phase 6 будет обрабатывать; пока fallback — переход на sessions tab)
- - **🗑 Delete** — confirm, вызывает существующий `confirmDeleteUser`
- - **ВАЖНО:** иконки только inline SVG (.ic класс) или Lucide — НИКАКИХ эмоджи
- - Кнопки `event.stopPropagation()` чтобы не триггерить `openUserPanel`
-- [ ] **Sessions table** (`frontend/js/admin/sections/sessions.js`):
- - **👁 View** — открыть session detail (текущий механизм)
- - **🗑 Delete** — confirm + DELETE /admin/sessions/:id (если такой endpoint есть, иначе добавить)
-- [ ] **Если delete session endpoint отсутствует** — добавить в backend:
- - `DELETE /api/admin/sessions/:id` с auth admin only
- - Контроллер: удалить из `test_sessions` + connected `session_answers`
- - Audit log entry
-- [ ] **CSS** (в admin.html style блоке или новый файл):
- ```css
- .row-actions { opacity: 0; transition: opacity .15s; display: inline-flex; gap: 4px; }
- tr:hover .row-actions { opacity: 1; }
- .row-action-btn { width: 28px; height: 28px; border-radius: 6px; ... }
- ```
-- [ ] Подсказки через `title="..."` атрибут на каждой кнопке
-- [ ] Confirm-модалки используют `LS.confirm` (не reinventing)
+- [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
@@ -78,17 +83,31 @@ Inspired by Linear / Vercel admin: actions visible on row hover, positioned righ
## Review Checklist
-- [ ] Кнопки не сдвигают layout (используют absolute / hidden / opacity)
-- [ ] Все action эскейпят пользовательский ввод
-- [ ] No emoji — только SVG
-- [ ] event.stopPropagation на всех кнопках
-- [ ] Confirm для destructive actions
-- [ ] Tooltip присутствует
-- [ ] Mobile-friendly (hidden или альтернативный UI)
-- [ ] Build passes
+- [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 скрипта.
|