'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, }; })();