diff --git a/frontend/admin.html b/frontend/admin.html index 971bea7..6c06a18 100644 --- a/frontend/admin.html +++ b/frontend/admin.html @@ -1143,6 +1143,16 @@ + +
+
+
+ + +
+
+
+
@@ -2006,6 +2016,8 @@ + +
diff --git a/frontend/js/admin/admin.js b/frontend/js/admin/admin.js index 64737f4..4877b50 100644 --- a/frontend/js/admin/admin.js +++ b/frontend/js/admin/admin.js @@ -69,6 +69,32 @@ sublog: 'sublog', }; + /* Phase 6: deep entity pages. When a route has a first param (#users/123), + * dispatch to the matching detail section instead of the list section. + * Detail sections render into hidden tab-panes (#tab-user-detail / #tab-session-detail) + * which are activated by activateDeepPane() below. The "parent" nav item + * (Пользователи / Тесты) stays highlighted so users know where they are. */ + const DEEP_ROUTES = { + users: { section: 'user-detail', paneId: 'tab-user-detail', parentTab: 'users' }, + sessions: { section: 'session-detail', paneId: 'tab-session-detail', parentTab: 'sessions' }, + }; + + function activateDeepPane(deepInfo, params) { + // Activate the parent nav item visually (so user knows the section), + // but show the deep-page pane instead of the list pane. + document.querySelectorAll('.tab-pane').forEach(p => p.classList.remove('active')); + document.querySelectorAll('.admin-nav-item').forEach(b => b.classList.remove('active')); + const parentBtn = document.querySelector('.admin-nav-item[data-tab="' + deepInfo.parentTab + '"]'); + if (parentBtn) parentBtn.classList.add('active'); + const pane = document.getElementById(deepInfo.paneId); + if (pane) pane.classList.add('active'); + const sec = AdminSections[deepInfo.section]; + if (sec && typeof sec.init === 'function') { + // params: [id, subTab?] + sec.init(params[0], params[1]); + } + } + function switchTab(btn, opts) { if (btn.classList.contains('locked')) { LS.toast('Этот раздел доступен только администраторам', 'warn'); @@ -670,8 +696,17 @@ (function initAdminRouter() { if (!window.AdminRouter) return; - function activate(route) { + function activate(route, params) { const name = route || 'overview'; + params = Array.isArray(params) ? params : []; + + // Phase 6: deep page dispatch when route has a first param. + const deep = DEEP_ROUTES[name]; + if (deep && params.length > 0 && AdminSections[deep.section]) { + activateDeepPane(deep, params); + return; + } + const btn = document.querySelector('.admin-nav-item[data-tab="' + name + '"]'); if (!btn) { console.warn('AdminRouter: unknown route', name); @@ -690,13 +725,13 @@ switchTab(btn, { fromRouter: true }); } - AdminRouter.on('change', (r) => activate(r.route)); + AdminRouter.on('change', (r) => activate(r.route, r.params)); // Initial dispatch: respect existing hash, else default to #overview. const initial = AdminRouter.current(); if (!initial.route) { AdminRouter.navigate('#overview', { replace: true, silent: true }); - } else if (initial.route !== 'overview') { - activate(initial.route); + } else if (initial.route !== 'overview' || initial.params.length > 0) { + activate(initial.route, initial.params); } })(); diff --git a/frontend/js/admin/sections/session-detail.js b/frontend/js/admin/sections/session-detail.js new file mode 100644 index 0000000..f7cfa20 --- /dev/null +++ b/frontend/js/admin/sections/session-detail.js @@ -0,0 +1,199 @@ +'use strict'; +/* admin → session-detail (Phase 6) — deep page for a single test session + * (#sessions/:id). Replaces the inline drawer rendering when a row is clicked. + * + * Lazy-init via AdminSections['session-detail'].init(id). + */ +(function () { + 'use strict'; + + /* ── one-time CSS injection ── */ + function ensureSdStyles() { + if (document.getElementById('session-detail-style')) return; + const s = document.createElement('style'); + s.id = 'session-detail-style'; + s.textContent = ` + .sd-wrap { padding: 4px 2px 24px; } + .sd-back { display:inline-flex; align-items:center; gap:6px; font-size:0.82rem; color:var(--text-3); text-decoration:none; padding:6px 10px; border-radius:8px; margin-bottom:16px; transition:background .12s, color .12s; cursor:pointer; background:transparent; border:0; font-family:inherit; } + .sd-back:hover { background:rgba(155,93,229,.07); color:var(--violet); } + .sd-back svg { width:14px; height:14px; } + .sd-header { display:flex; align-items:center; gap:20px; padding:22px 26px; background:var(--surface); border:1px solid var(--border); border-radius:var(--r-lg); margin-bottom:20px; flex-wrap:wrap; } + .sd-user-block { flex:1; min-width:200px; } + .sd-user-name { font-family:'Unbounded',sans-serif; font-size:1.05rem; font-weight:800; cursor:pointer; transition:color .12s; } + .sd-user-name:hover { color:var(--violet); } + .sd-user-meta { font-size:0.82rem; color:var(--text-3); margin-top:4px; } + .sd-score { font-family:'Unbounded',sans-serif; font-size:1.5rem; font-weight:800; padding:14px 20px; border-radius:16px; } + .sd-score.pct-hi { color:var(--green); background:rgba(16,185,129,.1); } + .sd-score.pct-mid { color:var(--amber); background:rgba(255,179,71,.12); } + .sd-score.pct-lo { color:var(--pink); background:rgba(241,91,181,.1); } + .sd-score.pct-none { color:var(--text-3); background:rgba(15,23,42,.04); } + .sd-stats { display:flex; gap:24px; flex-wrap:wrap; } + .sd-stat { text-align:center; } + .sd-stat-val { font-family:'Unbounded',sans-serif; font-weight:700; font-size:0.95rem; } + .sd-stat-val.correct { color:var(--green); } + .sd-stat-val.wrong { color:var(--pink); } + .sd-stat-val.skipped { color:var(--text-3); } + .sd-stat-label { font-size:0.72rem; color:var(--text-3); margin-top:2px; } + .sd-actions { display:flex; gap:6px; flex-wrap:wrap; margin-left:auto; } + .sd-q-list { display:flex; flex-direction:column; gap:10px; } + .sd-q-item { padding:16px 18px; background:var(--surface); border:1px solid var(--border); border-radius:14px; border-left:4px solid var(--text-3); } + .sd-q-item.correct { border-left-color:var(--green); } + .sd-q-item.wrong { border-left-color:var(--pink); } + .sd-q-item.skipped { border-left-color:var(--amber); } + .sd-q-header { display:flex; align-items:center; gap:10px; margin-bottom:8px; flex-wrap:wrap; } + .sd-q-num { font-size:0.74rem; color:var(--text-3); font-weight:700; text-transform:uppercase; letter-spacing:.04em; } + .sd-q-badge { font-size:0.7rem; padding:2px 8px; border-radius:var(--r-pill); font-weight:700; } + .sd-q-badge.correct { background:rgba(16,185,129,.12); color:var(--green); } + .sd-q-badge.wrong { background:rgba(241,91,181,.12); color:var(--pink); } + .sd-q-badge.skipped { background:rgba(255,179,71,.14); color:var(--amber); } + .sd-q-time { font-size:0.72rem; color:var(--text-3); margin-left:auto; } + .sd-q-text { font-size:0.92rem; line-height:1.45; margin-bottom:10px; } + .sd-q-opts { display:flex; flex-direction:column; gap:5px; } + .sd-q-opt { padding:8px 12px; border-radius:8px; background:rgba(15,23,42,.03); font-size:0.86rem; display:flex; align-items:center; gap:8px; } + .sd-q-opt.correct-opt { background:rgba(16,185,129,.08); color:var(--green); font-weight:600; } + .sd-q-opt.chosen-wrong { background:rgba(241,91,181,.08); color:var(--pink); font-weight:600; } + .sd-q-opt-icon { width:14px; height:14px; flex-shrink:0; display:inline-flex; } + .sd-q-expl { margin-top:10px; padding:10px 14px; background:rgba(155,93,229,.06); border-radius:8px; font-size:0.84rem; color:var(--text-2); } + .sd-empty { padding:30px; text-align:center; color:var(--text-3); font-size:0.88rem; background:var(--surface); border:1px dashed var(--border); border-radius:var(--r-lg); } + @media (max-width: 640px) { + .sd-header { padding:16px 14px; gap:14px; } + .sd-actions { margin-left:0; width:100%; } + .sd-score { font-size:1.2rem; padding:10px 14px; } + .sd-stats { gap:14px; } + } + `; + document.head.appendChild(s); + } + + const ICONS = { + arrowLeft: '', + trash: '', + }; + + let _sessionId = null; + let _data = null; + + async function init(id) { + ensureSdStyles(); + const newId = Number(id); + if (!Number.isFinite(newId) || newId <= 0) { + renderError('Некорректный ID сессии'); + return; + } + if (_sessionId === newId && _data) { + render(); + return; + } + _sessionId = newId; + _data = null; + renderLoading(); + try { + _data = await LS.adminGetSessionDetail(newId); + render(); + } catch (e) { + renderError(e.message || String(e)); + } + } + + function renderLoading() { + const el = document.getElementById('session-detail-content'); + if (!el) return; + el.innerHTML = '
'; + } + + function renderError(msg) { + const el = document.getElementById('session-detail-content'); + if (!el) return; + el.innerHTML = `
+ +
${esc(msg)}
+
`; + } + + function render() { + const el = document.getElementById('session-detail-content'); + if (!el || !_data) return; + const d = _data; + const { MODES, pctClass, fmtDate, fmtTime, renderMath } = AdminCtx; + const pct = (d.score !== null && d.score !== undefined && d.total) + ? Math.round((d.score / d.total) * 100) + : null; + const pc = pct === null ? 'pct-none' : 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 isAdmin = AdminCtx.isAdmin; + + 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 || '')} · ${esc(d.subject_name || 'Тест')} · ${MODES[d.mode] || d.mode}
+
${fmtDate(d.started_at)}${d.finished_at ? ' · завершена ' + fmtDate(d.finished_at) : ''}
+
+
${pct !== null ? pct + '%' : '—'}
+
+
${correct}
Верно
+
${wrong}
Неверно
+
${skipped}
Пропущено
+
${fmtTime(d.duration_sec)}
Время
+
+ ${isAdmin ? `
+ +
` : ''} +
+
${qHtml || '
Вопросы не найдены
'}
+
+ `; + renderMath(el); + if (window.lucide) lucide.createIcons({ nodes: [el] }); + } + + async function deleteSession() { + if (!_sessionId) return; + if (!await LS.confirm( + 'Удалить эту сессию? Все ответы и связанные данные будут удалены.\nЭто действие нельзя отменить.', + { title: 'Удалить сессию', confirmText: 'Удалить' } + )) return; + try { + await LS.adminDeleteSession(_sessionId); + LS.toast('Сессия удалена', 'success'); + AdminRouter.navigate('#sessions'); + } catch (e) { + LS.toast('Ошибка: ' + e.message, 'error'); + } + } + + /* ── Expose ── */ + window.sdDeleteSession = deleteSession; + + window.AdminSections = window.AdminSections || {}; + window.AdminSections['session-detail'] = { + init, + reload: () => init(_sessionId), + }; +})(); diff --git a/frontend/js/admin/sections/user-detail.js b/frontend/js/admin/sections/user-detail.js new file mode 100644 index 0000000..a1c76a2 --- /dev/null +++ b/frontend/js/admin/sections/user-detail.js @@ -0,0 +1,423 @@ +'use strict'; +/* admin → user-detail (Phase 6) — deep page for a single user (#users/:id). + * + * Replaces the legacy `.user-panel` overlay. Lazy-init via + * AdminSections['user-detail'].init(id, subTab) + * where subTab ∈ 'overview' | 'sessions' | 'classes' | 'audit'. + * + * Reuses existing user-related modals (openEditUserModal, openUserPermsModal, + * etc.) — they live in sections/users.js and operate on `window.activeUid`, + * which we set before opening any of them. + */ +(function () { + 'use strict'; + + /* ── one-time CSS injection ── */ + function ensureUdStyles() { + if (document.getElementById('user-detail-style')) return; + const s = document.createElement('style'); + s.id = 'user-detail-style'; + s.textContent = ` + .ud-wrap { padding: 4px 2px 24px; } + .ud-back { display:inline-flex; align-items:center; gap:6px; font-size:0.82rem; color:var(--text-3); text-decoration:none; padding:6px 10px; border-radius:8px; margin-bottom:16px; transition:background .12s, color .12s; cursor:pointer; background:transparent; border:0; font-family:inherit; } + .ud-back:hover { background:rgba(155,93,229,.07); color:var(--violet); } + .ud-back svg { width:14px; height:14px; } + .ud-header { display:flex; align-items:flex-start; gap:20px; padding:24px 26px; background:var(--surface); border:1px solid var(--border); border-radius:var(--r-lg); margin-bottom:20px; flex-wrap:wrap; } + .ud-avatar { width:64px; height:64px; border-radius:18px; display:flex; align-items:center; justify-content:center; font-family:'Unbounded',sans-serif; font-size:1.1rem; font-weight:800; color:#fff; flex-shrink:0; } + .ud-avatar.banned { filter:grayscale(1); opacity:.6; } + .ud-id-block { flex:1; min-width:200px; } + .ud-name { font-family:'Unbounded',sans-serif; font-size:1.25rem; font-weight:800; line-height:1.2; display:flex; align-items:center; gap:10px; flex-wrap:wrap; } + .ud-name .ud-role-badge { font-size:0.7rem; padding:3px 9px; border-radius:var(--r-pill); font-weight:700; letter-spacing:.02em; vertical-align:middle; } + .ud-name .ud-banned-tag { font-size:0.66rem; padding:2px 7px; border-radius:4px; background:rgba(239,68,68,.12); color:#EF4444; font-weight:700; } + .ud-email { font-size:0.88rem; color:var(--text-3); margin-top:6px; } + .ud-meta-row { display:flex; gap:18px; margin-top:10px; font-size:0.76rem; color:var(--text-3); flex-wrap:wrap; } + .ud-meta-row strong { color:var(--text-2); font-weight:600; } + .ud-actions { display:flex; flex-wrap:wrap; gap:6px; align-items:flex-start; margin-left:auto; } + .ud-actions .btn-edit-q, .ud-actions .btn-del-q { white-space:nowrap; } + .ud-tabs { display:flex; gap:2px; border-bottom:1px solid var(--border); margin-bottom:20px; overflow-x:auto; } + .ud-tab-btn { background:transparent; border:0; padding:11px 18px; font-family:inherit; font-size:0.86rem; font-weight:600; color:var(--text-3); cursor:pointer; border-bottom:2px solid transparent; transition:color .12s, border-color .12s; white-space:nowrap; } + .ud-tab-btn:hover { color:var(--text-2); } + .ud-tab-btn.active { color:var(--violet); border-bottom-color:var(--violet); } + .ud-tab-pane { display:none; } + .ud-tab-pane.active { display:block; } + .ud-stats { display:grid; grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); gap:14px; margin-bottom:24px; } + .ud-stat { padding:18px 18px; background:var(--surface); border:1px solid var(--border); border-radius:var(--r-lg); } + .ud-stat-val { font-family:'Unbounded',sans-serif; font-size:1.5rem; font-weight:800; line-height:1.1; } + .ud-stat-val.pct-hi { color:var(--green); } + .ud-stat-val.pct-mid { color:var(--amber); } + .ud-stat-val.pct-lo { color:var(--pink); } + .ud-stat-label { font-size:0.74rem; color:var(--text-3); font-weight:600; text-transform:uppercase; letter-spacing:.03em; margin-top:6px; } + .ud-sess-list { display:flex; flex-direction:column; gap:6px; } + .ud-sess-row { display:flex; align-items:center; gap:14px; padding:12px 16px; background:var(--surface); border:1px solid var(--border); border-radius:12px; cursor:pointer; transition:border-color .12s, background .12s; } + .ud-sess-row:hover { border-color:rgba(155,93,229,.35); background:rgba(155,93,229,.04); } + .ud-sess-pct { font-family:'Unbounded',sans-serif; font-weight:800; font-size:0.9rem; width:50px; text-align:center; padding:6px 0; border-radius:8px; } + .ud-sess-pct.pct-hi { color:var(--green); background:rgba(16,185,129,.1); } + .ud-sess-pct.pct-mid { color:var(--amber); background:rgba(255,179,71,.12); } + .ud-sess-pct.pct-lo { color:var(--pink); background:rgba(241,91,181,.1); } + .ud-sess-info { flex:1; min-width:0; } + .ud-sess-subj { font-weight:600; font-size:0.9rem; } + .ud-sess-meta { font-size:0.76rem; color:var(--text-3); margin-top:2px; } + .ud-sess-score { font-weight:700; font-size:0.88rem; } + .ud-sess-chev { color:var(--text-3); flex-shrink:0; } + .ud-empty { padding:30px; text-align:center; color:var(--text-3); font-size:0.88rem; background:var(--surface); border:1px dashed var(--border); border-radius:var(--r-lg); } + .ud-audit-list { display:flex; flex-direction:column; gap:6px; } + .ud-audit-row { display:flex; gap:14px; padding:10px 14px; background:var(--surface); border:1px solid var(--border); border-radius:10px; font-size:0.84rem; align-items:center; flex-wrap:wrap; } + .ud-audit-when { font-size:0.74rem; color:var(--text-3); min-width:140px; } + .ud-audit-action { font-weight:700; font-size:0.78rem; } + .ud-audit-detail { color:var(--text-3); font-size:0.78rem; flex:1; min-width:140px; overflow:hidden; text-overflow:ellipsis; } + .ud-chart-card { padding:18px 20px; background:var(--surface); border:1px solid var(--border); border-radius:var(--r-lg); margin-top:20px; } + .ud-chart-title { font-size:0.78rem; font-weight:700; text-transform:uppercase; letter-spacing:.04em; color:var(--text-3); margin-bottom:12px; } + .ud-bars { display:flex; flex-direction:column; gap:8px; } + .ud-bar-row { display:flex; align-items:center; gap:10px; } + .ud-bar-name { font-size:0.84rem; min-width:120px; } + .ud-bar-track { flex:1; height:18px; background:rgba(15,23,42,.06); border-radius:6px; overflow:hidden; position:relative; } + .ud-bar-fill { height:100%; border-radius:6px; transition:width .3s; } + .ud-bar-fill.pct-hi { background:var(--green); } + .ud-bar-fill.pct-mid { background:var(--amber); } + .ud-bar-fill.pct-lo { background:var(--pink); } + .ud-bar-val { font-family:'Unbounded',sans-serif; font-size:0.82rem; font-weight:700; min-width:48px; text-align:right; } + @media (max-width: 640px) { + .ud-header { padding:18px 16px; gap:14px; } + .ud-actions { margin-left:0; width:100%; } + .ud-actions .btn-edit-q, .ud-actions .btn-del-q { font-size:0.78rem; padding:6px 10px; } + .ud-sess-row { padding:10px 12px; gap:10px; } + .ud-sess-meta { font-size:0.72rem; } + } + `; + document.head.appendChild(s); + } + + const ROLE_LABEL = { student:'Ученик', free_student:'Своб. ученик', teacher:'Учитель', admin:'Админ' }; + const ROLE_BG = { + admin: 'linear-gradient(135deg,#9B5DE5,#c084fc)', + teacher: 'linear-gradient(135deg,#06D6E0,#9B5DE5)', + free_student: 'linear-gradient(135deg,#10B981,#059669)', + student: 'linear-gradient(135deg,#8898AA,#3D4F6B)', + }; + const ROLE_BADGE_BG = { + admin: 'rgba(155,93,229,.14)', teacher: 'rgba(6,214,224,.14)', + free_student: 'rgba(16,185,129,.14)', student: 'rgba(136,152,170,.14)', + }; + const ROLE_BADGE_FG = { + admin: 'var(--violet)', teacher: '#05aab3', + free_student: 'var(--green)', student: 'var(--text-2)', + }; + + /* SVG icons */ + const ICONS = { + arrowLeft: '', + chev: '', + ban: '', + }; + + /* State */ + let _userId = null; + let _userData = null; // last fetched user object + let _sessions = []; // last fetched sessions array + let _activeSubTab = 'overview'; + + /* ── Public init: called by admin.js dispatch ── */ + async function init(id, subTab) { + ensureUdStyles(); + const newId = Number(id); + if (!Number.isFinite(newId) || newId <= 0) { + renderError('Некорректный ID пользователя'); + return; + } + _activeSubTab = subTab || 'overview'; + // Make user-related modal handlers (openEditUserModal etc.) work — they read window.activeUid. + window.activeUid = newId; + + if (_userId === newId && _userData) { + // Same user — just switch sub-tab without re-fetch + renderShell(); + switchSubTab(_activeSubTab, /*pushUrl*/ false); + return; + } + _userId = newId; + _userData = null; + _sessions = []; + renderLoading(); + try { + const data = await LS.adminGetUserSessions(newId); + _userData = data.user; + _sessions = Array.isArray(data.sessions) ? data.sessions : []; + // Sync globals used by overlay-era modal helpers (still live in users.js). + window.activeUid = newId; + window.activeUserRole = _userData?.role || null; + renderShell(); + switchSubTab(_activeSubTab, /*pushUrl*/ false); + } catch (e) { + renderError(e.message || String(e)); + } + } + + function renderLoading() { + const el = document.getElementById('user-detail-content'); + if (!el) return; + el.innerHTML = '
'; + } + + function renderError(msg) { + const el = document.getElementById('user-detail-content'); + if (!el) return; + el.innerHTML = `
+ +
${esc(msg)}
+
`; + } + + function renderShell() { + const el = document.getElementById('user-detail-content'); + if (!el || !_userData) return; + const u = _userData; + const isAdmin = AdminCtx.isAdmin; + const isSelf = AdminCtx.user && AdminCtx.user.id === u.id; + const canAct = isAdmin && !isSelf; + const initials = (u.name || '?').split(' ').slice(0, 2).map(w => (w[0] || '').toUpperCase()).join('') || '?'; + const avatarBg = ROLE_BG[u.role] || ROLE_BG.student; + const roleLabel = ROLE_LABEL[u.role] || u.role; + const bannedTag = u.is_banned ? ' заблокирован' : ''; + const banLabel = u.is_banned ? 'Разблокировать' : 'Заблокировать'; + + const actions = canAct ? ` +
+ + ${u.role === 'teacher' ? '' : ''} + + + +
` : ''; + + const created = u.created_at ? AdminCtx.fmtDate(u.created_at) : '—'; + const lastLog = u.last_login ? new Date(u.last_login).toLocaleString('ru', { day:'numeric', month:'short', year:'numeric', hour:'2-digit', minute:'2-digit' }) : '—'; + + el.innerHTML = ` +
+ +
+
${esc(initials)}
+
+
+ ${esc(u.name)} + ${roleLabel} + ${bannedTag} +
+
${esc(u.email || '')}
+
+ Регистрация: ${created} + Последний вход: ${lastLog} + ID: #${u.id} +
+
+ ${actions} +
+
+ + + + ${isAdmin ? '' : ''} +
+
+
+
+
+
+ `; + if (window.lucide) lucide.createIcons({ nodes: [el] }); + } + + function switchSubTab(name, pushUrl) { + const allowed = ['overview', 'sessions', 'classes', 'audit']; + if (!allowed.includes(name)) name = 'overview'; + _activeSubTab = name; + document.querySelectorAll('#user-detail-content .ud-tab-btn').forEach(b => { + b.classList.toggle('active', b.dataset.st === name); + }); + document.querySelectorAll('#user-detail-content .ud-tab-pane').forEach(p => p.classList.remove('active')); + const pane = document.getElementById('ud-pane-' + name); + if (pane) pane.classList.add('active'); + + if (pushUrl && window.AdminRouter && _userId) { + const target = name === 'overview' ? `#users/${_userId}` : `#users/${_userId}/${name}`; + AdminRouter.navigate(target, { replace: true, silent: true }); + } + + if (name === 'overview') renderOverview(); + else if (name === 'sessions') renderSessions(); + else if (name === 'classes') renderClasses(); + else if (name === 'audit') renderAudit(); + } + + /* ── Overview tab ── */ + function renderOverview() { + const pane = document.getElementById('ud-pane-overview'); + if (!pane || !_userData) return; + const u = _userData; + const total = _sessions.length; + const completed = _sessions.filter(s => s.score !== null && s.score !== undefined); + const avgPct = completed.length + ? Math.round(completed.reduce((acc, s) => acc + Math.round((s.score / s.total) * 100), 0) / completed.length) + : null; + const pcCls = AdminCtx.pctClass(avgPct); + const lastSess = _sessions[0]; + const lastDate = lastSess ? AdminCtx.fmtDate(lastSess.started_at) : '—'; + + // Aggregate by subject for simple bar chart + const bySubj = {}; + completed.forEach(s => { + const k = s.subject_name || 'Без предмета'; + bySubj[k] = bySubj[k] || { sum: 0, n: 0 }; + bySubj[k].sum += Math.round((s.score / s.total) * 100); + bySubj[k].n += 1; + }); + const subjBars = Object.entries(bySubj) + .map(([name, v]) => ({ name, pct: Math.round(v.sum / v.n), n: v.n })) + .sort((a, b) => b.n - a.n) + .slice(0, 6); + + const barHtml = subjBars.length ? subjBars.map(b => { + const pc = AdminCtx.pctClass(b.pct); + return `
+
${esc(b.name)} (${b.n})
+
+
${b.pct}%
+
`; + }).join('') : '
Нет данных по предметам
'; + + pane.innerHTML = ` +
+
+
${total}
+
Всего сессий
+
+
+
${avgPct !== null ? avgPct + '%' : '—'}
+
Средний %
+
+
+
${u.created_at ? AdminCtx.fmtDate(u.created_at) : '—'}
+
Регистрация
+
+
+
${lastDate}
+
Последняя сессия
+
+
+
+
Успеваемость по предметам
+
${barHtml}
+
+ `; + } + + /* ── Sessions tab ── */ + function renderSessions() { + const pane = document.getElementById('ud-pane-sessions'); + if (!pane) return; + if (!_sessions.length) { + pane.innerHTML = '
Тестов нет
'; + return; + } + const { MODES, pctClass, fmtDate } = AdminCtx; + pane.innerHTML = '
' + _sessions.map(s => { + const pct = (s.score !== null && s.score !== undefined && s.total) + ? Math.round((s.score / s.total) * 100) + : null; + const pc = pctClass(pct); + return `
+
${pct !== null ? pct + '%' : '—'}
+
+
${esc(s.subject_name || 'Тест')}
+
${fmtDate(s.started_at)} · ${MODES[s.mode] || s.mode}
+
+
${s.score ?? '—'} / ${s.total}
+
${ICONS.chev}
+
`; + }).join('') + '
'; + } + + /* ── Classes tab ── */ + /* No per-user "classes" endpoint exists; show empty state pointing to the + * Classes section. Post-merge: add GET /admin/users/:id/classes for full list. + */ + function renderClasses() { + const pane = document.getElementById('ud-pane-classes'); + if (!pane) return; + pane.innerHTML = `
+ Информация о классах пользователя пока недоступна.
+ Открыть управление классами → +
`; + } + + /* ── Audit tab ── */ + /* audit_log is system-wide; filter client-side by target containing user_id + * or by admin_id if this user IS an admin. */ + async function renderAudit() { + const pane = document.getElementById('ud-pane-audit'); + if (!pane) return; + pane.innerHTML = '
'; + try { + const rows = await LS.api('/api/admin/audit-log?limit=500'); + const uid = _userId; + // Match if target string includes "user:" or "userId=" or starts with uid, + // or if admin_id equals uid (this user performed the action). + const re = new RegExp(`(^|\\D)${uid}(\\D|$)`); + const filtered = (rows || []).filter(r => { + if (r.admin_id === uid) return true; + if (r.target && re.test(String(r.target))) return true; + return false; + }); + if (!filtered.length) { + pane.innerHTML = '
Нет записей аудита, связанных с этим пользователем
'; + return; + } + const ACTION_LABELS = { + 'user.role_change': 'Смена роли', 'user.edit': 'Редактирование', 'user.ban': 'Блокировка', + 'user.unban': 'Разблокировка', 'user.delete': 'Удаление', 'user.clear_sessions': 'Очистка истории', + 'features.update': 'Фичи обновлены', 'topic.create': 'Создание темы', + 'topic.update': 'Редакт. темы', 'topic.delete': 'Удаление темы', + 'broadcast': 'Рассылка', 'session.delete': 'Удаление сессии', + }; + pane.innerHTML = '
' + filtered.map(r => { + const dt = new Date(r.created_at); + const when = dt.toLocaleDateString('ru', { day:'numeric', month:'short', year:'numeric' }) + + ' ' + dt.toLocaleTimeString('ru', { hour:'2-digit', minute:'2-digit' }); + const lbl = ACTION_LABELS[r.action] || r.action; + const who = r.admin_id === uid ? '(сам пользователь)' : (r.admin_name ? `от ${esc(r.admin_name)}` : ''); + return `
+ ${when} + ${esc(lbl)} + ${esc(r.detail || '')} ${who} +
`; + }).join('') + '
'; + } catch (e) { + pane.innerHTML = `
Ошибка загрузки аудита: ${esc(e.message)}
`; + } + } + + /* ── Reload after mutations (called from action handlers) ── */ + async function reload() { + if (!_userId) return; + try { + const data = await LS.adminGetUserSessions(_userId); + _userData = data.user; + _sessions = Array.isArray(data.sessions) ? data.sessions : []; + window.activeUserRole = _userData?.role || null; + renderShell(); + switchSubTab(_activeSubTab, /*pushUrl*/ false); + } catch (e) { + LS.toast('Не удалось обновить: ' + e.message, 'error'); + } + } + + /* ── Expose handlers used by inline onclicks ── */ + window.udSwitchTab = function (name) { switchSubTab(name, /*pushUrl*/ true); }; + + window.AdminSections = window.AdminSections || {}; + window.AdminSections['user-detail'] = { + /* Called by admin.js dispatch. id REQUIRED. subTab optional. */ + init, + reload, + }; +})(); diff --git a/frontend/js/admin/sections/users.js b/frontend/js/admin/sections/users.js index f97c65b..8743563 100644 --- a/frontend/js/admin/sections/users.js +++ b/frontend/js/admin/sections/users.js @@ -176,9 +176,8 @@ } 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'); + // Phase 6: open the user's deep page with the Sessions sub-tab active. + if (window.AdminRouter) AdminRouter.navigate('#users/' + uid + '/sessions'); else if (typeof window.switchTab === 'function') { const btn = document.querySelector('.admin-nav-item[onclick*="sessions"]'); if (btn) window.switchTab(btn); diff --git a/plans/admin-redesign/PLAN.md b/plans/admin-redesign/PLAN.md index 807081a..5c3f4b1 100644 --- a/plans/admin-redesign/PLAN.md +++ b/plans/admin-redesign/PLAN.md @@ -52,8 +52,8 @@ | 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 | ✅ 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 | ⬜ | ⬜ | ⬜ | +| Phase 5: Quick actions | frontend | ✅ Done | ✅ PASS w/ notes | ✅ | ✅ 69113ab | +| Phase 6: Deep pages | frontend | 🟡 In Progress | ⬜ | ✅ node --check | ⬜ | ## 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 6341146..e0cd262 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:** ⬜ Not Started +**Status:** 🟡 In Progress (sub-commit 1 of 2 done) **Parent plan:** [PLAN.md](./PLAN.md) **Domain:** frontend