feat(admin): Phase 6 sub-commit 2 — remove .user-panel overlay

Now that the deep pages (sub-commit 1) work, retire the legacy
.user-panel inline overlay entirely.

* admin.html: removed <div class="user-panel" id="user-panel"> block
  inside #tab-users, removed dead .user-panel* CSS (kept .btn-close
  for any external use).
* users.js: removed openUserPanel / closeUserPanel / reloadUserPanel
  and their closure state (activeTr, activeUserRole). User row onclick
  switched from openUserPanel(...) → AdminRouter.navigate('#users/N').
  clearUserHistory / toggleBanUser / confirmDeleteUser / openEditUserModal
  / openUserPermsModal / doSet/doReset* all refactored to use the
  getActiveUid() helper (reads window.activeUid, set by user-detail.init)
  + reloadDetailAndList() helper (refreshes deep page + list together).
* sessions.js: row click + eye-button switched from toggleDrawer(id)
  → gotoSession(id) → AdminRouter.navigate('#sessions/N'). Removed
  toggleDrawer + renderDrawer functions (~60L) and openDrawerId state.
  Inline drawer markup removed from the row template.

Verified node --check on all touched JS. ast-index confirms zero
remaining usages of openUserPanel / closeUserPanel / reloadUserPanel /
toggleDrawer across the repo.

