'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;
/* ── one-time CSS injection for hover row-actions (shared with sessions) ── */
function ensureRowActionsStyles() {
if (document.getElementById('row-actions-style')) return;
const s = document.createElement('style');
s.id = 'row-actions-style';
s.textContent = `
.row-actions { opacity: 0; transition: opacity .15s ease; display: inline-flex; gap: 4px; vertical-align: middle; }
tr:hover .row-actions, .sess-tl-item:hover .row-actions { opacity: 1; }
tr.selected .row-actions, .sess-tl-item.open .row-actions { opacity: 1; }
.row-action-btn { width: 28px; height: 28px; border-radius: 6px; border: 1px solid var(--border); background: transparent; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; color: var(--text-3); transition: background .12s ease, border-color .12s ease, color .12s ease; padding: 0; }
.row-action-btn:hover { background: rgba(155,93,229,.08); border-color: var(--violet); color: var(--violet); }
.row-action-btn:focus-visible { outline: 2px solid var(--violet); outline-offset: 1px; }
.row-action-btn.danger:hover { background: rgba(239,68,68,.08); border-color: var(--red, #EF4444); color: var(--red, #EF4444); }
.row-action-btn svg { width: 14px; height: 14px; pointer-events: none; }
.row-action-btn:disabled { opacity: .5; cursor: wait; }
.row-actions-cell { text-align: right; white-space: nowrap; padding-right: 12px; }
@media (max-width: 768px) {
.row-actions { display: none; }
}
`;
document.head.appendChild(s);
}
/* SVG icons (Lucide-style, 24x24 viewBox) */
const ICONS = {
ban: '',
unlock: '',
coins: '',
history: '',
trash: '',
eye: '',
};
// 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;
ensureRowActionsStyles();
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 = '
Пользователей нет |
';
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
? ``
: `${{student:'Ученик',free_student:'Своб. ученик',teacher:'Учитель',admin:'Админ'}[u.role]||u.role}`;
return `
${initials}
${esc(u.name)}${u.is_banned ? ' заблокирован' : ''}
${esc(u.email)}
|
${roleCell} |
${u.tests_count} |
${u.avg_pct !== null ? u.avg_pct+'%' : '—'}
${u.avg_pct !== null ? `` : ''}
|
${fmtDate(u.created_at)} |
${u.last_login ? new Date(u.last_login).toLocaleDateString('ru',{day:'numeric',month:'short'}) : '—'} |
${renderUserRowActions(u, isAdmin && u.id !== user.id)} |
`;
}).join('');
renderPgnControls('users-pagination', _usersPage, r.total || users.length, _USERS_PER_PAGE, 'gotoUsersPage');
} catch (e) {
document.getElementById('users-body').innerHTML = ` |
`;
LS.state.error(document.getElementById('users-body').querySelector('td'), e, load);
}
}
/* ─── Per-row hover actions (Phase 5) ─── */
function renderUserRowActions(u, canAct) {
if (!canAct) {
// Hide actions for non-admins or current user; keep arrow indicator as before
return '›';
}
const banIcon = u.is_banned ? ICONS.unlock : ICONS.ban;
const banLabel = u.is_banned ? 'Разблокировать' : 'Заблокировать';
return `
`;
}
async function quickToggleBan(uid, isBanned, btn) {
const action = isBanned ? 'Разблокировать' : 'Заблокировать';
const msg = isBanned
? 'Разблокировать пользователя? Он снова сможет войти в систему.'
: 'Заблокировать пользователя? Он не сможет войти в систему.';
if (!await LS.confirm(msg, { title: action, confirmText: action })) return;
btn.disabled = true;
try {
await LS.adminBanUser(uid, !isBanned);
LS.toast(isBanned ? 'Пользователь разблокирован' : 'Пользователь заблокирован', isBanned ? 'success' : 'warning');
await load();
if (activeUid === uid) await reloadUserPanel(uid);
} catch (e) {
LS.toast('Ошибка: ' + e.message, 'error');
btn.disabled = false;
}
}
function quickAwardCoins(uid, name) {
const body = document.createElement('div');
body.innerHTML = `
Начислить монеты пользователю ${esc(name)}:
`;
const m = LS.modal({
title: 'Начислить монеты',
content: body,
size: 'sm',
actions: [
{ label: 'Отмена', onClick: ({ close }) => close() },
{ label: 'Начислить', primary: true, onClick: async ({ close, setError }) => {
const amt = parseInt(body.querySelector('#qa-coins-amt').value, 10);
const reason = body.querySelector('#qa-coins-reason').value.trim();
if (!Number.isFinite(amt) || amt <= 0) { setError('Введите положительное количество монет'); return; }
try {
const r = await LS.adminShopAwardCoins({ userId: uid, amount: amt, reason });
LS.toast(`Начислено ${amt} монет. Баланс: ${r.coins ?? '?'}`, 'success');
close();
} catch (e) { setError('Ошибка: ' + e.message); }
} },
],
});
setTimeout(() => body.querySelector('#qa-coins-amt')?.focus(), 80);
}
function quickOpenUserSessions(uid) {
// 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);
}
}
async function quickDeleteUser(uid, name, btn) {
if (!await LS.confirm(
`Удалить пользователя «${name}» навсегда?\nВсе его данные, тесты и прогресс будут удалены. Это действие нельзя отменить.`,
{ title: 'Удалить пользователя', confirmText: 'Удалить навсегда' }
)) return;
btn.disabled = true;
try {
await LS.adminDeleteUser(uid);
LS.toast('Пользователь удалён', 'success');
if (activeUid === uid) closeUserPanel();
await load();
} catch (e) {
LS.toast('Ошибка: ' + e.message, 'error');
btn.disabled = false;
}
}
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 ? ' ' : '');
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 = 'Тестов нет
'; return; }
el.innerHTML = '' + sessions.map(s => {
const pct = s.score !== null ? Math.round((s.score/s.total)*100) : null;
return `
${pct !== null ? pct+'%' : '—'}
${s.subject_name||'Тест'}
${fmtDate(s.started_at)} · ${MODES[s.mode]||s.mode}
${s.score??'—'} / ${s.total}
`;
}).join('') + '
';
} 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(' ','');
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(' ','');
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 = `Ошибка: ${esc(e.message)}
`;
}
}
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
? `Инд.`
: `По роли`;
const resetBtn = hasOverride
? ``
: '';
return `
`;
}).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;
// Phase 5 quick actions
window.quickToggleBan = quickToggleBan;
window.quickAwardCoins = quickAwardCoins;
window.quickOpenUserSessions = quickOpenUserSessions;
window.quickDeleteUser = quickDeleteUser;
window.AdminSections = window.AdminSections || {};
window.AdminSections.users = {
init: async () => { if (inited) return; inited = true; await load(); },
reload: load,
};
})();