'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-related modal state. * After Phase 6 the .user-panel overlay is gone — instead the modals * (edit, perms) operate on window.activeUid which is set by user-detail.js * when the deep page opens, or transiently by row actions on the list. */ let _editUid = null; let _upPermsData = null; // Helper: read the currently-active user id (set by user-detail.js or quick actions). const getActiveUid = () => window.activeUid || null; // Helper: after a mutation that may affect the active user, refresh the deep page // (if it's currently showing the same user) AND the list. function reloadDetailAndList() { const sec = (window.AdminSections || {})['user-detail']; if (sec && typeof sec.reload === 'function') sec.reload(); load(); } 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 ? 'Разблокировать' : 'Заблокировать'; // Pass uid/name via data-* attributes (esc() escapes & < > " for the attribute // context; dataset reads back the raw string — no JS-string injection surface). return `
`; } async function quickToggleBan(btn) { const uid = +btn.dataset.uid; const isBanned = +btn.dataset.banned; 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 (getActiveUid() === uid) { const sec = (window.AdminSections || {})['user-detail']; if (sec && typeof sec.reload === 'function') sec.reload(); } } catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); btn.disabled = false; } } function quickAwardCoins(btn) { const uid = +btn.dataset.uid; const name = btn.dataset.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(btn) { const uid = +btn.dataset.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(btn) { const uid = +btn.dataset.uid; const name = btn.dataset.name || ''; if (!await LS.confirm( `Удалить пользователя «${name}» навсегда?\nВсе его данные, тесты и прогресс будут удалены. Это действие нельзя отменить.`, { title: 'Удалить пользователя', confirmText: 'Удалить навсегда' } )) return; btn.disabled = true; try { await LS.adminDeleteUser(uid); LS.toast('Пользователь удалён', 'success'); // If the deleted user is currently open as a deep page, go back to the list. if (getActiveUid() === uid && window.AdminRouter) { AdminRouter.navigate('#users'); } 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 actions (called from the user-detail deep page header buttons) ─── * Pre-Phase 6 these talked to the .user-panel overlay; now they: * - read the active uid via getActiveUid() (set by user-detail.init) * - read display name from the #up-name span rendered inside the deep page * - reload via AdminSections['user-detail'].reload() */ function _activeName() { const el = document.getElementById('up-name'); return el ? el.textContent.trim() : ''; } async function clearUserHistory() { const uid = getActiveUid(); if (!uid) return; const name = _activeName(); if (!await LS.confirm(`Удалить всю историю тестов пользователя «${name}»?\nЭто действие нельзя отменить.`, { title: 'Очистить историю', confirmText: 'Удалить историю' })) return; try { await LS.adminClearUserSessions(uid); reloadDetailAndList(); } catch (e) { LS.toast('Ошибка очистки истории: ' + e.message, 'error'); } } async function toggleBanUser() { const uid = getActiveUid(); if (!uid) return; const banLbl = document.getElementById('up-ban-label'); const isBanning = banLbl ? banLbl.textContent.trim() === 'Заблокировать' : true; const name = _activeName(); const msg = isBanning ? `Заблокировать пользователя «${name}»?\nОн не сможет войти в систему.` : `Разблокировать пользователя «${name}»?`; if (!await LS.confirm(msg, { title: isBanning ? 'Блокировка' : 'Разблокировка', confirmText: isBanning ? 'Заблокировать' : 'Разблокировать' })) return; try { await LS.adminBanUser(uid, isBanning); LS.toast(isBanning ? 'Пользователь заблокирован' : 'Пользователь разблокирован', isBanning ? 'warning' : 'success'); reloadDetailAndList(); } catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); } } async function confirmDeleteUser() { const uid = getActiveUid(); if (!uid) return; const name = _activeName(); if (!await LS.confirm(`Удалить пользователя «${name}» навсегда?\nВсе его данные, тесты и прогресс будут удалены. Это действие нельзя отменить.`, { title: 'Удалить пользователя', confirmText: 'Удалить навсегда' })) return; try { await LS.adminDeleteUser(uid); LS.toast('Пользователь удалён', 'success'); if (window.AdminRouter) AdminRouter.navigate('#users'); 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 = getActiveUid(); if (!_editUid) return; 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(); reloadDetailAndList(); } 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() { const uid = getActiveUid(); if (!uid) return; const name = _activeName(); 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(uid); 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 `
${esc(p.label)} ${badge} ${resetBtn}
${esc(p.desc)}
`; }).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) { const uid = getActiveUid(); if (!uid) return; checkbox.disabled = true; try { await LS.setUserPermission(uid, key, enabled); _upPermsData = await LS.getUserPermissions(uid); renderUserPerms(); LS.toast(enabled ? 'Право включено' : 'Право отключено', 'success'); } catch(e) { checkbox.checked = !enabled; LS.toast('Ошибка: ' + e.message, 'error'); } finally { checkbox.disabled = false; } } async function doResetOneUserPerm(key) { const uid = getActiveUid(); if (!uid) return; try { await LS.resetUserPermissions(uid, key); _upPermsData = await LS.getUserPermissions(uid); renderUserPerms(); LS.toast('Сброшено к значению роли', 'success'); } catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); } } async function doResetAllUserPerms() { const uid = getActiveUid(); if (!uid) return; const name = _activeName(); if (!await LS.confirm(`Сбросить все индивидуальные права «${name}»?\nБудут применены права роли.`, { title: 'Сбросить права', confirmText: 'Сбросить' })) return; try { await LS.resetUserPermissions(uid); _upPermsData = await LS.getUserPermissions(uid); 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.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, }; })();