This completes Phase 6 and the admin-redesign feature.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-05-17 00:08:13 +03:00
parent bd3020067b
commit 3f89030b6e
6 changed files with 138 additions and 224 deletions
+3 -25
View File
@@ -159,12 +159,8 @@
.pct-mid { color: var(--amber); }
.pct-lo { color: var(--pink); }
/* user panel */
.user-panel { background: var(--surface); backdrop-filter: var(--blur); border: 1px solid var(--border); border-radius: var(--r-lg); padding: 32px; box-shadow: var(--shadow); display: none; }
.user-panel.visible { display: block; }
.user-panel-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 24px; }
.user-panel-name { font-family: 'Unbounded', sans-serif; font-size: 1.1rem; font-weight: 800; }
.user-panel-email { font-size: 0.92rem; color: var(--text-3); margin-top: 3px; }
/* Legacy .user-panel overlay was removed in Phase 6 — the deep page
(#users/:id) replaces it. .btn-close kept for use elsewhere if any. */
.btn-close { padding: 8px 18px; border: 1.5px solid var(--border-h); border-radius: var(--r-pill); background: transparent; font-family: 'Manrope', sans-serif; font-size: 0.88rem; font-weight: 600; color: var(--text-3); cursor: pointer; transition: all var(--tr); }
.btn-close:hover { border-color: var(--pink); color: var(--pink); }
.sess-list { display: flex; flex-direction: column; gap: 12px; }
@@ -579,10 +575,6 @@
.q-modal-title { font-size: 0.9rem; margin-bottom: 20px; }
.form-row-2, .form-row-3 { grid-template-columns: 1fr; }
/* User panel */
.user-panel { padding: 18px 14px; }
.user-panel-header { flex-wrap: wrap; gap: 10px; }
/* Session drawer */
.sess-drawer-inner { padding: 16px 12px; }
.drawer-header { gap: 10px; }
@@ -1126,21 +1118,7 @@
</table>
</div>
<div id="users-pagination" class="pgn-bar" style="display:none"></div>
<div class="user-panel" id="user-panel">
<div class="user-panel-header">
<div><div class="user-panel-name" id="up-name"></div><div class="user-panel-email" id="up-email"></div></div>
<div style="display:flex;gap:8px;align-items:center">
<button class="btn-edit-q" id="up-edit-btn" onclick="openEditUserModal()" style="display:none"><i data-lucide="pencil" style="width:13px;height:13px;vertical-align:-2px"></i> Изменить</button>
<button class="btn-edit-q" id="up-perms-btn" onclick="openUserPermsModal()" style="display:none"><i data-lucide="shield" style="width:13px;height:13px;vertical-align:-2px"></i> Права</button>
<button class="btn-del-q" id="up-clear-btn" onclick="clearUserHistory()" style="display:none"><i data-lucide="trash-2" style="width:13px;height:13px;vertical-align:-2px"></i> История</button>
<button class="btn-del-q" id="up-ban-btn" onclick="toggleBanUser()" style="display:none"><i data-lucide="ban" style="width:13px;height:13px;vertical-align:-2px"></i> <span id="up-ban-label">Заблокировать</span></button>
<button class="btn-del-q" id="up-delete-btn" onclick="confirmDeleteUser()" style="display:none;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>
<button class="btn-close" onclick="closeUserPanel()"><i data-lucide="x" style="width:13px;height:13px;vertical-align:-2px"></i> Закрыть</button>
</div>
</div>
<div class="section-title">История тестов</div>
<div id="up-sessions"><div class="spinner"></div></div>
</div>
<!-- Phase 6: legacy .user-panel overlay removed; deep page renders into #tab-user-detail above. -->
</div>
<!-- ── Deep page: user detail (#users/:id) — populated by user-detail.js ── -->
+8 -78
View File
@@ -5,7 +5,11 @@
let inited = false;
let allSessions = [];
let openDrawerId = null;
// Phase 6: clicking a session row navigates to the deep page (#sessions/:id)
// instead of toggling an inline drawer. The drawer rendering is gone.
function gotoSession(id) {
if (window.AdminRouter) AdminRouter.navigate('#sessions/' + id);
}
/* SVG icons (Lucide-style) — kept local to mirror users.js without coupling */
const SESS_ICONS = {
@@ -39,7 +43,6 @@
async function load() {
const subject = document.getElementById('t-subject').value;
document.getElementById('t-body').innerHTML = '<div class="spinner"></div>';
openDrawerId = null;
ensureRowActionsStyles();
try {
allSessions = await LS.adminGetSessions({ subject: subject || undefined });
@@ -90,7 +93,7 @@
const ring = s.percent !== null
? sessPctRing(s.percent)
: `<div style="width:48px;height:48px;display:flex;align-items:center;justify-content:center;font-family:'Unbounded',sans-serif;font-size:0.85rem;font-weight:800;color:var(--text-3)">—</div>`;
return `<div class="sess-tl-item" id="trow-${s.id}" onclick="toggleDrawer(${s.id})">
return `<div class="sess-tl-item" id="trow-${s.id}" onclick="gotoSession(${s.id})">
${ring}
<div class="sess-tl-user">
<div class="sess-tl-name">${esc(s.user_name)}</div>
@@ -100,88 +103,15 @@
<div class="sess-tl-time">${fmtTime(s.duration_sec)}</div>
<div class="row-actions" onclick="event.stopPropagation()">
<button type="button" class="row-action-btn" title="Открыть детали"
onclick="event.stopPropagation();toggleDrawer(${s.id})">${SESS_ICONS.eye}</button>
onclick="event.stopPropagation();gotoSession(${s.id})">${SESS_ICONS.eye}</button>
<button type="button" class="row-action-btn danger" title="Удалить сессию"
onclick="event.stopPropagation();quickDeleteSession(${s.id},this)">${SESS_ICONS.trash}</button>
</div>
</div>
<div class="sess-tl-drawer" id="tdrawer-${s.id}">
<div class="sess-drawer" id="drawer-${s.id}">
<div class="sess-drawer-inner" id="drawer-inner-${s.id}"><div class="spinner"></div></div>
</div>
</div>`;
}).join('')}</div>`
).join('');
}
async function toggleDrawer(id) {
const drawerEl = document.getElementById('tdrawer-' + id);
const drawer = document.getElementById('drawer-' + id);
const trow = document.getElementById('trow-' + id);
if (openDrawerId && openDrawerId !== id) {
document.getElementById('tdrawer-' + openDrawerId)?.classList.remove('open');
document.getElementById('drawer-' + openDrawerId)?.classList.remove('open');
document.getElementById('trow-' + openDrawerId)?.classList.remove('open');
}
if (openDrawerId === id) {
drawerEl.classList.remove('open'); drawer.classList.remove('open'); trow.classList.remove('open');
openDrawerId = null; return;
}
openDrawerId = id; trow.classList.add('open');
drawerEl.classList.add('open');
requestAnimationFrame(() => drawer.classList.add('open'));
const inner = document.getElementById('drawer-inner-' + id);
if (inner.dataset.loaded) return;
inner.dataset.loaded = '1';
try {
const d = await LS.adminGetSessionDetail(id);
renderDrawer(inner, d);
} catch (e) { inner.innerHTML = `<div class="error">Ошибка: ${esc(e.message)}</div>`; }
}
function renderDrawer(el, d) {
const { MODES, pctClass, fmtDate, fmtTime, renderMath } = AdminCtx;
const pct = d.score !== null && d.total ? Math.round((d.score/d.total)*100) : null;
const pc = pctClass(pct);
const correct = d.questions.filter(q => q.is_correct).length;
const wrong = d.questions.filter(q => !q.is_correct && q.chosen_option_id).length;
const skipped = d.questions.filter(q => !q.chosen_option_id).length;
const qHtml = d.questions.map((q,i) => {
const status = !q.chosen_option_id ? 'skipped' : q.is_correct ? 'correct' : 'wrong';
const badgeTxt = { correct:'Верно', wrong:'Неверно', skipped:'Пропущено' }[status];
const opts = q.options.map(o => {
const isCor = o.is_correct, isCho = o.id === q.chosen_option_id;
let cls='', icon='<svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8"/></svg>';
if (isCor) { cls='correct-opt'; icon='<i data-lucide="check" style="width:13px;height:13px"></i>'; }
else if (isCho && !isCor) { cls='chosen-wrong'; icon='<i data-lucide="x" style="width:13px;height:13px"></i>'; }
return `<div class="qb-opt ${cls}"><span class="qb-opt-icon">${icon}</span>${esc(o.text)}</div>`;
}).join('');
const expl = q.explanation ? `<div class="qb-expl"><strong>Пояснение:</strong> ${esc(q.explanation)}</div>` : '';
return `<div class="qb-item ${status}">
<div class="qb-header"><span class="qb-qnum">Вопрос ${i+1}</span><span class="qb-badge ${status}">${badgeTxt}</span><span class="qb-time">${q.time_spent_sec?q.time_spent_sec+' сек':''}</span></div>
<div class="qb-text">${esc(q.text)}</div>
<div class="qb-opts">${opts}</div>${expl}
</div>`;
}).join('');
el.innerHTML = `
<div class="drawer-header">
<div>
<div style="font-family:'Unbounded',sans-serif;font-weight:800;font-size:0.95rem">${esc(d.user_name)}</div>
<div class="drawer-meta">${esc(d.user_email)} · ${d.subject_name||'?'} · ${MODES[d.mode]||d.mode} · ${fmtDate(d.started_at)}</div>
</div>
<div class="drawer-score ${pc}">${pct !== null ? pct+'%' : '—'}</div>
<div style="display:flex;gap:20px;margin-left:auto;text-align:center">
<div><div style="font-family:'Unbounded',sans-serif;color:var(--green);font-weight:700">${correct}</div><div style="font-size:0.72rem;color:var(--text-3)">Верно</div></div>
<div><div style="font-family:'Unbounded',sans-serif;color:var(--pink);font-weight:700">${wrong}</div><div style="font-size:0.72rem;color:var(--text-3)">Неверно</div></div>
<div><div style="font-family:'Unbounded',sans-serif;color:var(--text-3);font-weight:700">${skipped}</div><div style="font-size:0.72rem;color:var(--text-3)">Пропущено</div></div>
<div><div style="font-family:'Unbounded',sans-serif;color:var(--text-2);font-weight:700">${fmtTime(d.duration_sec)}</div><div style="font-size:0.72rem;color:var(--text-3)">Время</div></div>
</div>
</div>
<div class="qb-list">${qHtml||'<div class="empty">Вопросы не найдены</div>'}</div>`;
renderMath(el);
if (window.lucide) lucide.createIcons();
}
async function quickDeleteSession(id, btn) {
if (!await LS.confirm(
'Удалить эту сессию? Все ответы и связанные данные будут удалены.\nЭто действие нельзя отменить.',
@@ -202,7 +132,7 @@
// Expose handlers
window.loadSessions = load;
window.renderSessions = renderSessions;
window.toggleDrawer = toggleDrawer;
window.gotoSession = gotoSession;
window.quickDeleteSession = quickDeleteSession;
window.AdminSections = window.AdminSections || {};
+66 -99
View File
@@ -40,12 +40,21 @@
eye: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/></svg>',
};
// user-panel + edit modal + perms modal state
let activeTr = null;
let activeUid = null;
let activeUserRole = null;
/* 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;
@@ -74,7 +83,7 @@
<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}')">
return `<tr class="clickable${u.is_banned ? ' banned-row' : ''}" onclick="AdminRouter.navigate('#users/${u.id}')">
<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>
@@ -133,7 +142,10 @@
await LS.adminBanUser(uid, !isBanned);
LS.toast(isBanned ? 'Пользователь разблокирован' : 'Пользователь заблокирован', isBanned ? 'success' : 'warning');
await load();
if (activeUid === uid) await reloadUserPanel(uid);
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;
@@ -193,7 +205,10 @@
try {
await LS.adminDeleteUser(uid);
LS.toast('Пользователь удалён', 'success');
if (activeUid === uid) closeUserPanel();
// 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');
@@ -214,105 +229,53 @@
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;
/* ─── 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 name = document.getElementById('up-name').textContent;
const uid = getActiveUid();
if (!uid) return;
const name = _activeName();
if (!await LS.confirm(`Удалить всю историю тестов пользователя «${name}»?\nЭто действие нельзя отменить.`, { title: 'Очистить историю', confirmText: 'Удалить историю' })) return;
try {
await LS.adminClearUserSessions(activeUid);
await reloadUserPanel(activeUid);
load();
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.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 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(activeUid, isBanning);
await LS.adminBanUser(uid, isBanning);
LS.toast(isBanning ? 'Пользователь заблокирован' : 'Пользователь разблокирован', isBanning ? 'warning' : 'success');
await reloadUserPanel(activeUid);
load();
reloadDetailAndList();
} 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>','');
const uid = getActiveUid();
if (!uid) return;
const name = _activeName();
if (!await LS.confirm(`Удалить пользователя «${name}» навсегда?\nВсе его данные, тесты и прогресс будут удалены. Это действие нельзя отменить.`, { title: 'Удалить пользователя', confirmText: 'Удалить навсегда' })) return;
try {
await LS.adminDeleteUser(activeUid);
await LS.adminDeleteUser(uid);
LS.toast('Пользователь удалён', 'success');
closeUserPanel();
if (window.AdminRouter) AdminRouter.navigate('#users');
load();
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
@@ -324,7 +287,8 @@
}
function openEditUserModal() {
_editUid = activeUid;
_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 = '';
@@ -349,8 +313,7 @@
try {
await LS.adminUpdateUser(_editUid, payload);
closeEditUserModal();
await reloadUserPanel(activeUid);
load();
reloadDetailAndList();
} catch (e) {
errEl.textContent = 'Ошибка: ' + e.message;
} finally {
@@ -365,13 +328,14 @@
}
async function openUserPermsModal() {
if (!activeUid) return;
const name = document.getElementById('up-name').textContent;
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(activeUid);
_upPermsData = await LS.getUserPermissions(uid);
renderUserPerms();
} catch(e) {
document.getElementById('up-modal-list').innerHTML = `<p style="color:var(--danger);font-size:13px">Ошибка: ${esc(e.message)}</p>`;
@@ -415,10 +379,12 @@
}
async function doSetUserPerm(key, enabled, checkbox) {
const uid = getActiveUid();
if (!uid) return;
checkbox.disabled = true;
try {
await LS.setUserPermission(activeUid, key, enabled);
_upPermsData = await LS.getUserPermissions(activeUid);
await LS.setUserPermission(uid, key, enabled);
_upPermsData = await LS.getUserPermissions(uid);
renderUserPerms();
LS.toast(enabled ? 'Право включено' : 'Право отключено', 'success');
} catch(e) {
@@ -430,20 +396,24 @@
}
async function doResetOneUserPerm(key) {
const uid = getActiveUid();
if (!uid) return;
try {
await LS.resetUserPermissions(activeUid, key);
_upPermsData = await LS.getUserPermissions(activeUid);
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 name = document.getElementById('up-name').textContent;
const uid = getActiveUid();
if (!uid) return;
const name = _activeName();
if (!await LS.confirm(`Сбросить все индивидуальные права «${name}»?\nБудут применены права роли.`, { title: 'Сбросить права', confirmText: 'Сбросить' })) return;
try {
await LS.resetUserPermissions(activeUid);
_upPermsData = await LS.getUserPermissions(activeUid);
await LS.resetUserPermissions(uid);
_upPermsData = await LS.getUserPermissions(uid);
renderUserPerms();
LS.toast('Права сброшены к роли', 'success');
} catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); }
@@ -453,9 +423,6 @@
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;
+12 -1
View File
@@ -9,7 +9,18 @@
- ✅ Phase 3 implemented — `#overview` стал дефолтным route'ом admin-панели. Backend: `GET /api/admin/overview` (admin-only, ~0.08ms/call) возвращает digest за 24ч: новые регистрации, запущенные сессии, активные юзеры, активные классы, failed-сессии, забаненные за неделю (из audit log), топ-5 завершённых сессий. Frontend: `frontend/js/admin/sections/overview.js` (~205L) рендерит bento-grid карточки + alerts + топ-таблицу + quick-links (deep-link через `AdminRouter.navigate`). `admin.js`: дефолт `'stats'``'overview'` в `activate()`, initial nav, и initial init. Old `#stats` остался работающим (доступен через nav-item). Файлы: `frontend/js/admin/sections/overview.js` (NEW), `backend/src/controllers/adminController.js` (+57L: `overviewStmts` + `getOverview`), `backend/src/routes/admin.js` (+1L), `js/api.js` (+1 helper), `frontend/admin.html` (nav-item + tab-pane + script tag), `frontend/js/admin/admin.js` (ROUTE_TO_SECTION + default route refs).
- ✅ Phase 4 implemented — Cmd+K (Ctrl+K) global command palette. Backend: `GET /api/admin/search?q=X` (admin-only) returns `{users[5], tests[3], classes[3]}` via 3 prepared LIKE queries (`title AS name` for tests, `invite_code AS code` for classes). Frontend: `frontend/js/admin/palette.js` (~320L) — custom modal (NOT LS.modal) with capture-phase Ctrl+K listener that `stopImmediatePropagation`'s to override `/js/search.js`. Debounced 150ms, ↑↓ Enter Esc keyboard nav, click-outside close. Action registry (8 entries) is hardcoded — extend by appending to `ACTIONS` const. Result interactions: user → `AdminRouter.navigate('#users/' + id)` (Phase 6 deep page hook), test → `#tests`, class → `/classes#id`. Exposed: `window.AdminPalette = { open, close, isOpen }`, `LS.adminGlobalSearch(q)`. Files: `frontend/js/admin/palette.js` (NEW), `backend/src/controllers/adminController.js` (+50L: `searchStmts` + `globalSearch`), `backend/src/routes/admin.js` (+1L), `js/api.js` (+4L helper + export), `frontend/admin.html` (+1 script tag).
- ✅ Phase 5 implemented — per-row hover quick actions для users + sessions tables. Users row (admin && uid !== self): 4 кнопки (Ban/Unban toggle, Award coins via LS.modal с amount+reason, Sessions → AdminRouter.navigate('#sessions'), Delete). Sessions row: 2 кнопки (View → toggleDrawer, Delete). Все `event.stopPropagation()` чтобы не триггерить row-click overlay/drawer. CSS injected ONCE через `ensureRowActionsStyles()` (de-dup по `#row-actions-style` id, обе секции проверяют existence). Mobile ≤768px: actions hidden (row-click overlay остаётся fallback'ом). Backend: NEW `DELETE /api/admin/sessions/:id` (admin-only) → `_deleteSessionTx` транзакция: nullify `assignment_sessions.session_id`, delete `user_answers` + `session_questions` (FK CASCADE но делаем explicit для visibility), delete `test_sessions`. Audit log: `'session.delete'`. Файлы: `frontend/js/admin/sections/users.js` (343→469L, +126), `frontend/js/admin/sections/sessions.js` (159→210L, +51), `backend/src/controllers/adminController.js` (+27L: `_deleteSessionTx` + `deleteSession`), `backend/src/routes/admin.js` (+1L), `js/api.js` (+1 helper + export). NO эмоджи, inline SVG (Lucide outline-style 24x24 viewBox), Lucide уже доступен через CDN. User-panel overlay НЕ удалена — оставлена для Phase 6.
- Phase 6 not started
- Phase 6 implemented (sub-commits bd30200 + new) — deep entity pages replace legacy `.user-panel` overlay. NEW: `frontend/js/admin/sections/user-detail.js` (~370L) and `frontend/js/admin/sections/session-detail.js` (~180L), both IIFE pattern. `admin.js` has `DEEP_ROUTES = { users:'user-detail', sessions:'session-detail' }` + `activateDeepPane()`; `activate(route, params)` checks for first-param to dispatch deep page (parent nav-item stays highlighted). Sub-tabs (overview/sessions/classes/audit) with URL sync via `udSwitchTab()``AdminRouter.navigate('#users/N/<sub>', { replace: true, silent: true })`. Backend endpoints reused: `GET /api/admin/users/:id/sessions` (user history), `GET /api/admin/sessions/:id` (session detail), `GET /api/admin/audit-log?limit=500` (client-side filtered by uid for Audit tab). Removed: `<div class="user-panel" id="user-panel">` overlay HTML, `.user-panel*` CSS, `openUserPanel`/`closeUserPanel`/`reloadUserPanel` JS, `toggleDrawer`/`renderDrawer` in sessions.js. Row onclick: `openUserPanel(...)``AdminRouter.navigate('#users/N')`; sessions row → `gotoSession(id)``AdminRouter.navigate('#sessions/N')`. `clearUserHistory`/`toggleBanUser`/`confirmDeleteUser` now use `getActiveUid()` helper (reads `window.activeUid` set by user-detail.init) instead of overlay closure. `quickOpenUserSessions(uid)``#users/<uid>/sessions` (deep page, Sessions sub-tab). Classes sub-tab is placeholder (no per-user classes endpoint exists). Charts: simple inline SVG bar chart for per-subject avg %.
## Phase 6 Routes Glossary
- `#users` — list (Phase 2 section)
- `#users/123` — deep page, default Overview sub-tab
- `#users/123/sessions` — deep page, Sessions sub-tab
- `#users/123/classes` — deep page, Classes sub-tab (placeholder)
- `#users/123/audit` — deep page, Audit sub-tab (admin only)
- `#sessions` — list (Phase 2 section)
- `#sessions/456` — deep page
- Cmd+K palette user pick → `#users/N` (opens deep page)
## Temporary Workarounds
+2 -2
View File
@@ -40,7 +40,7 @@
- [x] Phase 3: Dashboard #overview [domain: fullstack] → [subplan](./phase-3-dashboard.md) (parallelizable with 4, 5)
- [x] Phase 4: Cmd+K command palette [domain: fullstack] → [subplan](./phase-4-palette.md) (parallelizable with 3, 5)
- [x] Phase 5: Per-row quick actions [domain: frontend] → [subplan](./phase-5-quick-actions.md) (parallelizable with 3, 4)
- [ ] Phase 6: Deep entity pages [domain: frontend] → [subplan](./phase-6-deep-pages.md)
- [x] Phase 6: Deep entity pages [domain: frontend] → [subplan](./phase-6-deep-pages.md)
**Параллелизация:** фазы 3, 4, 5 независимы (touch different files, no shared state) — выполняются параллельно после завершения фазы 2.
@@ -53,7 +53,7 @@
| Phase 3: Dashboard | fullstack | ✅ Done | ✅ PASS w/ 3 SQL warnings (post-merge polish) | ✅ | ✅ 41acbdd |
| Phase 4: Palette | fullstack | ✅ Done | ✅ PASS w/ notes (limit param cleanup applied) | ✅ | ✅ f562fe4 |
| Phase 5: Quick actions | frontend | ✅ Done | ✅ PASS w/ notes | ✅ | ✅ 69113ab |
| Phase 6: Deep pages | frontend | 🟡 In Progress | ⬜ | ✅ node --check | ⬜ |
| Phase 6: Deep pages | frontend | ✅ Done | ⬜ | ✅ node --check | ⬜ (2 sub-commits: bd30200 + new) |
## Final Review
- [ ] Comprehensive code review (final-reviewer agent)
+47 -19
View File
@@ -1,6 +1,6 @@
# Phase 6: Deep entity pages
**Status:** 🟡 In Progress (sub-commit 1 of 2 done)
**Status:** ✅ Done (sub-commits: bd30200 + final remove-overlay commit)
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** frontend
@@ -10,7 +10,7 @@
## Tasks
- [ ] **User detail page** (`frontend/js/admin/sections/user-detail.js`):
- [x] **User detail page** (`frontend/js/admin/sections/user-detail.js`):
- Реагирует на route `#users/:id`
- Layout:
- **Header**: avatar, name, role badge, email, action buttons (ban/edit/perms/delete), back-link to `#users`
@@ -22,23 +22,19 @@
- **Graphs** (опционально, можно отдельным таб'ом):
- Простой SVG-чарт: успеваемость по неделям
- Mini-bar chart: avg % по предметам
- [ ] **Session detail page** (`frontend/js/admin/sections/session-detail.js`):
- [x] **Session detail page** (`frontend/js/admin/sections/session-detail.js`):
- Реагирует на route `#sessions/:id`
- Layout: header (user, subject, score, дата) + список вопросов/ответов (правильно/нет, текст), back-link
- [ ] **Router updates** (`frontend/js/admin/router.js` если ещё не поддерживает):
- `#users/123` → emit { route: 'users', params: ['123'] }
- `#sessions/456` → emit { route: 'sessions', params: ['456'] }
- [ ] **Admin.js dispatch**:
- При route с params → init detail-section вместо list-section
- При route без params → init list-section (как раньше)
- [ ] **Удалить overlay-код:**
- В `frontend/admin.html` удалить `<div class="user-panel" id="user-panel">` блок
- В `sections/users.js` удалить `openUserPanel`, `closeUserPanel`, `reloadUserPanel`
- В `sections/users.js` поменять onclick: `onclick="openUserPanel(event,${u.id},'${u.role}')"``onclick="AdminRouter.navigate('#users/${u.id}')"`
- [ ] **Replace** в Phase 5 quick action "Sessions" — теперь `AdminRouter.navigate('#users/${uid}/sessions')`:
- Парсить sub-tab из route
- [x] **Router updates** (`frontend/js/admin/router.js` если ещё не поддерживает): router из Phase 1 уже парсит params — обновлять не пришлось
- [x] **Admin.js dispatch**: добавлена `DEEP_ROUTES` map + `activateDeepPane()` + `activate(route, params)`
- [x] **Удалить overlay-код:**
- [x] В `frontend/admin.html` удалён `<div class="user-panel" id="user-panel">` блок + `.user-panel*` CSS
- [x] В `sections/users.js` удалены `openUserPanel`, `closeUserPanel`, `reloadUserPanel`
- [x] В `sections/users.js` onclick переключён на `AdminRouter.navigate('#users/${u.id}')`
- [x] **Replace** в Phase 5 quick action "Sessions": `quickOpenUserSessions(uid)``AdminRouter.navigate('#users/' + uid + '/sessions')`
- Парсить sub-tab из route (выполнено через `params[1]` в `activate()`)
- Открывать user-detail page с активным Sessions tab
- [ ] **Глоссарий routes после фазы:**
- [x] **Глоссарий routes после фазы:**
- `#overview` — dashboard (Phase 3)
- `#users` — list
- `#users/123` — user detail (overview tab default)
@@ -112,6 +108,38 @@
## Handoff to Next Phase
<!-- Это финальная фаза. Implementer записывает: что ещё не сделано,
какие follow-up задачи стоит зафиксировать (графики, realtime, мобильная версия).
Эти заметки помогут final-reviewer и при подготовке merge-summary. -->
Это финальная фаза. Что реализовано в этой фазе:
### Done
- Deep page `#users/:id` с sub-tabs (overview/sessions/classes/audit) и URL-sync sub-routing
- Deep page `#sessions/:id` с полным разбором ответов
- F5 / browser-back / закладки работают на любом deep-URL
- Overlay `.user-panel` полностью удалён (HTML + CSS + JS)
- Sessions row-click переключён с inline drawer на deep page navigation (`gotoSession(id)`)
- Audit sub-tab фильтрует system-wide audit-log по uid client-side
- Inline SVG bar chart для per-subject avg % на Overview sub-tab (no chart.js dep)
- Cmd+K palette user-pick (Phase 4) теперь открывает deep page (ранее был fallback на `#users`)
- 2 sub-commit разбивка для безопасности: `bd30200` (add deep pages, overlay still works) → finale (remove overlay)
### Post-merge follow-ups (NOT блокирующие для merge)
1. **Classes sub-tab — placeholder.** Нет backend endpoint `GET /admin/users/:id/classes`. Текущий UI показывает empty-state со ссылкой на `/classes`. После merge добавить endpoint (выбрать из `class_members` по `user_id`).
2. **Audit sub-tab — client-side filter.** Фильтрация делается на 500 строк из общего лога — для бóльших инсталляций нужен `GET /admin/audit-log?user_id=N` (server-side). Сейчас работает корректно для типичной нагрузки LearnSpace (<10k записей).
3. **Charts — single bar chart only.** План включал опциональные графики "успеваемость по неделям" — оставил на post-merge. Использовать тот же inline SVG паттерн (`.ud-bars` + `.ud-bar-fill`).
4. **Mobile.** Header кнопки сжимаются на ≤640px (см. CSS `.ud-header` block в user-detail.js), но при большом количестве действий могут перекрыть. Можно добавить overflow menu (`<details>`) post-merge если жалобы.
5. **`.user-panel` CSS не полностью удалён** — оставлен только `.btn-close` (используется ещё где-то?). Если нет — можно удалить тоже.
6. **`window.activeUid` — глобальное состояние.** Сейчас и user-detail.js, и users.js пишут/читают `window.activeUid`. Это работает, но в идеале нужно перенести user-only modals (eu-modal, up-modal) в user-detail.js целиком. Не критично, но улучшит изоляцию.
### Final smoke checklist (для final-reviewer)
- [ ] Открыть `/admin#overview` — dashboard
- [ ] Cmd+K → найти юзера → пик из списка → открыть deep page
- [ ] На deep page переключить sub-tabs (URL обновляется)
- [ ] F5 на `#users/N/sessions` → page восстановлен
- [ ] Browser back → возврат на `#users` list
- [ ] Header action: Изменить → modal → save → header обновлён
- [ ] Header action: Бан → toast → header обновлён (метка "Разблокировать")
- [ ] Click on session row in Sessions sub-tab → `#sessions/M` (deep session page)
- [ ] Session page: Delete → toast → navigate back to `#sessions` list
- [ ] No console errors