92030b462c
Replace ~3500L admin.js monolith with thin orchestrator (~700L) + 14 IIFE-wrapped per-section modules under /js/admin/sections/. Section modules expose AdminSections.<name>.init/reload (lazy init via switchTab/router) and re-expose onclick handlers via window.X for backward compat. Shared helpers (MODES/DIFFS, fmtDate, pctClass, renderMath, qTypeBadge, pagination) live in /js/admin/_shared.js exposed on window.AdminCtx. switchTab now dispatches to AdminSections via ROUTE_TO_SECTION map; non-extracted system tabs (topics/audit/errors/health/classroom/avatars) remain inline in admin.js. user-panel overlay markup untouched — Phase 6 will remove it.
344 lines
18 KiB
JavaScript
344 lines
18 KiB
JavaScript
'use strict';
|
||
/* admin → users section: users table + pagination + user-panel overlay + user-perms modal */
|
||
(function () {
|
||
'use strict';
|
||
let inited = false;
|
||
|
||
let _usersPage = 1;
|
||
const _USERS_PER_PAGE = 50;
|
||
|
||
// user-panel + edit modal + perms modal state
|
||
let activeTr = null;
|
||
let activeUid = null;
|
||
let activeUserRole = null;
|
||
let _editUid = null;
|
||
let _upPermsData = null;
|
||
|
||
async function load(page) {
|
||
const { pctClass, fmtDate, renderPgnControls } = AdminCtx;
|
||
const isAdmin = AdminCtx.isAdmin;
|
||
const user = AdminCtx.user;
|
||
if (page) _usersPage = page;
|
||
try {
|
||
const r = await LS.adminGetUsers({ page: _usersPage, limit: _USERS_PER_PAGE });
|
||
const users = r.users || [];
|
||
const tbody = document.getElementById('users-body');
|
||
if (!users.length) {
|
||
tbody.innerHTML = '<tr><td colspan="7"><div class="empty">Пользователей нет</div></td></tr>';
|
||
document.getElementById('users-pagination').style.display = 'none';
|
||
return;
|
||
}
|
||
tbody.innerHTML = users.map(u => {
|
||
const pc = pctClass(u.avg_pct);
|
||
const initials = (u.name||'?').split(' ').slice(0,2).map(w=>w[0]?.toUpperCase()||'').join('')||'?';
|
||
const avatarBg = u.role==='admin' ? 'linear-gradient(135deg,#9B5DE5,#c084fc)' : u.role==='teacher' ? 'linear-gradient(135deg,#06D6E0,#9B5DE5)' : u.role==='free_student' ? 'linear-gradient(135deg,#10B981,#059669)' : 'linear-gradient(135deg,#8898AA,#3D4F6B)';
|
||
const roleCell = isAdmin && u.id !== user.id
|
||
? `<select class="role-select" data-uid="${u.id}" onchange="changeRole(this)">
|
||
<option value="student" ${u.role==='student' ?'selected':''}>Ученик</option>
|
||
<option value="free_student" ${u.role==='free_student' ?'selected':''}>Своб. ученик</option>
|
||
<option value="teacher" ${u.role==='teacher' ?'selected':''}>Учитель</option>
|
||
<option value="admin" ${u.role==='admin' ?'selected':''}>Админ</option>
|
||
</select>`
|
||
: `<span class="role-badge ${u.role}">${{student:'Ученик',free_student:'Своб. ученик',teacher:'Учитель',admin:'Админ'}[u.role]||u.role}</span>`;
|
||
return `<tr class="clickable${u.is_banned ? ' banned-row' : ''}" onclick="openUserPanel(event,${u.id},'${u.role}')">
|
||
<td>
|
||
<div style="display:flex;align-items:center;gap:12px">
|
||
<div style="width:36px;height:36px;border-radius:10px;background:${avatarBg};display:flex;align-items:center;justify-content:center;font-family:'Unbounded',sans-serif;font-size:0.62rem;font-weight:800;color:#fff;flex-shrink:0;${u.is_banned?'filter:grayscale(1);opacity:.5':''}">${initials}</div>
|
||
<div>
|
||
<div style="font-weight:700;font-size:0.88rem;color:var(--text)">${esc(u.name)}${u.is_banned ? ' <span style="font-size:0.7rem;background:rgba(239,68,68,.12);color:#EF4444;border-radius:4px;padding:1px 5px;font-weight:600;vertical-align:middle">заблокирован</span>' : ''}</div>
|
||
<div style="color:var(--text-3);font-size:0.76rem">${esc(u.email)}</div>
|
||
</div>
|
||
</div>
|
||
</td>
|
||
<td onclick="event.stopPropagation()">${roleCell}</td>
|
||
<td style="font-weight:700">${u.tests_count}</td>
|
||
<td>
|
||
<span class="pct-cell ${pc}">${u.avg_pct !== null ? u.avg_pct+'%' : '—'}</span>
|
||
${u.avg_pct !== null ? `<div class="perf-bar"><div class="perf-fill ${pc}" style="width:${u.avg_pct}%"></div></div>` : ''}
|
||
</td>
|
||
<td style="color:var(--text-3);font-size:0.8rem">${fmtDate(u.created_at)}</td>
|
||
<td style="color:var(--text-3);font-size:0.8rem">${u.last_login ? new Date(u.last_login).toLocaleDateString('ru',{day:'numeric',month:'short'}) : '—'}</td>
|
||
<td style="text-align:right;color:var(--text-3);font-size:0.85rem;opacity:0.4">›</td>
|
||
</tr>`;
|
||
}).join('');
|
||
renderPgnControls('users-pagination', _usersPage, r.total || users.length, _USERS_PER_PAGE, 'gotoUsersPage');
|
||
} catch (e) {
|
||
document.getElementById('users-body').innerHTML = `<tr><td colspan="7"></td></tr>`;
|
||
LS.state.error(document.getElementById('users-body').querySelector('td'), e, load);
|
||
}
|
||
}
|
||
|
||
function gotoUsersPage(n) {
|
||
_usersPage = n;
|
||
load();
|
||
document.getElementById('tab-users')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||
}
|
||
|
||
async function changeRole(select) {
|
||
select.disabled = true;
|
||
try { await LS.adminUpdateRole(select.dataset.uid, select.value); LS.toast('Роль изменена', 'success', 2000); }
|
||
catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
||
finally { select.disabled = false; }
|
||
}
|
||
|
||
/* ─── User panel ─── */
|
||
async function openUserPanel(e, uid, role) {
|
||
const isAdmin = AdminCtx.isAdmin;
|
||
if (activeTr) activeTr.classList.remove('selected');
|
||
activeTr = e.currentTarget; activeTr.classList.add('selected');
|
||
activeUid = uid;
|
||
activeUserRole = role;
|
||
const panel = document.getElementById('user-panel');
|
||
panel.classList.add('visible');
|
||
panel.scrollIntoView({ behavior:'smooth', block:'nearest' });
|
||
document.getElementById('up-sessions').innerHTML = LS.skeleton(3, 'row');
|
||
document.getElementById('up-name').textContent = '…';
|
||
document.getElementById('up-email').textContent = '';
|
||
if (isAdmin) {
|
||
document.getElementById('up-edit-btn').style.display = '';
|
||
document.getElementById('up-clear-btn').style.display = '';
|
||
document.getElementById('up-perms-btn').style.display = role === 'teacher' ? '' : 'none';
|
||
document.getElementById('up-ban-btn').style.display = '';
|
||
document.getElementById('up-delete-btn').style.display = '';
|
||
}
|
||
await reloadUserPanel(uid);
|
||
}
|
||
|
||
async function reloadUserPanel(uid) {
|
||
const { MODES, pctClass, fmtDate } = AdminCtx;
|
||
const isAdmin = AdminCtx.isAdmin;
|
||
try {
|
||
const { user: u, sessions } = await LS.adminGetUserSessions(uid);
|
||
activeUserRole = u.role;
|
||
document.getElementById('up-name').innerHTML = LS.esc(u.name) + (u.is_banned ? ' <svg class="ic" viewBox="0 0 24 24"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>' : '');
|
||
document.getElementById('up-email').textContent = u.email;
|
||
if (isAdmin) {
|
||
document.getElementById('up-perms-btn').style.display = u.role === 'teacher' ? '' : 'none';
|
||
const banBtn = document.getElementById('up-ban-btn');
|
||
const banLbl = document.getElementById('up-ban-label');
|
||
if (u.is_banned) {
|
||
banBtn.style.background = 'rgba(34,197,94,.12)';
|
||
banBtn.style.color = '#22C55E';
|
||
banBtn.style.borderColor = 'rgba(34,197,94,.25)';
|
||
banLbl.textContent = 'Разблокировать';
|
||
} else {
|
||
banBtn.style.background = '';
|
||
banBtn.style.color = '';
|
||
banBtn.style.borderColor = '';
|
||
banLbl.textContent = 'Заблокировать';
|
||
}
|
||
}
|
||
const el = document.getElementById('up-sessions');
|
||
if (!sessions.length) { el.innerHTML = '<div class="empty">Тестов нет</div>'; return; }
|
||
el.innerHTML = '<div class="sess-list">' + sessions.map(s => {
|
||
const pct = s.score !== null ? Math.round((s.score/s.total)*100) : null;
|
||
return `<div class="sess-item">
|
||
<div class="sess-pct ${pctClass(pct)}">${pct !== null ? pct+'%' : '—'}</div>
|
||
<div class="sess-info"><div class="sess-subj">${s.subject_name||'Тест'}</div><div class="sess-meta">${fmtDate(s.started_at)} · ${MODES[s.mode]||s.mode}</div></div>
|
||
<div class="sess-score">${s.score??'—'} / ${s.total}</div>
|
||
</div>`;
|
||
}).join('') + '</div>';
|
||
} catch (e) { LS.state.error(document.getElementById('up-sessions'), e); }
|
||
}
|
||
|
||
function closeUserPanel() {
|
||
document.getElementById('user-panel').classList.remove('visible');
|
||
if (activeTr) { activeTr.classList.remove('selected'); activeTr = null; }
|
||
activeUid = null;
|
||
}
|
||
|
||
async function clearUserHistory() {
|
||
const name = document.getElementById('up-name').textContent;
|
||
if (!await LS.confirm(`Удалить всю историю тестов пользователя «${name}»?\nЭто действие нельзя отменить.`, { title: 'Очистить историю', confirmText: 'Удалить историю' })) return;
|
||
try {
|
||
await LS.adminClearUserSessions(activeUid);
|
||
await reloadUserPanel(activeUid);
|
||
load();
|
||
} catch (e) { LS.toast('Ошибка очистки истории: ' + e.message, 'error'); }
|
||
}
|
||
|
||
async function toggleBanUser() {
|
||
const banLbl = document.getElementById('up-ban-label');
|
||
const isBanning = banLbl.textContent === 'Заблокировать';
|
||
const name = document.getElementById('up-name').innerHTML.replace(' <svg class="ic" viewBox="0 0 24 24"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>','');
|
||
const msg = isBanning
|
||
? `Заблокировать пользователя «${name}»?\nОн не сможет войти в систему.`
|
||
: `Разблокировать пользователя «${name}»?`;
|
||
if (!await LS.confirm(msg, { title: isBanning ? 'Блокировка' : 'Разблокировка', confirmText: isBanning ? 'Заблокировать' : 'Разблокировать' })) return;
|
||
try {
|
||
await LS.adminBanUser(activeUid, isBanning);
|
||
LS.toast(isBanning ? 'Пользователь заблокирован' : 'Пользователь разблокирован', isBanning ? 'warning' : 'success');
|
||
await reloadUserPanel(activeUid);
|
||
load();
|
||
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
||
}
|
||
|
||
async function confirmDeleteUser() {
|
||
const name = document.getElementById('up-name').innerHTML.replace(' <svg class="ic" viewBox="0 0 24 24"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>','');
|
||
if (!await LS.confirm(`Удалить пользователя «${name}» навсегда?\nВсе его данные, тесты и прогресс будут удалены. Это действие нельзя отменить.`, { title: 'Удалить пользователя', confirmText: 'Удалить навсегда' })) return;
|
||
try {
|
||
await LS.adminDeleteUser(activeUid);
|
||
LS.toast('Пользователь удалён', 'success');
|
||
closeUserPanel();
|
||
load();
|
||
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
||
}
|
||
|
||
/* ─── Edit user modal ─── */
|
||
function closeEditUserModal() {
|
||
document.getElementById('eu-modal').classList.remove('open');
|
||
_editUid = null;
|
||
}
|
||
|
||
function openEditUserModal() {
|
||
_editUid = activeUid;
|
||
document.getElementById('eu-name').value = document.getElementById('up-name').textContent;
|
||
document.getElementById('eu-email').value = document.getElementById('up-email').textContent;
|
||
document.getElementById('eu-password').value = '';
|
||
document.getElementById('eu-error').textContent = '';
|
||
document.getElementById('eu-modal').classList.add('open');
|
||
setTimeout(() => document.getElementById('eu-name').focus(), 80);
|
||
}
|
||
|
||
async function saveEditUser() {
|
||
const name = document.getElementById('eu-name').value.trim();
|
||
const email = document.getElementById('eu-email').value.trim();
|
||
const password = document.getElementById('eu-password').value;
|
||
const errEl = document.getElementById('eu-error');
|
||
errEl.textContent = '';
|
||
if (!name) { errEl.textContent = 'Введите имя'; return; }
|
||
if (!email) { errEl.textContent = 'Введите email'; return; }
|
||
if (password && password.length < 6) { errEl.textContent = 'Пароль должен быть не менее 6 символов'; return; }
|
||
const payload = { name, email };
|
||
if (password) payload.password = password;
|
||
const btn = document.getElementById('eu-save');
|
||
btn.disabled = true; btn.textContent = 'Сохранение…';
|
||
try {
|
||
await LS.adminUpdateUser(_editUid, payload);
|
||
closeEditUserModal();
|
||
await reloadUserPanel(activeUid);
|
||
load();
|
||
} catch (e) {
|
||
errEl.textContent = 'Ошибка: ' + e.message;
|
||
} finally {
|
||
btn.disabled = false; btn.textContent = 'Сохранить';
|
||
}
|
||
}
|
||
|
||
/* ─── User permissions modal (opened from inside user-panel) ─── */
|
||
function closeUserPermsModal() {
|
||
document.getElementById('up-modal').classList.remove('open');
|
||
_upPermsData = null;
|
||
}
|
||
|
||
async function openUserPermsModal() {
|
||
if (!activeUid) return;
|
||
const name = document.getElementById('up-name').textContent;
|
||
document.getElementById('up-modal-title').textContent = `Права: ${name}`;
|
||
document.getElementById('up-modal-list').innerHTML = LS.skeleton(5, 'row');
|
||
document.getElementById('up-modal').classList.add('open');
|
||
try {
|
||
_upPermsData = await LS.getUserPermissions(activeUid);
|
||
renderUserPerms();
|
||
} catch(e) {
|
||
document.getElementById('up-modal-list').innerHTML = `<p style="color:var(--danger);font-size:13px">Ошибка: ${esc(e.message)}</p>`;
|
||
}
|
||
}
|
||
|
||
function renderUserPerms() {
|
||
if (!_upPermsData) return;
|
||
const list = document.getElementById('up-modal-list');
|
||
list.innerHTML = _upPermsData.permissions.map(p => {
|
||
const hasOverride = p.userVal !== undefined;
|
||
const checked = p.effective;
|
||
const badge = hasOverride
|
||
? `<span style="font-size:10px;padding:2px 7px;border-radius:var(--r-pill);background:rgba(155,93,229,0.12);color:var(--violet);font-weight:700">Инд.</span>`
|
||
: `<span style="font-size:10px;padding:2px 7px;border-radius:var(--r-pill);background:rgba(136,152,170,0.12);color:var(--text-3);font-weight:700">По роли</span>`;
|
||
const resetBtn = hasOverride
|
||
? `<button style="background:none;border:none;cursor:pointer;color:var(--text-3);padding:3px 6px;border-radius:6px;font-size:11px;font-weight:700;transition:color .2s"
|
||
onmouseover="this.style.color='var(--danger)'" onmouseout="this.style.color='var(--text-3)'"
|
||
onclick="doResetOneUserPerm('${esc(p.key)}')" title="Сбросить к роли">×</button>`
|
||
: '';
|
||
return `
|
||
<div class="perm-card${checked ? ' enabled' : ''}" id="up-perm-card-${p.key.replace('.','_')}">
|
||
<div class="perm-info">
|
||
<div style="display:flex;align-items:center;gap:7px">
|
||
<span class="perm-label">${esc(p.label)}</span>
|
||
${badge}
|
||
${resetBtn}
|
||
</div>
|
||
<div class="perm-desc">${esc(p.desc)}</div>
|
||
</div>
|
||
<label class="perm-toggle">
|
||
<input type="checkbox" ${checked ? 'checked' : ''}
|
||
onchange="doSetUserPerm('${esc(p.key)}', this.checked, this)">
|
||
<span class="perm-track"></span>
|
||
<span class="perm-thumb"></span>
|
||
</label>
|
||
</div>`;
|
||
}).join('');
|
||
const hasAny = _upPermsData.permissions.some(p => p.userVal !== undefined);
|
||
document.getElementById('up-modal-reset-btn').style.opacity = hasAny ? '1' : '0.4';
|
||
}
|
||
|
||
async function doSetUserPerm(key, enabled, checkbox) {
|
||
checkbox.disabled = true;
|
||
try {
|
||
await LS.setUserPermission(activeUid, key, enabled);
|
||
_upPermsData = await LS.getUserPermissions(activeUid);
|
||
renderUserPerms();
|
||
LS.toast(enabled ? 'Право включено' : 'Право отключено', 'success');
|
||
} catch(e) {
|
||
checkbox.checked = !enabled;
|
||
LS.toast('Ошибка: ' + e.message, 'error');
|
||
} finally {
|
||
checkbox.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function doResetOneUserPerm(key) {
|
||
try {
|
||
await LS.resetUserPermissions(activeUid, key);
|
||
_upPermsData = await LS.getUserPermissions(activeUid);
|
||
renderUserPerms();
|
||
LS.toast('Сброшено к значению роли', 'success');
|
||
} catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
||
}
|
||
|
||
async function doResetAllUserPerms() {
|
||
const name = document.getElementById('up-name').textContent;
|
||
if (!await LS.confirm(`Сбросить все индивидуальные права «${name}»?\nБудут применены права роли.`, { title: 'Сбросить права', confirmText: 'Сбросить' })) return;
|
||
try {
|
||
await LS.resetUserPermissions(activeUid);
|
||
_upPermsData = await LS.getUserPermissions(activeUid);
|
||
renderUserPerms();
|
||
LS.toast('Права сброшены к роли', 'success');
|
||
} catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
||
}
|
||
|
||
// Expose handlers used by HTML onclicks
|
||
window.loadUsers = load;
|
||
window.gotoUsersPage = gotoUsersPage;
|
||
window.changeRole = changeRole;
|
||
window.openUserPanel = openUserPanel;
|
||
window.reloadUserPanel = reloadUserPanel;
|
||
window.closeUserPanel = closeUserPanel;
|
||
window.clearUserHistory = clearUserHistory;
|
||
window.toggleBanUser = toggleBanUser;
|
||
window.confirmDeleteUser = confirmDeleteUser;
|
||
window.closeEditUserModal = closeEditUserModal;
|
||
window.openEditUserModal = openEditUserModal;
|
||
window.saveEditUser = saveEditUser;
|
||
window.closeUserPermsModal = closeUserPermsModal;
|
||
window.openUserPermsModal = openUserPermsModal;
|
||
window.doSetUserPerm = doSetUserPerm;
|
||
window.doResetOneUserPerm = doResetOneUserPerm;
|
||
window.doResetAllUserPerms = doResetAllUserPerms;
|
||
|
||
window.AdminSections = window.AdminSections || {};
|
||
window.AdminSections.users = {
|
||
init: async () => { if (inited) return; inited = true; await load(); },
|
||
reload: load,
|
||
};
|
||
})();
|