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