diff --git a/frontend/admin.html b/frontend/admin.html index 6c06a18..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; } @@ -1126,21 +1118,7 @@ -
-
-
-
- - - - - - -
-
-
История тестов
-
-
+ diff --git a/frontend/js/admin/sections/sessions.js b/frontend/js/admin/sections/sessions.js index ed6d3b1..8bfb97a 100644 --- a/frontend/js/admin/sections/sessions.js +++ b/frontend/js/admin/sections/sessions.js @@ -5,7 +5,11 @@ let inited = false; let allSessions = []; - let openDrawerId = null; + // Phase 6: clicking a session row navigates to the deep page (#sessions/:id) + // instead of toggling an inline drawer. The drawer rendering is gone. + function gotoSession(id) { + if (window.AdminRouter) AdminRouter.navigate('#sessions/' + id); + } /* SVG icons (Lucide-style) — kept local to mirror users.js without coupling */ const SESS_ICONS = { @@ -39,7 +43,6 @@ async function load() { const subject = document.getElementById('t-subject').value; document.getElementById('t-body').innerHTML = '
'; - openDrawerId = null; ensureRowActionsStyles(); try { allSessions = await LS.adminGetSessions({ subject: subject || undefined }); @@ -90,7 +93,7 @@ const ring = s.percent !== null ? sessPctRing(s.percent) : `
`; - return `
+ return `
${ring}
${esc(s.user_name)}
@@ -100,88 +103,15 @@
${fmtTime(s.duration_sec)}
+ onclick="event.stopPropagation();gotoSession(${s.id})">${SESS_ICONS.eye}
-
-
-
-
-
`; }).join('')}
` ).join(''); } - async function toggleDrawer(id) { - const drawerEl = document.getElementById('tdrawer-' + id); - const drawer = document.getElementById('drawer-' + id); - const trow = document.getElementById('trow-' + id); - if (openDrawerId && openDrawerId !== id) { - document.getElementById('tdrawer-' + openDrawerId)?.classList.remove('open'); - document.getElementById('drawer-' + openDrawerId)?.classList.remove('open'); - document.getElementById('trow-' + openDrawerId)?.classList.remove('open'); - } - if (openDrawerId === id) { - drawerEl.classList.remove('open'); drawer.classList.remove('open'); trow.classList.remove('open'); - openDrawerId = null; return; - } - openDrawerId = id; trow.classList.add('open'); - drawerEl.classList.add('open'); - requestAnimationFrame(() => drawer.classList.add('open')); - const inner = document.getElementById('drawer-inner-' + id); - if (inner.dataset.loaded) return; - inner.dataset.loaded = '1'; - try { - const d = await LS.adminGetSessionDetail(id); - renderDrawer(inner, d); - } catch (e) { inner.innerHTML = `
Ошибка: ${esc(e.message)}
`; } - } - - function renderDrawer(el, d) { - const { MODES, pctClass, fmtDate, fmtTime, renderMath } = AdminCtx; - const pct = d.score !== null && d.total ? Math.round((d.score/d.total)*100) : null; - const pc = pctClass(pct); - const correct = d.questions.filter(q => q.is_correct).length; - const wrong = d.questions.filter(q => !q.is_correct && q.chosen_option_id).length; - const skipped = d.questions.filter(q => !q.chosen_option_id).length; - const qHtml = d.questions.map((q,i) => { - const status = !q.chosen_option_id ? 'skipped' : q.is_correct ? 'correct' : 'wrong'; - const badgeTxt = { correct:'Верно', wrong:'Неверно', skipped:'Пропущено' }[status]; - const opts = q.options.map(o => { - const isCor = o.is_correct, isCho = o.id === q.chosen_option_id; - let cls='', icon=''; - if (isCor) { cls='correct-opt'; icon=''; } - else if (isCho && !isCor) { cls='chosen-wrong'; icon=''; } - return `
${icon}${esc(o.text)}
`; - }).join(''); - const expl = q.explanation ? `
Пояснение: ${esc(q.explanation)}
` : ''; - return `
-
Вопрос ${i+1}${badgeTxt}${q.time_spent_sec?q.time_spent_sec+' сек':''}
-
${esc(q.text)}
-
${opts}
${expl} -
`; - }).join(''); - el.innerHTML = ` -
-
-
${esc(d.user_name)}
-
${esc(d.user_email)} · ${d.subject_name||'?'} · ${MODES[d.mode]||d.mode} · ${fmtDate(d.started_at)}
-
-
${pct !== null ? pct+'%' : '—'}
-
-
${correct}
Верно
-
${wrong}
Неверно
-
${skipped}
Пропущено
-
${fmtTime(d.duration_sec)}
Время
-
-
-
${qHtml||'
Вопросы не найдены
'}
`; - renderMath(el); - if (window.lucide) lucide.createIcons(); - } - async function quickDeleteSession(id, btn) { if (!await LS.confirm( 'Удалить эту сессию? Все ответы и связанные данные будут удалены.\nЭто действие нельзя отменить.', @@ -202,7 +132,7 @@ // Expose handlers window.loadSessions = load; window.renderSessions = renderSessions; - window.toggleDrawer = toggleDrawer; + window.gotoSession = gotoSession; window.quickDeleteSession = quickDeleteSession; window.AdminSections = window.AdminSections || {}; diff --git a/frontend/js/admin/sections/users.js b/frontend/js/admin/sections/users.js index 8743563..4058180 100644 --- a/frontend/js/admin/sections/users.js +++ b/frontend/js/admin/sections/users.js @@ -40,12 +40,21 @@ eye: '', }; - // user-panel + edit modal + perms modal state - let activeTr = null; - let activeUid = null; - let activeUserRole = null; + /* User-related modal state. + * After Phase 6 the .user-panel overlay is gone — instead the modals + * (edit, perms) operate on window.activeUid which is set by user-detail.js + * when the deep page opens, or transiently by row actions on the list. */ let _editUid = null; let _upPermsData = null; + // Helper: read the currently-active user id (set by user-detail.js or quick actions). + const getActiveUid = () => window.activeUid || null; + // Helper: after a mutation that may affect the active user, refresh the deep page + // (if it's currently showing the same user) AND the list. + function reloadDetailAndList() { + const sec = (window.AdminSections || {})['user-detail']; + if (sec && typeof sec.reload === 'function') sec.reload(); + load(); + } async function load(page) { const { pctClass, fmtDate, renderPgnControls } = AdminCtx; @@ -74,7 +83,7 @@ ` : `${{student:'Ученик',free_student:'Своб. ученик',teacher:'Учитель',admin:'Админ'}[u.role]||u.role}`; - return ` + return `
${initials}
@@ -133,7 +142,10 @@ await LS.adminBanUser(uid, !isBanned); LS.toast(isBanned ? 'Пользователь разблокирован' : 'Пользователь заблокирован', isBanned ? 'success' : 'warning'); await load(); - if (activeUid === uid) await reloadUserPanel(uid); + if (getActiveUid() === uid) { + const sec = (window.AdminSections || {})['user-detail']; + if (sec && typeof sec.reload === 'function') sec.reload(); + } } catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); btn.disabled = false; @@ -193,7 +205,10 @@ try { await LS.adminDeleteUser(uid); LS.toast('Пользователь удалён', 'success'); - if (activeUid === uid) closeUserPanel(); + // If the deleted user is currently open as a deep page, go back to the list. + if (getActiveUid() === uid && window.AdminRouter) { + AdminRouter.navigate('#users'); + } await load(); } catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); @@ -214,105 +229,53 @@ finally { select.disabled = false; } } - /* ─── User panel ─── */ - async function openUserPanel(e, uid, role) { - const isAdmin = AdminCtx.isAdmin; - if (activeTr) activeTr.classList.remove('selected'); - activeTr = e.currentTarget; activeTr.classList.add('selected'); - activeUid = uid; - activeUserRole = role; - const panel = document.getElementById('user-panel'); - panel.classList.add('visible'); - panel.scrollIntoView({ behavior:'smooth', block:'nearest' }); - document.getElementById('up-sessions').innerHTML = LS.skeleton(3, 'row'); - document.getElementById('up-name').textContent = '…'; - document.getElementById('up-email').textContent = ''; - if (isAdmin) { - document.getElementById('up-edit-btn').style.display = ''; - document.getElementById('up-clear-btn').style.display = ''; - document.getElementById('up-perms-btn').style.display = role === 'teacher' ? '' : 'none'; - document.getElementById('up-ban-btn').style.display = ''; - document.getElementById('up-delete-btn').style.display = ''; - } - await reloadUserPanel(uid); - } - - async function reloadUserPanel(uid) { - const { MODES, pctClass, fmtDate } = AdminCtx; - const isAdmin = AdminCtx.isAdmin; - try { - const { user: u, sessions } = await LS.adminGetUserSessions(uid); - activeUserRole = u.role; - document.getElementById('up-name').innerHTML = LS.esc(u.name) + (u.is_banned ? ' ' : ''); - document.getElementById('up-email').textContent = u.email; - if (isAdmin) { - document.getElementById('up-perms-btn').style.display = u.role === 'teacher' ? '' : 'none'; - const banBtn = document.getElementById('up-ban-btn'); - const banLbl = document.getElementById('up-ban-label'); - if (u.is_banned) { - banBtn.style.background = 'rgba(34,197,94,.12)'; - banBtn.style.color = '#22C55E'; - banBtn.style.borderColor = 'rgba(34,197,94,.25)'; - banLbl.textContent = 'Разблокировать'; - } else { - banBtn.style.background = ''; - banBtn.style.color = ''; - banBtn.style.borderColor = ''; - banLbl.textContent = 'Заблокировать'; - } - } - const el = document.getElementById('up-sessions'); - if (!sessions.length) { el.innerHTML = '
Тестов нет
'; return; } - el.innerHTML = '
' + sessions.map(s => { - const pct = s.score !== null ? Math.round((s.score/s.total)*100) : null; - return `
-
${pct !== null ? pct+'%' : '—'}
-
${s.subject_name||'Тест'}
${fmtDate(s.started_at)} · ${MODES[s.mode]||s.mode}
-
${s.score??'—'} / ${s.total}
-
`; - }).join('') + '
'; - } catch (e) { LS.state.error(document.getElementById('up-sessions'), e); } - } - - function closeUserPanel() { - document.getElementById('user-panel').classList.remove('visible'); - if (activeTr) { activeTr.classList.remove('selected'); activeTr = null; } - activeUid = null; + /* ─── User actions (called from the user-detail deep page header buttons) ─── + * Pre-Phase 6 these talked to the .user-panel overlay; now they: + * - read the active uid via getActiveUid() (set by user-detail.init) + * - read display name from the #up-name span rendered inside the deep page + * - reload via AdminSections['user-detail'].reload() */ + function _activeName() { + const el = document.getElementById('up-name'); + return el ? el.textContent.trim() : ''; } async function clearUserHistory() { - const name = document.getElementById('up-name').textContent; + const uid = getActiveUid(); + if (!uid) return; + const name = _activeName(); if (!await LS.confirm(`Удалить всю историю тестов пользователя «${name}»?\nЭто действие нельзя отменить.`, { title: 'Очистить историю', confirmText: 'Удалить историю' })) return; try { - await LS.adminClearUserSessions(activeUid); - await reloadUserPanel(activeUid); - load(); + await LS.adminClearUserSessions(uid); + reloadDetailAndList(); } catch (e) { LS.toast('Ошибка очистки истории: ' + e.message, 'error'); } } async function toggleBanUser() { + const uid = getActiveUid(); + if (!uid) return; const banLbl = document.getElementById('up-ban-label'); - const isBanning = banLbl.textContent === 'Заблокировать'; - const name = document.getElementById('up-name').innerHTML.replace(' ',''); + const isBanning = banLbl ? banLbl.textContent.trim() === 'Заблокировать' : true; + const name = _activeName(); const msg = isBanning ? `Заблокировать пользователя «${name}»?\nОн не сможет войти в систему.` : `Разблокировать пользователя «${name}»?`; if (!await LS.confirm(msg, { title: isBanning ? 'Блокировка' : 'Разблокировка', confirmText: isBanning ? 'Заблокировать' : 'Разблокировать' })) return; try { - await LS.adminBanUser(activeUid, isBanning); + await LS.adminBanUser(uid, isBanning); LS.toast(isBanning ? 'Пользователь заблокирован' : 'Пользователь разблокирован', isBanning ? 'warning' : 'success'); - await reloadUserPanel(activeUid); - load(); + reloadDetailAndList(); } catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); } } async function confirmDeleteUser() { - const name = document.getElementById('up-name').innerHTML.replace(' ',''); + const uid = getActiveUid(); + if (!uid) return; + const name = _activeName(); if (!await LS.confirm(`Удалить пользователя «${name}» навсегда?\nВсе его данные, тесты и прогресс будут удалены. Это действие нельзя отменить.`, { title: 'Удалить пользователя', confirmText: 'Удалить навсегда' })) return; try { - await LS.adminDeleteUser(activeUid); + await LS.adminDeleteUser(uid); LS.toast('Пользователь удалён', 'success'); - closeUserPanel(); + if (window.AdminRouter) AdminRouter.navigate('#users'); load(); } catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); } } @@ -324,7 +287,8 @@ } function openEditUserModal() { - _editUid = activeUid; + _editUid = getActiveUid(); + if (!_editUid) return; document.getElementById('eu-name').value = document.getElementById('up-name').textContent; document.getElementById('eu-email').value = document.getElementById('up-email').textContent; document.getElementById('eu-password').value = ''; @@ -349,8 +313,7 @@ try { await LS.adminUpdateUser(_editUid, payload); closeEditUserModal(); - await reloadUserPanel(activeUid); - load(); + reloadDetailAndList(); } catch (e) { errEl.textContent = 'Ошибка: ' + e.message; } finally { @@ -365,13 +328,14 @@ } async function openUserPermsModal() { - if (!activeUid) return; - const name = document.getElementById('up-name').textContent; + const uid = getActiveUid(); + if (!uid) return; + const name = _activeName(); document.getElementById('up-modal-title').textContent = `Права: ${name}`; document.getElementById('up-modal-list').innerHTML = LS.skeleton(5, 'row'); document.getElementById('up-modal').classList.add('open'); try { - _upPermsData = await LS.getUserPermissions(activeUid); + _upPermsData = await LS.getUserPermissions(uid); renderUserPerms(); } catch(e) { document.getElementById('up-modal-list').innerHTML = `

Ошибка: ${esc(e.message)}

`; @@ -415,10 +379,12 @@ } async function doSetUserPerm(key, enabled, checkbox) { + const uid = getActiveUid(); + if (!uid) return; checkbox.disabled = true; try { - await LS.setUserPermission(activeUid, key, enabled); - _upPermsData = await LS.getUserPermissions(activeUid); + await LS.setUserPermission(uid, key, enabled); + _upPermsData = await LS.getUserPermissions(uid); renderUserPerms(); LS.toast(enabled ? 'Право включено' : 'Право отключено', 'success'); } catch(e) { @@ -430,20 +396,24 @@ } async function doResetOneUserPerm(key) { + const uid = getActiveUid(); + if (!uid) return; try { - await LS.resetUserPermissions(activeUid, key); - _upPermsData = await LS.getUserPermissions(activeUid); + await LS.resetUserPermissions(uid, key); + _upPermsData = await LS.getUserPermissions(uid); renderUserPerms(); LS.toast('Сброшено к значению роли', 'success'); } catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); } } async function doResetAllUserPerms() { - const name = document.getElementById('up-name').textContent; + const uid = getActiveUid(); + if (!uid) return; + const name = _activeName(); if (!await LS.confirm(`Сбросить все индивидуальные права «${name}»?\nБудут применены права роли.`, { title: 'Сбросить права', confirmText: 'Сбросить' })) return; try { - await LS.resetUserPermissions(activeUid); - _upPermsData = await LS.getUserPermissions(activeUid); + await LS.resetUserPermissions(uid); + _upPermsData = await LS.getUserPermissions(uid); renderUserPerms(); LS.toast('Права сброшены к роли', 'success'); } catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); } @@ -453,9 +423,6 @@ window.loadUsers = load; window.gotoUsersPage = gotoUsersPage; window.changeRole = changeRole; - window.openUserPanel = openUserPanel; - window.reloadUserPanel = reloadUserPanel; - window.closeUserPanel = closeUserPanel; window.clearUserHistory = clearUserHistory; window.toggleBanUser = toggleBanUser; window.confirmDeleteUser = confirmDeleteUser; diff --git a/plans/admin-redesign/CONTEXT.md b/plans/admin-redesign/CONTEXT.md index b78ea2d..ff27371 100644 --- a/plans/admin-redesign/CONTEXT.md +++ b/plans/admin-redesign/CONTEXT.md @@ -9,7 +9,18 @@ - ✅ 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 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 +- ✅ Phase 6 implemented (sub-commits bd30200 + new) — deep entity pages replace legacy `.user-panel` overlay. NEW: `frontend/js/admin/sections/user-detail.js` (~370L) and `frontend/js/admin/sections/session-detail.js` (~180L), both IIFE pattern. `admin.js` has `DEEP_ROUTES = { users:'user-detail', sessions:'session-detail' }` + `activateDeepPane()`; `activate(route, params)` checks for first-param to dispatch deep page (parent nav-item stays highlighted). Sub-tabs (overview/sessions/classes/audit) with URL sync via `udSwitchTab()` → `AdminRouter.navigate('#users/N/', { replace: true, silent: true })`. Backend endpoints reused: `GET /api/admin/users/:id/sessions` (user history), `GET /api/admin/sessions/:id` (session detail), `GET /api/admin/audit-log?limit=500` (client-side filtered by uid for Audit tab). Removed: `
` overlay HTML, `.user-panel*` CSS, `openUserPanel`/`closeUserPanel`/`reloadUserPanel` JS, `toggleDrawer`/`renderDrawer` in sessions.js. Row onclick: `openUserPanel(...)` → `AdminRouter.navigate('#users/N')`; sessions row → `gotoSession(id)` → `AdminRouter.navigate('#sessions/N')`. `clearUserHistory`/`toggleBanUser`/`confirmDeleteUser` now use `getActiveUid()` helper (reads `window.activeUid` set by user-detail.init) instead of overlay closure. `quickOpenUserSessions(uid)` → `#users//sessions` (deep page, Sessions sub-tab). Classes sub-tab is placeholder (no per-user classes endpoint exists). Charts: simple inline SVG bar chart for per-subject avg %. + +## Phase 6 Routes Glossary + +- `#users` — list (Phase 2 section) +- `#users/123` — deep page, default Overview sub-tab +- `#users/123/sessions` — deep page, Sessions sub-tab +- `#users/123/classes` — deep page, Classes sub-tab (placeholder) +- `#users/123/audit` — deep page, Audit sub-tab (admin only) +- `#sessions` — list (Phase 2 section) +- `#sessions/456` — deep page +- Cmd+K palette user pick → `#users/N` (opens deep page) ## Temporary Workarounds diff --git a/plans/admin-redesign/PLAN.md b/plans/admin-redesign/PLAN.md index 5c3f4b1..1a870e3 100644 --- a/plans/admin-redesign/PLAN.md +++ b/plans/admin-redesign/PLAN.md @@ -40,7 +40,7 @@ - [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) - [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) +- [x] Phase 6: Deep entity pages [domain: frontend] → [subplan](./phase-6-deep-pages.md) **Параллелизация:** фазы 3, 4, 5 независимы (touch different files, no shared state) — выполняются параллельно после завершения фазы 2. @@ -53,7 +53,7 @@ | Phase 3: Dashboard | fullstack | ✅ Done | ✅ PASS w/ 3 SQL warnings (post-merge polish) | ✅ | ✅ 41acbdd | | Phase 4: Palette | fullstack | ✅ Done | ✅ PASS w/ notes (limit param cleanup applied) | ✅ | ✅ f562fe4 | | Phase 5: Quick actions | frontend | ✅ Done | ✅ PASS w/ notes | ✅ | ✅ 69113ab | -| Phase 6: Deep pages | frontend | 🟡 In Progress | ⬜ | ✅ node --check | ⬜ | +| Phase 6: Deep pages | frontend | ✅ Done | ⬜ | ✅ node --check | ⬜ (2 sub-commits: bd30200 + new) | ## Final Review - [ ] Comprehensive code review (final-reviewer agent) diff --git a/plans/admin-redesign/phase-6-deep-pages.md b/plans/admin-redesign/phase-6-deep-pages.md index e0cd262..6b319c4 100644 --- a/plans/admin-redesign/phase-6-deep-pages.md +++ b/plans/admin-redesign/phase-6-deep-pages.md @@ -1,6 +1,6 @@ # Phase 6: Deep entity pages -**Status:** 🟡 In Progress (sub-commit 1 of 2 done) +**Status:** ✅ Done (sub-commits: bd30200 + final remove-overlay commit) **Parent plan:** [PLAN.md](./PLAN.md) **Domain:** frontend @@ -10,7 +10,7 @@ ## Tasks -- [ ] **User detail page** (`frontend/js/admin/sections/user-detail.js`): +- [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` @@ -22,23 +22,19 @@ - **Graphs** (опционально, можно отдельным таб'ом): - Простой SVG-чарт: успеваемость по неделям - Mini-bar chart: avg % по предметам -- [ ] **Session detail page** (`frontend/js/admin/sections/session-detail.js`): +- [x] **Session detail page** (`frontend/js/admin/sections/session-detail.js`): - Реагирует на route `#sessions/:id` - Layout: header (user, subject, score, дата) + список вопросов/ответов (правильно/нет, текст), back-link -- [ ] **Router updates** (`frontend/js/admin/router.js` если ещё не поддерживает): - - `#users/123` → emit { route: 'users', params: ['123'] } - - `#sessions/456` → emit { route: 'sessions', params: ['456'] } -- [ ] **Admin.js dispatch**: - - При route с params → init detail-section вместо list-section - - При route без params → init list-section (как раньше) -- [ ] **Удалить overlay-код:** - - В `frontend/admin.html` удалить `
` блок - - В `sections/users.js` удалить `openUserPanel`, `closeUserPanel`, `reloadUserPanel` - - В `sections/users.js` поменять onclick: `onclick="openUserPanel(event,${u.id},'${u.role}')"` → `onclick="AdminRouter.navigate('#users/${u.id}')"` -- [ ] **Replace** в Phase 5 quick action "Sessions" — теперь `AdminRouter.navigate('#users/${uid}/sessions')`: - - Парсить sub-tab из route +- [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 -- [ ] **Глоссарий routes после фазы:** +- [x] **Глоссарий routes после фазы:** - `#overview` — dashboard (Phase 3) - `#users` — list - `#users/123` — user detail (overview tab default) @@ -112,6 +108,38 @@ ## Handoff to Next Phase - +Это финальная фаза. Что реализовано в этой фазе: + +### Done + +- Deep page `#users/:id` с sub-tabs (overview/sessions/classes/audit) и URL-sync sub-routing +- Deep page `#sessions/:id` с полным разбором ответов +- F5 / browser-back / закладки работают на любом deep-URL +- Overlay `.user-panel` полностью удалён (HTML + CSS + JS) +- Sessions row-click переключён с inline drawer на deep page navigation (`gotoSession(id)`) +- Audit sub-tab фильтрует system-wide audit-log по uid client-side +- Inline SVG bar chart для per-subject avg % на Overview sub-tab (no chart.js dep) +- Cmd+K palette user-pick (Phase 4) теперь открывает deep page (ранее был fallback на `#users`) +- 2 sub-commit разбивка для безопасности: `bd30200` (add deep pages, overlay still works) → finale (remove overlay) + +### Post-merge follow-ups (NOT блокирующие для merge) + +1. **Classes sub-tab — placeholder.** Нет backend endpoint `GET /admin/users/:id/classes`. Текущий UI показывает empty-state со ссылкой на `/classes`. После merge добавить endpoint (выбрать из `class_members` по `user_id`). +2. **Audit sub-tab — client-side filter.** Фильтрация делается на 500 строк из общего лога — для бóльших инсталляций нужен `GET /admin/audit-log?user_id=N` (server-side). Сейчас работает корректно для типичной нагрузки LearnSpace (<10k записей). +3. **Charts — single bar chart only.** План включал опциональные графики "успеваемость по неделям" — оставил на post-merge. Использовать тот же inline SVG паттерн (`.ud-bars` + `.ud-bar-fill`). +4. **Mobile.** Header кнопки сжимаются на ≤640px (см. CSS `.ud-header` block в user-detail.js), но при большом количестве действий могут перекрыть. Можно добавить overflow menu (`
`) post-merge если жалобы. +5. **`.user-panel` CSS не полностью удалён** — оставлен только `.btn-close` (используется ещё где-то?). Если нет — можно удалить тоже. +6. **`window.activeUid` — глобальное состояние.** Сейчас и user-detail.js, и users.js пишут/читают `window.activeUid`. Это работает, но в идеале нужно перенести user-only modals (eu-modal, up-modal) в user-detail.js целиком. Не критично, но улучшит изоляцию. + +### Final smoke checklist (для final-reviewer) + +- [ ] Открыть `/admin#overview` — dashboard +- [ ] Cmd+K → найти юзера → пик из списка → открыть deep page +- [ ] На deep page переключить sub-tabs (URL обновляется) +- [ ] F5 на `#users/N/sessions` → page восстановлен +- [ ] Browser back → возврат на `#users` list +- [ ] Header action: Изменить → modal → save → header обновлён +- [ ] Header action: Бан → toast → header обновлён (метка "Разблокировать") +- [ ] Click on session row in Sessions sub-tab → `#sessions/M` (deep session page) +- [ ] Session page: Delete → toast → navigate back to `#sessions` list +- [ ] No console errors