Files
Learn_System/frontend/js/admin/sections/user-detail.js
T
Maxim Dolgolyov b4a5b1abc2 fix(permissions): кнопка «Права» (вкл. временные права) видна не только учителям
Модалка индивидуальных прав пользователя (с кнопкой «врем.» — выдать право на
срок, B8) открывалась только для u.role==='teacher'. Временные/индивидуальные
права нужны и ученикам (магазин, лаба, тесты на срок). Показываем «Права» всем,
кроме admin (он и так байпасит все права).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 15:34:15 +03:00

424 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>',
chev: '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>',
ban: '<i data-lucide="ban" style="width:13px;height:13px;vertical-align:-2px"></i>',
};
/* 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 = '<div class="ud-wrap"><div class="spinner"></div></div>';
}
function renderError(msg) {
const el = document.getElementById('user-detail-content');
if (!el) return;
el.innerHTML = `<div class="ud-wrap">
<button type="button" class="ud-back" onclick="AdminRouter.navigate('#users')">${ICONS.arrowLeft} К списку</button>
<div class="ud-empty" style="color:var(--pink)">${esc(msg)}</div>
</div>`;
}
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 ? ' <span class="ud-banned-tag">заблокирован</span>' : '';
const banLabel = u.is_banned ? 'Разблокировать' : 'Заблокировать';
const actions = canAct ? `
<div class="ud-actions">
<button class="btn-edit-q" onclick="openEditUserModal()"><i data-lucide="pencil" style="width:13px;height:13px;vertical-align:-2px"></i> Изменить</button>
${u.role !== 'admin' ? '<button class="btn-edit-q" onclick="openUserPermsModal()"><i data-lucide="shield" style="width:13px;height:13px;vertical-align:-2px"></i> Права</button>' : ''}
<button class="btn-del-q" onclick="clearUserHistory()"><i data-lucide="trash-2" style="width:13px;height:13px;vertical-align:-2px"></i> История</button>
<button class="btn-del-q" onclick="toggleBanUser()" ${u.is_banned ? 'style="background:rgba(34,197,94,.12);color:#22C55E;border-color:rgba(34,197,94,.25)"' : ''}>${ICONS.ban} <span id="up-ban-label">${banLabel}</span></button>
<button class="btn-del-q" onclick="confirmDeleteUser()" style="background:rgba(239,68,68,.12);color:#EF4444;border-color:rgba(239,68,68,.25)"><i data-lucide="user-x" style="width:13px;height:13px;vertical-align:-2px"></i> Удалить</button>
</div>` : '';
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 = `
<div class="ud-wrap">
<button type="button" class="ud-back" onclick="AdminRouter.navigate('#users')">${ICONS.arrowLeft} К списку пользователей</button>
<div class="ud-header">
<div class="ud-avatar${u.is_banned ? ' banned' : ''}" style="background:${avatarBg}">${esc(initials)}</div>
<div class="ud-id-block">
<div class="ud-name">
<span id="up-name">${esc(u.name)}</span>
<span class="ud-role-badge" style="background:${ROLE_BADGE_BG[u.role] || ROLE_BADGE_BG.student};color:${ROLE_BADGE_FG[u.role] || ROLE_BADGE_FG.student}">${roleLabel}</span>
${bannedTag}
</div>
<div class="ud-email" id="up-email">${esc(u.email || '')}</div>
<div class="ud-meta-row">
<span><strong>Регистрация:</strong> ${created}</span>
<span><strong>Последний вход:</strong> ${lastLog}</span>
<span><strong>ID:</strong> #${u.id}</span>
</div>
</div>
${actions}
</div>
<div class="ud-tabs" role="tablist">
<button type="button" class="ud-tab-btn" data-st="overview" onclick="udSwitchTab('overview')">Обзор</button>
<button type="button" class="ud-tab-btn" data-st="sessions" onclick="udSwitchTab('sessions')">Сессии</button>
<button type="button" class="ud-tab-btn" data-st="classes" onclick="udSwitchTab('classes')">Классы</button>
${isAdmin ? '<button type="button" class="ud-tab-btn" data-st="audit" onclick="udSwitchTab(\'audit\')">Audit</button>' : ''}
</div>
<div class="ud-tab-pane" id="ud-pane-overview"></div>
<div class="ud-tab-pane" id="ud-pane-sessions"></div>
<div class="ud-tab-pane" id="ud-pane-classes"></div>
<div class="ud-tab-pane" id="ud-pane-audit"></div>
</div>
`;
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 `<div class="ud-bar-row">
<div class="ud-bar-name">${esc(b.name)} <span style="color:var(--text-3);font-size:0.72rem">(${b.n})</span></div>
<div class="ud-bar-track"><div class="ud-bar-fill ${pc}" style="width:${b.pct}%"></div></div>
<div class="ud-bar-val">${b.pct}%</div>
</div>`;
}).join('') : '<div class="ud-empty" style="padding:14px">Нет данных по предметам</div>';
pane.innerHTML = `
<div class="ud-stats">
<div class="ud-stat">
<div class="ud-stat-val">${total}</div>
<div class="ud-stat-label">Всего сессий</div>
</div>
<div class="ud-stat">
<div class="ud-stat-val ${pcCls}">${avgPct !== null ? avgPct + '%' : '—'}</div>
<div class="ud-stat-label">Средний %</div>
</div>
<div class="ud-stat">
<div class="ud-stat-val" style="font-size:1rem">${u.created_at ? AdminCtx.fmtDate(u.created_at) : '—'}</div>
<div class="ud-stat-label">Регистрация</div>
</div>
<div class="ud-stat">
<div class="ud-stat-val" style="font-size:1rem">${lastDate}</div>
<div class="ud-stat-label">Последняя сессия</div>
</div>
</div>
<div class="ud-chart-card">
<div class="ud-chart-title">Успеваемость по предметам</div>
<div class="ud-bars">${barHtml}</div>
</div>
`;
}
/* ── Sessions tab ── */
function renderSessions() {
const pane = document.getElementById('ud-pane-sessions');
if (!pane) return;
if (!_sessions.length) {
pane.innerHTML = '<div class="ud-empty">Тестов нет</div>';
return;
}
const { MODES, pctClass, fmtDate } = AdminCtx;
pane.innerHTML = '<div class="ud-sess-list">' + _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 `<div class="ud-sess-row" onclick="AdminRouter.navigate('#sessions/${s.id}')">
<div class="ud-sess-pct ${pc}">${pct !== null ? pct + '%' : '—'}</div>
<div class="ud-sess-info">
<div class="ud-sess-subj">${esc(s.subject_name || 'Тест')}</div>
<div class="ud-sess-meta">${fmtDate(s.started_at)} · ${MODES[s.mode] || s.mode}</div>
</div>
<div class="ud-sess-score">${s.score ?? '—'} / ${s.total}</div>
<div class="ud-sess-chev">${ICONS.chev}</div>
</div>`;
}).join('') + '</div>';
}
/* ── 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 = `<div class="ud-empty">
Информация о классах пользователя пока недоступна.<br>
<a href="/classes" style="color:var(--violet);font-weight:600;text-decoration:none">Открыть управление классами →</a>
</div>`;
}
/* ── 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 = '<div class="spinner"></div>';
try {
const rows = await LS.api('/api/admin/audit-log?limit=500');
const uid = _userId;
// Match if target string includes "user:<uid>" or "userId=<uid>" 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 = '<div class="ud-empty">Нет записей аудита, связанных с этим пользователем</div>';
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 = '<div class="ud-audit-list">' + 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 `<div class="ud-audit-row">
<span class="ud-audit-when">${when}</span>
<span class="ud-audit-action" style="color:var(--violet)">${esc(lbl)}</span>
<span class="ud-audit-detail">${esc(r.detail || '')} ${who}</span>
</div>`;
}).join('') + '</div>';
} catch (e) {
pane.innerHTML = `<div class="ud-empty" style="color:var(--pink)">Ошибка загрузки аудита: ${esc(e.message)}</div>`;
}
}
/* ── 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,
};
})();