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 = `
+
${ICONS.arrowLeft} К списку сессий
+
${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 `
+
+
${esc(q.text || '')}
+
${opts}
${expl}
+
`;
+ }).join('');
+
+ el.innerHTML = `
+
+
${ICONS.arrowLeft} К списку сессий
+
+
${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 = `
+
${ICONS.arrowLeft} К списку
+
${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' ? ' Права ' : ''}
+ История
+ ${ICONS.ban} ${banLabel}
+ Удалить
+
` : '';
+
+ 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 = `
+
+
${ICONS.arrowLeft} К списку пользователей
+
+
+ Обзор
+ Сессии
+ Классы
+ ${isAdmin ? 'Audit ' : ''}
+
+
+
+
+
+
+ `;
+ 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