feat(admin): phase 5 — per-row quick actions for users + sessions

Hover-only action buttons (right-aligned, opacity transition, hidden on mobile).

- users.js: 4 actions (ban/unban, award coins, sessions, delete) — replaces `>` glyph cell, falls back to glyph for non-admin / self

- sessions.js: 2 actions (view, delete)

- DELETE /api/admin/sessions/:id (NEW): transactional (assignment_sessions=NULL, user_answers, session_questions, test_sessions), audit-logged, admin-only

- event.stopPropagation defence-in-depth (each button + parent .row-actions)

- LS.confirm for destructive ops; LS.modal for award-coins amount/reason

- CSS injected once via #row-actions-style id-dedup (same content in both sections)

Existing user-panel overlay + session toggle-drawer flows untouched (Phase 6 removes overlay).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-05-16 23:53:19 +03:00
parent f562fe4a71
commit 69113ab35e
8 changed files with 286 additions and 44 deletions
+28 -1
View File
@@ -293,6 +293,33 @@ function getSessionDetail(req, res) {
res.json(session); res.json(session);
} }
/* ── DELETE /api/admin/sessions/:id ──────────────────────────────────── */
const _deleteSessionTx = db.transaction((sid) => {
// assignment_sessions references test_sessions with ON DELETE SET NULL,
// but we explicitly null it so the assignment slot stays usable.
db.prepare('UPDATE assignment_sessions SET session_id = NULL WHERE session_id = ?').run(sid);
// user_answers / session_questions cascade via ON DELETE CASCADE,
// but delete explicitly for visibility and to mirror clearUserSessions().
db.prepare('DELETE FROM user_answers WHERE session_id = ?').run(sid);
db.prepare('DELETE FROM session_questions WHERE session_id = ?').run(sid);
db.prepare('DELETE FROM test_sessions WHERE id = ?').run(sid);
});
function deleteSession(req, res, next) {
const sid = Number(req.params.id);
if (!Number.isInteger(sid) || sid <= 0)
return res.status(400).json({ error: 'Invalid session id' });
try {
const sess = db.prepare('SELECT id, user_id, mode FROM test_sessions WHERE id = ?').get(sid);
if (!sess) return res.status(404).json({ error: 'Session not found' });
_deleteSessionTx(sid);
audit(req, 'session.delete', `session:${sid}`, `user:${sess.user_id} mode:${sess.mode}`);
res.json({ ok: true });
} catch (err) {
next(err);
}
}
/* ── DELETE /api/admin/users/:id/sessions ────────────────────────────── */ /* ── DELETE /api/admin/users/:id/sessions ────────────────────────────── */
function clearUserSessions(req, res, next) { function clearUserSessions(req, res, next) {
const uid = Number(req.params.id); const uid = Number(req.params.id);
@@ -638,7 +665,7 @@ function broadcast(req, res) {
module.exports = { module.exports = {
getStats, getOverview, globalSearch, getStats, getOverview, globalSearch,
getUsers, updateRole, getUserSessions, getAllSessions, getSessionDetail, getUsers, updateRole, getUserSessions, getAllSessions, getSessionDetail,
clearUserSessions, updateUser, banUser, deleteUser, clearUserSessions, deleteSession, updateUser, banUser, deleteUser,
getFeatures, updateFeatures, getFreeStudentFeatures, updateFreeStudentFeatures, getFeatures, updateFeatures, getFreeStudentFeatures, updateFreeStudentFeatures,
getAuditLog, clearAuditLog, getErrorLog, clearErrorLog, getHealth, getAuditLog, clearAuditLog, getErrorLog, clearErrorLog, getHealth,
getTopics, createTopic, updateTopic, deleteTopic, getTopics, createTopic, updateTopic, deleteTopic,
+1
View File
@@ -26,6 +26,7 @@ router.patch('/users/:id/ban', ctrl.banUser);
router.delete('/users/:id', ctrl.deleteUser); router.delete('/users/:id', ctrl.deleteUser);
router.get('/sessions', ctrl.getAllSessions); router.get('/sessions', ctrl.getAllSessions);
router.get('/sessions/:id', ctrl.getSessionDetail); router.get('/sessions/:id', ctrl.getSessionDetail);
router.delete('/sessions/:id', ctrl.deleteSession);
/* Audit log */ /* Audit log */
router.get('/audit-log', ctrl.getAuditLog); router.get('/audit-log', ctrl.getAuditLog);
+54
View File
@@ -7,10 +7,40 @@
let allSessions = []; let allSessions = [];
let openDrawerId = null; let openDrawerId = null;
/* SVG icons (Lucide-style) — kept local to mirror users.js without coupling */
const SESS_ICONS = {
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>',
trash: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>',
};
/* Inject .row-actions / .row-action-btn styles only if users.js hasn't (sessions can render first). */
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);
}
async function load() { async function load() {
const subject = document.getElementById('t-subject').value; const subject = document.getElementById('t-subject').value;
document.getElementById('t-body').innerHTML = '<div class="spinner"></div>'; document.getElementById('t-body').innerHTML = '<div class="spinner"></div>';
openDrawerId = null; openDrawerId = null;
ensureRowActionsStyles();
try { try {
allSessions = await LS.adminGetSessions({ subject: subject || undefined }); allSessions = await LS.adminGetSessions({ subject: subject || undefined });
renderSessions(); renderSessions();
@@ -68,6 +98,12 @@
</div> </div>
<div class="sess-tl-score">${s.score??'—'} / ${s.total}</div> <div class="sess-tl-score">${s.score??'—'} / ${s.total}</div>
<div class="sess-tl-time">${fmtTime(s.duration_sec)}</div> <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>
<button type="button" class="row-action-btn danger" title="Удалить сессию"
onclick="event.stopPropagation();quickDeleteSession(${s.id},this)">${SESS_ICONS.trash}</button>
</div>
</div> </div>
<div class="sess-tl-drawer" id="tdrawer-${s.id}"> <div class="sess-tl-drawer" id="tdrawer-${s.id}">
<div class="sess-drawer" id="drawer-${s.id}"> <div class="sess-drawer" id="drawer-${s.id}">
@@ -146,10 +182,28 @@
if (window.lucide) lucide.createIcons(); if (window.lucide) lucide.createIcons();
} }
async function quickDeleteSession(id, btn) {
if (!await LS.confirm(
'Удалить эту сессию? Все ответы и связанные данные будут удалены.\nЭто действие нельзя отменить.',
{ title: 'Удалить сессию', confirmText: 'Удалить' }
)) return;
btn.disabled = true;
try {
await LS.adminDeleteSession(id);
LS.toast('Сессия удалена', 'success');
// Refresh from server — keeps grouped layout consistent.
await load();
} catch (e) {
LS.toast('Ошибка: ' + e.message, 'error');
btn.disabled = false;
}
}
// Expose handlers // Expose handlers
window.loadSessions = load; window.loadSessions = load;
window.renderSessions = renderSessions; window.renderSessions = renderSessions;
window.toggleDrawer = toggleDrawer; window.toggleDrawer = toggleDrawer;
window.quickDeleteSession = quickDeleteSession;
window.AdminSections = window.AdminSections || {}; window.AdminSections = window.AdminSections || {};
window.AdminSections.sessions = { window.AdminSections.sessions = {
+140 -1
View File
@@ -7,6 +7,39 @@
let _usersPage = 1; let _usersPage = 1;
const _USERS_PER_PAGE = 50; 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: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="m4.9 4.9 14.2 14.2"/></svg>',
unlock: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 9.9-1"/></svg>',
coins: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="6"/><path d="M18.09 10.37A6 6 0 1 1 10.34 18"/><path d="M7 6h1v4"/><path d="m16.71 13.88.7.71-2.82 2.82"/></svg>',
history: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 9-9 9.74 9.74 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/></svg>',
trash: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>',
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 // user-panel + edit modal + perms modal state
let activeTr = null; let activeTr = null;
let activeUid = null; let activeUid = null;
@@ -19,6 +52,7 @@
const isAdmin = AdminCtx.isAdmin; const isAdmin = AdminCtx.isAdmin;
const user = AdminCtx.user; const user = AdminCtx.user;
if (page) _usersPage = page; if (page) _usersPage = page;
ensureRowActionsStyles();
try { try {
const r = await LS.adminGetUsers({ page: _usersPage, limit: _USERS_PER_PAGE }); const r = await LS.adminGetUsers({ page: _usersPage, limit: _USERS_PER_PAGE });
const users = r.users || []; const users = r.users || [];
@@ -58,7 +92,7 @@
</td> </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">${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="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> <td class="row-actions-cell">${renderUserRowActions(u, isAdmin && u.id !== user.id)}</td>
</tr>`; </tr>`;
}).join(''); }).join('');
renderPgnControls('users-pagination', _usersPage, r.total || users.length, _USERS_PER_PAGE, 'gotoUsersPage'); renderPgnControls('users-pagination', _usersPage, r.total || users.length, _USERS_PER_PAGE, 'gotoUsersPage');
@@ -68,6 +102,106 @@
} }
} }
/* ─── 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 '<span style="color:var(--text-3);font-size:0.85rem;opacity:0.4"></span>';
}
const banIcon = u.is_banned ? ICONS.unlock : ICONS.ban;
const banLabel = u.is_banned ? 'Разблокировать' : 'Заблокировать';
return `<div class="row-actions" onclick="event.stopPropagation()">
<button type="button" class="row-action-btn" title="${banLabel}"
onclick="event.stopPropagation();quickToggleBan(${u.id},${u.is_banned?1:0},this)">${banIcon}</button>
<button type="button" class="row-action-btn" title="Начислить монеты"
onclick="event.stopPropagation();quickAwardCoins(${u.id},'${esc(u.name).replace(/'/g, "\\'")}')">${ICONS.coins}</button>
<button type="button" class="row-action-btn" title="История сессий"
onclick="event.stopPropagation();quickOpenUserSessions(${u.id})">${ICONS.history}</button>
<button type="button" class="row-action-btn danger" title="Удалить пользователя"
onclick="event.stopPropagation();quickDeleteUser(${u.id},'${esc(u.name).replace(/'/g, "\\'")}',this)">${ICONS.trash}</button>
</div>`;
}
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 = `
<p style="margin:0 0 14px;font-size:0.88rem;color:var(--text-2)">Начислить монеты пользователю <strong>${esc(name)}</strong>:</p>
<div style="display:flex;flex-direction:column;gap:10px">
<label style="font-size:0.78rem;font-weight:600;color:var(--text-3)">Количество монет
<input id="qa-coins-amt" type="number" min="1" max="100000" value="100"
style="display:block;margin-top:4px;width:100%;padding:9px 12px;border:1.5px solid rgba(15,23,42,.15);border-radius:10px;font-family:inherit;font-size:0.92rem">
</label>
<label style="font-size:0.78rem;font-weight:600;color:var(--text-3)">Причина (необязательно)
<input id="qa-coins-reason" type="text" maxlength="200" placeholder="напр. награда за активность"
style="display:block;margin-top:4px;width:100%;padding:9px 12px;border:1.5px solid rgba(15,23,42,.15);border-radius:10px;font-family:inherit;font-size:0.88rem">
</label>
</div>`;
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 may extend to `#sessions?user=${uid}` (deep-link with prefilter);
// for now just navigate to sessions tab.
if (window.AdminRouter) AdminRouter.navigate('#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) { function gotoUsersPage(n) {
_usersPage = n; _usersPage = n;
load(); load();
@@ -334,6 +468,11 @@
window.doSetUserPerm = doSetUserPerm; window.doSetUserPerm = doSetUserPerm;
window.doResetOneUserPerm = doResetOneUserPerm; window.doResetOneUserPerm = doResetOneUserPerm;
window.doResetAllUserPerms = doResetAllUserPerms; 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 = window.AdminSections || {};
window.AdminSections.users = { window.AdminSections.users = {
+2 -1
View File
@@ -175,6 +175,7 @@ async function adminGetSessions(params = {}) {
return req('GET', `/admin/sessions?${p}`); return req('GET', `/admin/sessions?${p}`);
} }
async function adminGetSessionDetail(id) { return req('GET', `/admin/sessions/${id}`); } async function adminGetSessionDetail(id) { return req('GET', `/admin/sessions/${id}`); }
async function adminDeleteSession(id) { return req('DELETE',`/admin/sessions/${id}`); }
async function adminClearUserSessions(id) { return req('POST', `/admin/users/${id}/sessions/clear`); } async function adminClearUserSessions(id) { return req('POST', `/admin/users/${id}/sessions/clear`); }
async function adminUpdateUser(id, data) { return req('PATCH', `/admin/users/${id}`, data); } async function adminUpdateUser(id, data) { return req('PATCH', `/admin/users/${id}`, data); }
async function adminBanUser(id, banned) { return req('PATCH', `/admin/users/${id}/ban`, { banned }); } async function adminBanUser(id, banned) { return req('PATCH', `/admin/users/${id}/ban`, { banned }); }
@@ -944,7 +945,7 @@ window.LS = {
register, login, fetchMe, updateProfile, register, login, fetchMe, updateProfile,
getSubjects, updateSubject, getTopics, getSubjects, updateSubject, getTopics,
startSession, sendAnswer, finishSession, getResult, getHistory, getWeakTopics, getStudentStats, getSessionQuestions, startSession, sendAnswer, finishSession, getResult, getHistory, getWeakTopics, getStudentStats, getSessionQuestions,
adminGetStats, adminGetOverview, adminGlobalSearch, adminGetUsers, adminUpdateRole, adminGetUserSessions, adminGetSessions, adminGetSessionDetail, adminClearUserSessions, adminUpdateUser, adminBanUser, adminDeleteUser, adminGetStats, adminGetOverview, adminGlobalSearch, adminGetUsers, adminUpdateRole, adminGetUserSessions, adminGetSessions, adminGetSessionDetail, adminDeleteSession, adminClearUserSessions, adminUpdateUser, adminBanUser, adminDeleteUser,
getQuestions, createQuestion, duplicateQuestion, updateQuestion, deleteQuestion, importQuestions, getQuestions, createQuestion, duplicateQuestion, updateQuestion, deleteQuestion, importQuestions,
getClasses, createClass, getClassDetail, updateClass, deleteClass, kickMember, addClassMember, createAssignment, createDirectAssignment, updateAssignment, deleteAssignment, getClasses, createClass, getClassDetail, updateClass, deleteClass, kickMember, addClassMember, createAssignment, createDirectAssignment, updateAssignment, deleteAssignment,
regenerateInviteCode, classJournal, regenerateInviteCode, classJournal,
+2 -1
View File
@@ -8,7 +8,8 @@
- ✅ Phase 2 implemented (commit 92030b4) — admin.js ужат с ~3591L до 701L. Все 13 plan-tabs живут в `frontend/js/admin/sections/*.js` (IIFE pattern) + `frontend/js/admin/_shared.js` (window.AdminCtx). switchTab() диспетчит в `AdminSections[ROUTE_TO_SECTION[name]].init()`. Lazy-load работает (inited флаг внутри каждой IIFE). System tabs (topics/audit/errors/health/classroom/avatars) остались inline в admin.js — Phase 2 их не extract'ил. - ✅ Phase 2 implemented (commit 92030b4) — admin.js ужат с ~3591L до 701L. Все 13 plan-tabs живут в `frontend/js/admin/sections/*.js` (IIFE pattern) + `frontend/js/admin/_shared.js` (window.AdminCtx). switchTab() диспетчит в `AdminSections[ROUTE_TO_SECTION[name]].init()`. Lazy-load работает (inited флаг внутри каждой IIFE). System tabs (topics/audit/errors/health/classroom/avatars) остались inline в admin.js — Phase 2 их не extract'ил.
- ✅ 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 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 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-6 not started - 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
## Temporary Workarounds ## Temporary Workarounds
+3 -3
View File
@@ -39,7 +39,7 @@
- [x] Phase 2: Split admin.html → per-section modules [domain: frontend] → [subplan](./phase-2-split-sections.md) - [x] Phase 2: Split admin.html → per-section modules [domain: frontend] → [subplan](./phase-2-split-sections.md)
- [x] Phase 3: Dashboard #overview [domain: fullstack] → [subplan](./phase-3-dashboard.md) (parallelizable with 4, 5) - [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 4: Cmd+K command palette [domain: fullstack] → [subplan](./phase-4-palette.md) (parallelizable with 3, 5)
- [ ] Phase 5: Per-row quick actions [domain: frontend] → [subplan](./phase-5-quick-actions.md) (parallelizable with 3, 4) - [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) - [ ] Phase 6: Deep entity pages [domain: frontend] → [subplan](./phase-6-deep-pages.md)
**Параллелизация:** фазы 3, 4, 5 независимы (touch different files, no shared state) — выполняются параллельно после завершения фазы 2. **Параллелизация:** фазы 3, 4, 5 независимы (touch different files, no shared state) — выполняются параллельно после завершения фазы 2.
@@ -51,8 +51,8 @@
| Phase 1: Hash-router | frontend | ✅ Done | ✅ PASS w/ notes | ✅ | ✅ 8a7bed4 | | Phase 1: Hash-router | frontend | ✅ Done | ✅ PASS w/ notes | ✅ | ✅ 8a7bed4 |
| Phase 2: Split sections | frontend | ✅ Done | ✅ PASS (1 blocker fixed: fa67ad1) | ✅ node --check | ✅ 92030b4 + fa67ad1 | | Phase 2: Split sections | frontend | ✅ Done | ✅ PASS (1 blocker fixed: fa67ad1) | ✅ node --check | ✅ 92030b4 + fa67ad1 |
| Phase 3: Dashboard | fullstack | ✅ Done | ✅ PASS w/ 3 SQL warnings (post-merge polish) | ✅ | ✅ 41acbdd | | Phase 3: Dashboard | fullstack | ✅ Done | ✅ PASS w/ 3 SQL warnings (post-merge polish) | ✅ | ✅ 41acbdd |
| Phase 4: Palette | fullstack | ✅ Done | ⬜ | ✅ node --check | ⬜ | | Phase 4: Palette | fullstack | ✅ Done | ✅ PASS w/ notes (limit param cleanup applied) | ✅ | ✅ f562fe4 |
| Phase 5: Quick actions | frontend | ⬜ Not Started | ⬜ | ⬜ | ⬜ | | Phase 5: Quick actions | frontend | ✅ Done | ⬜ | ✅ node --check + tests 32/35 (3 pre-existing auth fails) | ⬜ |
| Phase 6: Deep pages | frontend | ⬜ Not Started | ⬜ | ⬜ | ⬜ | | Phase 6: Deep pages | frontend | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
## Final Review ## Final Review
+56 -37
View File
@@ -1,6 +1,6 @@
# Phase 5: Per-row quick actions # Phase 5: Per-row quick actions
**Status:** ⬜ Not Started **Status:** ✅ Done
**Parent plan:** [PLAN.md](./PLAN.md) **Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** frontend **Domain:** frontend
**Parallelizable with:** Phase 3, Phase 4 **Parallelizable with:** Phase 3, Phase 4
@@ -11,31 +11,36 @@
## Tasks ## Tasks
- [ ] **Users table** (`frontend/js/admin/sections/users.js`): - [x] **Users table** (`frontend/js/admin/sections/users.js`):
- Добавить в каждый `<tr>` дополнительную ячейку или абсолютно-позиционированный блок с action-кнопками - Добавлена `<td class="row-actions-cell">` с inline-flex блоком `.row-actions` (заменяет старый `` индикатор)
- Visible: только на `:hover` строки (via CSS) - Visible: только на `:hover` строки (CSS opacity transition)
- Кнопки: - Кнопки (inline SVG, Lucide-style):
- **🔒 Ban / Unban** — открывает confirm modal, на confirm вызывает существующий `toggleBanUser()` (или его эквивалент с userId) - **Ban / Unban** — `quickToggleBan(uid, isBanned, btn)``LS.confirm``LS.adminBanUser`
- **🪙 Award coins** — открывает быстрый prompt-modal "Сколько монет?", вызывает существующий `shopAdminAwardCoins` без перехода в shop tab - **Award coins** — `quickAwardCoins(uid, name)``LS.modal` (sm) с inputs amount+reason → `LS.adminShopAwardCoins`
- **📜 Sessions** — навигирует через `AdminRouter.navigate('#sessions?user=' + uid)` (param Phase 6 будет обрабатывать; пока fallback — переход на sessions tab) - **Sessions** — `quickOpenUserSessions(uid)` `AdminRouter.navigate('#sessions')` (fallback на `switchTab`)
- **🗑 Delete** — confirm, вызывает существующий `confirmDeleteUser` - **Delete** — `quickDeleteUser(uid, name, btn)``LS.confirm` (destructive) → `LS.adminDeleteUser`
- **ВАЖНО:** иконки только inline SVG (.ic класс) или Lucide — НИКАКИХ эмоджи - SVG-иконки (inline, Lucide outline-style), НЕТ эмоджи
- Кнопки `event.stopPropagation()` чтобы не триггерить `openUserPanel` - `event.stopPropagation()` на каждой кнопке + на родительском `.row-actions` (чтобы не открывать user-panel overlay)
- [ ] **Sessions table** (`frontend/js/admin/sections/sessions.js`): - Hidden для self (`u.id !== user.id`) и для non-admin — fallback на старый ``
- **👁 View** — открыть session detail (текущий механизм) - [x] **Sessions table** (`frontend/js/admin/sections/sessions.js`):
- **🗑 Delete** — confirm + DELETE /admin/sessions/:id (если такой endpoint есть, иначе добавить) - **View (eye icon)** — `toggleDrawer(id)` (тот же flow что и row-click)
- [ ] **Если delete session endpoint отсутствует** — добавить в backend: - **Delete (trash, danger)** — `quickDeleteSession(id, btn)``LS.confirm``LS.adminDeleteSession``load()` (refresh)
- `DELETE /api/admin/sessions/:id` с auth admin only - [x] **Backend `DELETE /api/admin/sessions/:id`** — endpoint отсутствовал, добавлен:
- Контроллер: удалить из `test_sessions` + connected `session_answers` - Route: `backend/src/routes/admin.js` (внутри `requireRole('admin')` блока)
- Audit log entry - Controller: `deleteSession(req, res, next)` в `adminController.js` — транзакция:
- [ ] **CSS** (в admin.html style блоке или новый файл): 1. `UPDATE assignment_sessions SET session_id = NULL WHERE session_id = ?` (explicit null, hoarded slot stays)
```css 2. `DELETE FROM user_answers WHERE session_id = ?` (FK has `ON DELETE CASCADE`, но делаем явно)
.row-actions { opacity: 0; transition: opacity .15s; display: inline-flex; gap: 4px; } 3. `DELETE FROM session_questions WHERE session_id = ?` (то же)
tr:hover .row-actions { opacity: 1; } 4. `DELETE FROM test_sessions WHERE id = ?`
.row-action-btn { width: 28px; height: 28px; border-radius: 6px; ... } - Audit: `audit(req, 'session.delete', 'session:${sid}', 'user:N mode:X')`
``` - Validates `Number.isInteger(sid) && sid > 0`; 404 if not found
- [ ] Подсказки через `title="..."` атрибут на каждой кнопке - API helper: `LS.adminDeleteSession(id)``DELETE /admin/sessions/:id`
- [ ] Confirm-модалки используют `LS.confirm` (не reinventing) - [x] **CSS** (`#row-actions-style`):
- Inject ONCE из обеих секций (de-dup по element id) — оба `ensureRowActionsStyles()` проверяют `getElementById('row-actions-style')` перед добавлением
- Стили: `.row-actions`, `.row-action-btn` (default + .danger), `.row-actions-cell`, `@media (max-width: 768px) { display: none }`
- Также handle `tr.selected .row-actions` и `.sess-tl-item.open .row-actions` → opacity 1 (для активных строк)
- [x] `title="…"` на каждой кнопке (tooltip)
- [x] `LS.confirm(message, { title, confirmText })` использован везде (signature: `lsConfirm(message, { title, confirmText, danger=true })``danger:true` default, gradient pink→violet)
## Files to Modify/Create ## Files to Modify/Create
@@ -78,17 +83,31 @@ Inspired by Linear / Vercel admin: actions visible on row hover, positioned righ
## Review Checklist ## Review Checklist
- [ ] Кнопки не сдвигают layout (используют absolute / hidden / opacity) - [x] Кнопки не сдвигают layout `opacity: 0 → 1` без display swap, занимают слот старого ``
- [ ] Все action эскейпят пользовательский ввод - [x] Имя пользователя в onclick экранируется через `esc()` + `replace(/'/g, "\\'")` для безопасности SQL/HTML-injection в строковых литералах
- [ ] No emoji — только SVG - [x] No emoji — только inline SVG (Lucide-style outline-stroke, viewBox 24x24)
- [ ] event.stopPropagation на всех кнопках - [x] `event.stopPropagation()` на каждой кнопке + на родительском `.row-actions` div (defence in depth)
- [ ] Confirm для destructive actions - [x] Confirm через `LS.confirm` для destructive (delete user, delete session, ban/unban)
- [ ] Tooltip присутствует - [x] `title` атрибут есть на каждой кнопке
- [ ] Mobile-friendly (hidden или альтернативный UI) - [x] Mobile (≤768px): `.row-actions { display: none }` — row-click overlay по-прежнему работает как fallback
- [ ] Build passes - [x] `node --check` all modified files OK
- [x] Tests: 32/35 pass (3 pre-existing auth-test failures, unrelated)
## Handoff to Next Phase ## Handoff to Next Phase
<!-- Implementer: записать, какие action-кнопки добавлены, **Phase 6 (deep entity pages) рекомендации:**
какие param-форматы router использует (`#sessions?user=N`),
что Phase 6 deep-page должна включить (например, replace #users overlay на deep page). --> 1. **`quickOpenUserSessions(uid)`** сейчас просто навигирует на `#sessions` без фильтра. Phase 6 должна:
- Расширить router до `#sessions?user=N` (или новый формат `#sessions/user/N`)
- В `sessions.js` `load()` читать query param и передавать `user_id` в `LS.adminGetSessions({ user_id })` (backend уже поддерживает `user_id` query param — см. `getAllSessions` controller)
- Обновить хелпер: `AdminRouter.navigate('#sessions?user=' + uid)` (когда router научится parse'ить query)
2. **User-panel overlay vs hover actions:** Phase 6 удалит старую `.user-panel` overlay. Когда это произойдёт, row-click больше не будет открывать панель. Hover-actions останутся как primary UX. Рекомендация: при удалении overlay row-click сделать `onclick="AdminRouter.navigate('#users/' + uid)"` (deep page).
3. **Mobile UX gap:** на ≤768px actions сейчас полностью скрыты. Когда Phase 6 добавит deep page, mobile-row-click станет переходом на deep page → primary actions доступны там. До тех пор mobile = read-only browse.
4. **Backend `DELETE /admin/sessions/:id`** уже там, готов для Phase 6 deep session page (где будет кнопка "Удалить эту сессию" в header).
5. **Award coins modal pattern** (используем `LS.modal` с body=DOM Node + actions с `onClick({close, setError})`) — может быть полезен Phase 6 для inline-edit flow на deep user page.
6. **Linter note:** `npm run lint:routes` показывает FAIL (65 unprotected vs baseline 56) — pre-existing проблема, my new admin-protected `DELETE /sessions/:id` добавил +1 false-positive (роут защищён через `router.use(requireRole('admin'))` блок, который linter не видит). Не требует действий — это known limitation скрипта.