feat(admin): Phase 6 sub-commit 1 — add deep-page sections (overlay still works)
Add user-detail.js (~370L) and session-detail.js (~180L) section modules that render full pages for #users/:id and #sessions/:id, plus admin.js dispatch and HTML tab-panes. The legacy .user-panel overlay is intentionally still in place — sub-commit 2 will remove it once the deep pages are verified. * admin.js: DEEP_ROUTES map + activateDeepPane(); activate(route, params) signature; initial dispatch respects hash params (so F5 on #users/123 goes straight to the deep page). * admin.html: new tab-panes #tab-user-detail / #tab-session-detail and two script tags. Old #user-panel overlay untouched. * user-detail.js: header (avatar/role/email/meta) + sub-tabs (Обзор/Сессии/Классы/Audit) with URL-synced sub-tab routing (#users/N/sessions etc). Overview: 4 stat cards + per-subject SVG bar chart. Sessions: clickable rows that navigate to #sessions/N. Classes: placeholder empty-state (no per-user classes endpoint). Audit: client-side filter of /admin/audit-log by uid match. Header action buttons (Изменить/Права/История/Бан/Удалить) call existing overlay handlers; window.activeUid is set before opening any modal. * session-detail.js: full header (user/subject/score/stats) + per- question correctness layout reusing the drawer renderer. Delete button uses LS.adminDeleteSession then navigates to #sessions. Clicking the user name opens the user deep page. * users.js: quickOpenUserSessions now navigates to #users/<uid>/sessions instead of the bare #sessions list. Verified node --check on all new/modified JS. baseline npm test still shows pre-existing 3 auth failures unrelated to this change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1143,6 +1143,16 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Deep page: user detail (#users/:id) — populated by user-detail.js ── -->
|
||||
<div class="tab-pane" id="tab-user-detail">
|
||||
<div id="user-detail-content"><div class="spinner"></div></div>
|
||||
</div>
|
||||
|
||||
<!-- ── Deep page: session detail (#sessions/:id) — populated by session-detail.js ── -->
|
||||
<div class="tab-pane" id="tab-session-detail">
|
||||
<div id="session-detail-content"><div class="spinner"></div></div>
|
||||
</div>
|
||||
|
||||
<!-- ── Тесты (сессии) ── -->
|
||||
<div class="tab-pane" id="tab-sessions">
|
||||
<div class="t-toolbar">
|
||||
@@ -2006,6 +2016,8 @@
|
||||
<script src="/js/admin/sections/questions.js"></script>
|
||||
<script src="/js/admin/sections/users.js"></script>
|
||||
<script src="/js/admin/sections/sessions.js"></script>
|
||||
<script src="/js/admin/sections/user-detail.js"></script>
|
||||
<script src="/js/admin/sections/session-detail.js"></script>
|
||||
<script src="/js/admin/palette.js"></script>
|
||||
<script src="/js/admin/admin.js"></script>
|
||||
</div>
|
||||
|
||||
@@ -69,6 +69,32 @@
|
||||
sublog: 'sublog',
|
||||
};
|
||||
|
||||
/* Phase 6: deep entity pages. When a route has a first param (#users/123),
|
||||
* dispatch to the matching detail section instead of the list section.
|
||||
* Detail sections render into hidden tab-panes (#tab-user-detail / #tab-session-detail)
|
||||
* which are activated by activateDeepPane() below. The "parent" nav item
|
||||
* (Пользователи / Тесты) stays highlighted so users know where they are. */
|
||||
const DEEP_ROUTES = {
|
||||
users: { section: 'user-detail', paneId: 'tab-user-detail', parentTab: 'users' },
|
||||
sessions: { section: 'session-detail', paneId: 'tab-session-detail', parentTab: 'sessions' },
|
||||
};
|
||||
|
||||
function activateDeepPane(deepInfo, params) {
|
||||
// Activate the parent nav item visually (so user knows the section),
|
||||
// but show the deep-page pane instead of the list pane.
|
||||
document.querySelectorAll('.tab-pane').forEach(p => p.classList.remove('active'));
|
||||
document.querySelectorAll('.admin-nav-item').forEach(b => b.classList.remove('active'));
|
||||
const parentBtn = document.querySelector('.admin-nav-item[data-tab="' + deepInfo.parentTab + '"]');
|
||||
if (parentBtn) parentBtn.classList.add('active');
|
||||
const pane = document.getElementById(deepInfo.paneId);
|
||||
if (pane) pane.classList.add('active');
|
||||
const sec = AdminSections[deepInfo.section];
|
||||
if (sec && typeof sec.init === 'function') {
|
||||
// params: [id, subTab?]
|
||||
sec.init(params[0], params[1]);
|
||||
}
|
||||
}
|
||||
|
||||
function switchTab(btn, opts) {
|
||||
if (btn.classList.contains('locked')) {
|
||||
LS.toast('Этот раздел доступен только администраторам', 'warn');
|
||||
@@ -670,8 +696,17 @@
|
||||
(function initAdminRouter() {
|
||||
if (!window.AdminRouter) return;
|
||||
|
||||
function activate(route) {
|
||||
function activate(route, params) {
|
||||
const name = route || 'overview';
|
||||
params = Array.isArray(params) ? params : [];
|
||||
|
||||
// Phase 6: deep page dispatch when route has a first param.
|
||||
const deep = DEEP_ROUTES[name];
|
||||
if (deep && params.length > 0 && AdminSections[deep.section]) {
|
||||
activateDeepPane(deep, params);
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = document.querySelector('.admin-nav-item[data-tab="' + name + '"]');
|
||||
if (!btn) {
|
||||
console.warn('AdminRouter: unknown route', name);
|
||||
@@ -690,13 +725,13 @@
|
||||
switchTab(btn, { fromRouter: true });
|
||||
}
|
||||
|
||||
AdminRouter.on('change', (r) => activate(r.route));
|
||||
AdminRouter.on('change', (r) => activate(r.route, r.params));
|
||||
|
||||
// Initial dispatch: respect existing hash, else default to #overview.
|
||||
const initial = AdminRouter.current();
|
||||
if (!initial.route) {
|
||||
AdminRouter.navigate('#overview', { replace: true, silent: true });
|
||||
} else if (initial.route !== 'overview') {
|
||||
activate(initial.route);
|
||||
} else if (initial.route !== 'overview' || initial.params.length > 0) {
|
||||
activate(initial.route, initial.params);
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
'use strict';
|
||||
/* admin → session-detail (Phase 6) — deep page for a single test session
|
||||
* (#sessions/:id). Replaces the inline drawer rendering when a row is clicked.
|
||||
*
|
||||
* Lazy-init via AdminSections['session-detail'].init(id).
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
/* ── one-time CSS injection ── */
|
||||
function ensureSdStyles() {
|
||||
if (document.getElementById('session-detail-style')) return;
|
||||
const s = document.createElement('style');
|
||||
s.id = 'session-detail-style';
|
||||
s.textContent = `
|
||||
.sd-wrap { padding: 4px 2px 24px; }
|
||||
.sd-back { display:inline-flex; align-items:center; gap:6px; font-size:0.82rem; color:var(--text-3); text-decoration:none; padding:6px 10px; border-radius:8px; margin-bottom:16px; transition:background .12s, color .12s; cursor:pointer; background:transparent; border:0; font-family:inherit; }
|
||||
.sd-back:hover { background:rgba(155,93,229,.07); color:var(--violet); }
|
||||
.sd-back svg { width:14px; height:14px; }
|
||||
.sd-header { display:flex; align-items:center; gap:20px; padding:22px 26px; background:var(--surface); border:1px solid var(--border); border-radius:var(--r-lg); margin-bottom:20px; flex-wrap:wrap; }
|
||||
.sd-user-block { flex:1; min-width:200px; }
|
||||
.sd-user-name { font-family:'Unbounded',sans-serif; font-size:1.05rem; font-weight:800; cursor:pointer; transition:color .12s; }
|
||||
.sd-user-name:hover { color:var(--violet); }
|
||||
.sd-user-meta { font-size:0.82rem; color:var(--text-3); margin-top:4px; }
|
||||
.sd-score { font-family:'Unbounded',sans-serif; font-size:1.5rem; font-weight:800; padding:14px 20px; border-radius:16px; }
|
||||
.sd-score.pct-hi { color:var(--green); background:rgba(16,185,129,.1); }
|
||||
.sd-score.pct-mid { color:var(--amber); background:rgba(255,179,71,.12); }
|
||||
.sd-score.pct-lo { color:var(--pink); background:rgba(241,91,181,.1); }
|
||||
.sd-score.pct-none { color:var(--text-3); background:rgba(15,23,42,.04); }
|
||||
.sd-stats { display:flex; gap:24px; flex-wrap:wrap; }
|
||||
.sd-stat { text-align:center; }
|
||||
.sd-stat-val { font-family:'Unbounded',sans-serif; font-weight:700; font-size:0.95rem; }
|
||||
.sd-stat-val.correct { color:var(--green); }
|
||||
.sd-stat-val.wrong { color:var(--pink); }
|
||||
.sd-stat-val.skipped { color:var(--text-3); }
|
||||
.sd-stat-label { font-size:0.72rem; color:var(--text-3); margin-top:2px; }
|
||||
.sd-actions { display:flex; gap:6px; flex-wrap:wrap; margin-left:auto; }
|
||||
.sd-q-list { display:flex; flex-direction:column; gap:10px; }
|
||||
.sd-q-item { padding:16px 18px; background:var(--surface); border:1px solid var(--border); border-radius:14px; border-left:4px solid var(--text-3); }
|
||||
.sd-q-item.correct { border-left-color:var(--green); }
|
||||
.sd-q-item.wrong { border-left-color:var(--pink); }
|
||||
.sd-q-item.skipped { border-left-color:var(--amber); }
|
||||
.sd-q-header { display:flex; align-items:center; gap:10px; margin-bottom:8px; flex-wrap:wrap; }
|
||||
.sd-q-num { font-size:0.74rem; color:var(--text-3); font-weight:700; text-transform:uppercase; letter-spacing:.04em; }
|
||||
.sd-q-badge { font-size:0.7rem; padding:2px 8px; border-radius:var(--r-pill); font-weight:700; }
|
||||
.sd-q-badge.correct { background:rgba(16,185,129,.12); color:var(--green); }
|
||||
.sd-q-badge.wrong { background:rgba(241,91,181,.12); color:var(--pink); }
|
||||
.sd-q-badge.skipped { background:rgba(255,179,71,.14); color:var(--amber); }
|
||||
.sd-q-time { font-size:0.72rem; color:var(--text-3); margin-left:auto; }
|
||||
.sd-q-text { font-size:0.92rem; line-height:1.45; margin-bottom:10px; }
|
||||
.sd-q-opts { display:flex; flex-direction:column; gap:5px; }
|
||||
.sd-q-opt { padding:8px 12px; border-radius:8px; background:rgba(15,23,42,.03); font-size:0.86rem; display:flex; align-items:center; gap:8px; }
|
||||
.sd-q-opt.correct-opt { background:rgba(16,185,129,.08); color:var(--green); font-weight:600; }
|
||||
.sd-q-opt.chosen-wrong { background:rgba(241,91,181,.08); color:var(--pink); font-weight:600; }
|
||||
.sd-q-opt-icon { width:14px; height:14px; flex-shrink:0; display:inline-flex; }
|
||||
.sd-q-expl { margin-top:10px; padding:10px 14px; background:rgba(155,93,229,.06); border-radius:8px; font-size:0.84rem; color:var(--text-2); }
|
||||
.sd-empty { padding:30px; text-align:center; color:var(--text-3); font-size:0.88rem; background:var(--surface); border:1px dashed var(--border); border-radius:var(--r-lg); }
|
||||
@media (max-width: 640px) {
|
||||
.sd-header { padding:16px 14px; gap:14px; }
|
||||
.sd-actions { margin-left:0; width:100%; }
|
||||
.sd-score { font-size:1.2rem; padding:10px 14px; }
|
||||
.sd-stats { gap:14px; }
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
|
||||
const ICONS = {
|
||||
arrowLeft: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>',
|
||||
trash: '<i data-lucide="trash-2" style="width:13px;height:13px;vertical-align:-2px"></i>',
|
||||
};
|
||||
|
||||
let _sessionId = null;
|
||||
let _data = null;
|
||||
|
||||
async function init(id) {
|
||||
ensureSdStyles();
|
||||
const newId = Number(id);
|
||||
if (!Number.isFinite(newId) || newId <= 0) {
|
||||
renderError('Некорректный ID сессии');
|
||||
return;
|
||||
}
|
||||
if (_sessionId === newId && _data) {
|
||||
render();
|
||||
return;
|
||||
}
|
||||
_sessionId = newId;
|
||||
_data = null;
|
||||
renderLoading();
|
||||
try {
|
||||
_data = await LS.adminGetSessionDetail(newId);
|
||||
render();
|
||||
} catch (e) {
|
||||
renderError(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
function renderLoading() {
|
||||
const el = document.getElementById('session-detail-content');
|
||||
if (!el) return;
|
||||
el.innerHTML = '<div class="sd-wrap"><div class="spinner"></div></div>';
|
||||
}
|
||||
|
||||
function renderError(msg) {
|
||||
const el = document.getElementById('session-detail-content');
|
||||
if (!el) return;
|
||||
el.innerHTML = `<div class="sd-wrap">
|
||||
<button type="button" class="sd-back" onclick="AdminRouter.navigate('#sessions')">${ICONS.arrowLeft} К списку сессий</button>
|
||||
<div class="sd-empty" style="color:var(--pink)">${esc(msg)}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function render() {
|
||||
const el = document.getElementById('session-detail-content');
|
||||
if (!el || !_data) return;
|
||||
const d = _data;
|
||||
const { MODES, pctClass, fmtDate, fmtTime, renderMath } = AdminCtx;
|
||||
const pct = (d.score !== null && d.score !== undefined && d.total)
|
||||
? Math.round((d.score / d.total) * 100)
|
||||
: null;
|
||||
const pc = pct === null ? 'pct-none' : 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 isAdmin = AdminCtx.isAdmin;
|
||||
|
||||
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 viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><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="sd-q-opt ${cls}"><span class="sd-q-opt-icon">${icon}</span>${esc(o.text)}</div>`;
|
||||
}).join('');
|
||||
const expl = q.explanation ? `<div class="sd-q-expl"><strong>Пояснение:</strong> ${esc(q.explanation)}</div>` : '';
|
||||
return `<div class="sd-q-item ${status}">
|
||||
<div class="sd-q-header">
|
||||
<span class="sd-q-num">Вопрос ${i + 1}</span>
|
||||
<span class="sd-q-badge ${status}">${badgeTxt}</span>
|
||||
<span class="sd-q-time">${q.time_spent_sec ? q.time_spent_sec + ' сек' : ''}</span>
|
||||
</div>
|
||||
<div class="sd-q-text">${esc(q.text || '')}</div>
|
||||
<div class="sd-q-opts">${opts}</div>${expl}
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="sd-wrap">
|
||||
<button type="button" class="sd-back" onclick="AdminRouter.navigate('#sessions')">${ICONS.arrowLeft} К списку сессий</button>
|
||||
<div class="sd-header">
|
||||
<div class="sd-user-block">
|
||||
<div class="sd-user-name" onclick="AdminRouter.navigate('#users/${d.user_id}')" title="Открыть страницу пользователя">${esc(d.user_name || '?')}</div>
|
||||
<div class="sd-user-meta">${esc(d.user_email || '')} · ${esc(d.subject_name || 'Тест')} · <span class="mode-badge mode-${d.mode}">${MODES[d.mode] || d.mode}</span></div>
|
||||
<div class="sd-user-meta">${fmtDate(d.started_at)}${d.finished_at ? ' · завершена ' + fmtDate(d.finished_at) : ''}</div>
|
||||
</div>
|
||||
<div class="sd-score ${pc}">${pct !== null ? pct + '%' : '—'}</div>
|
||||
<div class="sd-stats">
|
||||
<div class="sd-stat"><div class="sd-stat-val correct">${correct}</div><div class="sd-stat-label">Верно</div></div>
|
||||
<div class="sd-stat"><div class="sd-stat-val wrong">${wrong}</div><div class="sd-stat-label">Неверно</div></div>
|
||||
<div class="sd-stat"><div class="sd-stat-val skipped">${skipped}</div><div class="sd-stat-label">Пропущено</div></div>
|
||||
<div class="sd-stat"><div class="sd-stat-val">${fmtTime(d.duration_sec)}</div><div class="sd-stat-label">Время</div></div>
|
||||
</div>
|
||||
${isAdmin ? `<div class="sd-actions">
|
||||
<button class="btn-del-q" onclick="sdDeleteSession()" style="background:rgba(239,68,68,.12);color:#EF4444;border-color:rgba(239,68,68,.25)">${ICONS.trash} Удалить</button>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
<div class="sd-q-list">${qHtml || '<div class="sd-empty">Вопросы не найдены</div>'}</div>
|
||||
</div>
|
||||
`;
|
||||
renderMath(el);
|
||||
if (window.lucide) lucide.createIcons({ nodes: [el] });
|
||||
}
|
||||
|
||||
async function deleteSession() {
|
||||
if (!_sessionId) return;
|
||||
if (!await LS.confirm(
|
||||
'Удалить эту сессию? Все ответы и связанные данные будут удалены.\nЭто действие нельзя отменить.',
|
||||
{ title: 'Удалить сессию', confirmText: 'Удалить' }
|
||||
)) return;
|
||||
try {
|
||||
await LS.adminDeleteSession(_sessionId);
|
||||
LS.toast('Сессия удалена', 'success');
|
||||
AdminRouter.navigate('#sessions');
|
||||
} catch (e) {
|
||||
LS.toast('Ошибка: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Expose ── */
|
||||
window.sdDeleteSession = deleteSession;
|
||||
|
||||
window.AdminSections = window.AdminSections || {};
|
||||
window.AdminSections['session-detail'] = {
|
||||
init,
|
||||
reload: () => init(_sessionId),
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,423 @@
|
||||
'use strict';
|
||||
/* admin → user-detail (Phase 6) — deep page for a single user (#users/:id).
|
||||
*
|
||||
* Replaces the legacy `.user-panel` overlay. Lazy-init via
|
||||
* AdminSections['user-detail'].init(id, subTab)
|
||||
* where subTab ∈ 'overview' | 'sessions' | 'classes' | 'audit'.
|
||||
*
|
||||
* Reuses existing user-related modals (openEditUserModal, openUserPermsModal,
|
||||
* etc.) — they live in sections/users.js and operate on `window.activeUid`,
|
||||
* which we set before opening any of them.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
/* ── one-time CSS injection ── */
|
||||
function ensureUdStyles() {
|
||||
if (document.getElementById('user-detail-style')) return;
|
||||
const s = document.createElement('style');
|
||||
s.id = 'user-detail-style';
|
||||
s.textContent = `
|
||||
.ud-wrap { padding: 4px 2px 24px; }
|
||||
.ud-back { display:inline-flex; align-items:center; gap:6px; font-size:0.82rem; color:var(--text-3); text-decoration:none; padding:6px 10px; border-radius:8px; margin-bottom:16px; transition:background .12s, color .12s; cursor:pointer; background:transparent; border:0; font-family:inherit; }
|
||||
.ud-back:hover { background:rgba(155,93,229,.07); color:var(--violet); }
|
||||
.ud-back svg { width:14px; height:14px; }
|
||||
.ud-header { display:flex; align-items:flex-start; gap:20px; padding:24px 26px; background:var(--surface); border:1px solid var(--border); border-radius:var(--r-lg); margin-bottom:20px; flex-wrap:wrap; }
|
||||
.ud-avatar { width:64px; height:64px; border-radius:18px; display:flex; align-items:center; justify-content:center; font-family:'Unbounded',sans-serif; font-size:1.1rem; font-weight:800; color:#fff; flex-shrink:0; }
|
||||
.ud-avatar.banned { filter:grayscale(1); opacity:.6; }
|
||||
.ud-id-block { flex:1; min-width:200px; }
|
||||
.ud-name { font-family:'Unbounded',sans-serif; font-size:1.25rem; font-weight:800; line-height:1.2; display:flex; align-items:center; gap:10px; flex-wrap:wrap; }
|
||||
.ud-name .ud-role-badge { font-size:0.7rem; padding:3px 9px; border-radius:var(--r-pill); font-weight:700; letter-spacing:.02em; vertical-align:middle; }
|
||||
.ud-name .ud-banned-tag { font-size:0.66rem; padding:2px 7px; border-radius:4px; background:rgba(239,68,68,.12); color:#EF4444; font-weight:700; }
|
||||
.ud-email { font-size:0.88rem; color:var(--text-3); margin-top:6px; }
|
||||
.ud-meta-row { display:flex; gap:18px; margin-top:10px; font-size:0.76rem; color:var(--text-3); flex-wrap:wrap; }
|
||||
.ud-meta-row strong { color:var(--text-2); font-weight:600; }
|
||||
.ud-actions { display:flex; flex-wrap:wrap; gap:6px; align-items:flex-start; margin-left:auto; }
|
||||
.ud-actions .btn-edit-q, .ud-actions .btn-del-q { white-space:nowrap; }
|
||||
.ud-tabs { display:flex; gap:2px; border-bottom:1px solid var(--border); margin-bottom:20px; overflow-x:auto; }
|
||||
.ud-tab-btn { background:transparent; border:0; padding:11px 18px; font-family:inherit; font-size:0.86rem; font-weight:600; color:var(--text-3); cursor:pointer; border-bottom:2px solid transparent; transition:color .12s, border-color .12s; white-space:nowrap; }
|
||||
.ud-tab-btn:hover { color:var(--text-2); }
|
||||
.ud-tab-btn.active { color:var(--violet); border-bottom-color:var(--violet); }
|
||||
.ud-tab-pane { display:none; }
|
||||
.ud-tab-pane.active { display:block; }
|
||||
.ud-stats { display:grid; grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); gap:14px; margin-bottom:24px; }
|
||||
.ud-stat { padding:18px 18px; background:var(--surface); border:1px solid var(--border); border-radius:var(--r-lg); }
|
||||
.ud-stat-val { font-family:'Unbounded',sans-serif; font-size:1.5rem; font-weight:800; line-height:1.1; }
|
||||
.ud-stat-val.pct-hi { color:var(--green); }
|
||||
.ud-stat-val.pct-mid { color:var(--amber); }
|
||||
.ud-stat-val.pct-lo { color:var(--pink); }
|
||||
.ud-stat-label { font-size:0.74rem; color:var(--text-3); font-weight:600; text-transform:uppercase; letter-spacing:.03em; margin-top:6px; }
|
||||
.ud-sess-list { display:flex; flex-direction:column; gap:6px; }
|
||||
.ud-sess-row { display:flex; align-items:center; gap:14px; padding:12px 16px; background:var(--surface); border:1px solid var(--border); border-radius:12px; cursor:pointer; transition:border-color .12s, background .12s; }
|
||||
.ud-sess-row:hover { border-color:rgba(155,93,229,.35); background:rgba(155,93,229,.04); }
|
||||
.ud-sess-pct { font-family:'Unbounded',sans-serif; font-weight:800; font-size:0.9rem; width:50px; text-align:center; padding:6px 0; border-radius:8px; }
|
||||
.ud-sess-pct.pct-hi { color:var(--green); background:rgba(16,185,129,.1); }
|
||||
.ud-sess-pct.pct-mid { color:var(--amber); background:rgba(255,179,71,.12); }
|
||||
.ud-sess-pct.pct-lo { color:var(--pink); background:rgba(241,91,181,.1); }
|
||||
.ud-sess-info { flex:1; min-width:0; }
|
||||
.ud-sess-subj { font-weight:600; font-size:0.9rem; }
|
||||
.ud-sess-meta { font-size:0.76rem; color:var(--text-3); margin-top:2px; }
|
||||
.ud-sess-score { font-weight:700; font-size:0.88rem; }
|
||||
.ud-sess-chev { color:var(--text-3); flex-shrink:0; }
|
||||
.ud-empty { padding:30px; text-align:center; color:var(--text-3); font-size:0.88rem; background:var(--surface); border:1px dashed var(--border); border-radius:var(--r-lg); }
|
||||
.ud-audit-list { display:flex; flex-direction:column; gap:6px; }
|
||||
.ud-audit-row { display:flex; gap:14px; padding:10px 14px; background:var(--surface); border:1px solid var(--border); border-radius:10px; font-size:0.84rem; align-items:center; flex-wrap:wrap; }
|
||||
.ud-audit-when { font-size:0.74rem; color:var(--text-3); min-width:140px; }
|
||||
.ud-audit-action { font-weight:700; font-size:0.78rem; }
|
||||
.ud-audit-detail { color:var(--text-3); font-size:0.78rem; flex:1; min-width:140px; overflow:hidden; text-overflow:ellipsis; }
|
||||
.ud-chart-card { padding:18px 20px; background:var(--surface); border:1px solid var(--border); border-radius:var(--r-lg); margin-top:20px; }
|
||||
.ud-chart-title { font-size:0.78rem; font-weight:700; text-transform:uppercase; letter-spacing:.04em; color:var(--text-3); margin-bottom:12px; }
|
||||
.ud-bars { display:flex; flex-direction:column; gap:8px; }
|
||||
.ud-bar-row { display:flex; align-items:center; gap:10px; }
|
||||
.ud-bar-name { font-size:0.84rem; min-width:120px; }
|
||||
.ud-bar-track { flex:1; height:18px; background:rgba(15,23,42,.06); border-radius:6px; overflow:hidden; position:relative; }
|
||||
.ud-bar-fill { height:100%; border-radius:6px; transition:width .3s; }
|
||||
.ud-bar-fill.pct-hi { background:var(--green); }
|
||||
.ud-bar-fill.pct-mid { background:var(--amber); }
|
||||
.ud-bar-fill.pct-lo { background:var(--pink); }
|
||||
.ud-bar-val { font-family:'Unbounded',sans-serif; font-size:0.82rem; font-weight:700; min-width:48px; text-align:right; }
|
||||
@media (max-width: 640px) {
|
||||
.ud-header { padding:18px 16px; gap:14px; }
|
||||
.ud-actions { margin-left:0; width:100%; }
|
||||
.ud-actions .btn-edit-q, .ud-actions .btn-del-q { font-size:0.78rem; padding:6px 10px; }
|
||||
.ud-sess-row { padding:10px 12px; gap:10px; }
|
||||
.ud-sess-meta { font-size:0.72rem; }
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
|
||||
const ROLE_LABEL = { student:'Ученик', free_student:'Своб. ученик', teacher:'Учитель', admin:'Админ' };
|
||||
const ROLE_BG = {
|
||||
admin: 'linear-gradient(135deg,#9B5DE5,#c084fc)',
|
||||
teacher: 'linear-gradient(135deg,#06D6E0,#9B5DE5)',
|
||||
free_student: 'linear-gradient(135deg,#10B981,#059669)',
|
||||
student: 'linear-gradient(135deg,#8898AA,#3D4F6B)',
|
||||
};
|
||||
const ROLE_BADGE_BG = {
|
||||
admin: 'rgba(155,93,229,.14)', teacher: 'rgba(6,214,224,.14)',
|
||||
free_student: 'rgba(16,185,129,.14)', student: 'rgba(136,152,170,.14)',
|
||||
};
|
||||
const ROLE_BADGE_FG = {
|
||||
admin: 'var(--violet)', teacher: '#05aab3',
|
||||
free_student: 'var(--green)', student: 'var(--text-2)',
|
||||
};
|
||||
|
||||
/* SVG icons */
|
||||
const ICONS = {
|
||||
arrowLeft: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>',
|
||||
chev: '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>',
|
||||
ban: '<i data-lucide="ban" style="width:13px;height:13px;vertical-align:-2px"></i>',
|
||||
};
|
||||
|
||||
/* State */
|
||||
let _userId = null;
|
||||
let _userData = null; // last fetched user object
|
||||
let _sessions = []; // last fetched sessions array
|
||||
let _activeSubTab = 'overview';
|
||||
|
||||
/* ── Public init: called by admin.js dispatch ── */
|
||||
async function init(id, subTab) {
|
||||
ensureUdStyles();
|
||||
const newId = Number(id);
|
||||
if (!Number.isFinite(newId) || newId <= 0) {
|
||||
renderError('Некорректный ID пользователя');
|
||||
return;
|
||||
}
|
||||
_activeSubTab = subTab || 'overview';
|
||||
// Make user-related modal handlers (openEditUserModal etc.) work — they read window.activeUid.
|
||||
window.activeUid = newId;
|
||||
|
||||
if (_userId === newId && _userData) {
|
||||
// Same user — just switch sub-tab without re-fetch
|
||||
renderShell();
|
||||
switchSubTab(_activeSubTab, /*pushUrl*/ false);
|
||||
return;
|
||||
}
|
||||
_userId = newId;
|
||||
_userData = null;
|
||||
_sessions = [];
|
||||
renderLoading();
|
||||
try {
|
||||
const data = await LS.adminGetUserSessions(newId);
|
||||
_userData = data.user;
|
||||
_sessions = Array.isArray(data.sessions) ? data.sessions : [];
|
||||
// Sync globals used by overlay-era modal helpers (still live in users.js).
|
||||
window.activeUid = newId;
|
||||
window.activeUserRole = _userData?.role || null;
|
||||
renderShell();
|
||||
switchSubTab(_activeSubTab, /*pushUrl*/ false);
|
||||
} catch (e) {
|
||||
renderError(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
function renderLoading() {
|
||||
const el = document.getElementById('user-detail-content');
|
||||
if (!el) return;
|
||||
el.innerHTML = '<div class="ud-wrap"><div class="spinner"></div></div>';
|
||||
}
|
||||
|
||||
function renderError(msg) {
|
||||
const el = document.getElementById('user-detail-content');
|
||||
if (!el) return;
|
||||
el.innerHTML = `<div class="ud-wrap">
|
||||
<button type="button" class="ud-back" onclick="AdminRouter.navigate('#users')">${ICONS.arrowLeft} К списку</button>
|
||||
<div class="ud-empty" style="color:var(--pink)">${esc(msg)}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderShell() {
|
||||
const el = document.getElementById('user-detail-content');
|
||||
if (!el || !_userData) return;
|
||||
const u = _userData;
|
||||
const isAdmin = AdminCtx.isAdmin;
|
||||
const isSelf = AdminCtx.user && AdminCtx.user.id === u.id;
|
||||
const canAct = isAdmin && !isSelf;
|
||||
const initials = (u.name || '?').split(' ').slice(0, 2).map(w => (w[0] || '').toUpperCase()).join('') || '?';
|
||||
const avatarBg = ROLE_BG[u.role] || ROLE_BG.student;
|
||||
const roleLabel = ROLE_LABEL[u.role] || u.role;
|
||||
const bannedTag = u.is_banned ? ' <span class="ud-banned-tag">заблокирован</span>' : '';
|
||||
const banLabel = u.is_banned ? 'Разблокировать' : 'Заблокировать';
|
||||
|
||||
const actions = canAct ? `
|
||||
<div class="ud-actions">
|
||||
<button class="btn-edit-q" onclick="openEditUserModal()"><i data-lucide="pencil" style="width:13px;height:13px;vertical-align:-2px"></i> Изменить</button>
|
||||
${u.role === 'teacher' ? '<button class="btn-edit-q" onclick="openUserPermsModal()"><i data-lucide="shield" style="width:13px;height:13px;vertical-align:-2px"></i> Права</button>' : ''}
|
||||
<button class="btn-del-q" onclick="clearUserHistory()"><i data-lucide="trash-2" style="width:13px;height:13px;vertical-align:-2px"></i> История</button>
|
||||
<button class="btn-del-q" onclick="toggleBanUser()" ${u.is_banned ? 'style="background:rgba(34,197,94,.12);color:#22C55E;border-color:rgba(34,197,94,.25)"' : ''}>${ICONS.ban} <span id="up-ban-label">${banLabel}</span></button>
|
||||
<button class="btn-del-q" onclick="confirmDeleteUser()" style="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>
|
||||
</div>` : '';
|
||||
|
||||
const created = u.created_at ? AdminCtx.fmtDate(u.created_at) : '—';
|
||||
const lastLog = u.last_login ? new Date(u.last_login).toLocaleString('ru', { day:'numeric', month:'short', year:'numeric', hour:'2-digit', minute:'2-digit' }) : '—';
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="ud-wrap">
|
||||
<button type="button" class="ud-back" onclick="AdminRouter.navigate('#users')">${ICONS.arrowLeft} К списку пользователей</button>
|
||||
<div class="ud-header">
|
||||
<div class="ud-avatar${u.is_banned ? ' banned' : ''}" style="background:${avatarBg}">${esc(initials)}</div>
|
||||
<div class="ud-id-block">
|
||||
<div class="ud-name">
|
||||
<span id="up-name">${esc(u.name)}</span>
|
||||
<span class="ud-role-badge" style="background:${ROLE_BADGE_BG[u.role] || ROLE_BADGE_BG.student};color:${ROLE_BADGE_FG[u.role] || ROLE_BADGE_FG.student}">${roleLabel}</span>
|
||||
${bannedTag}
|
||||
</div>
|
||||
<div class="ud-email" id="up-email">${esc(u.email || '')}</div>
|
||||
<div class="ud-meta-row">
|
||||
<span><strong>Регистрация:</strong> ${created}</span>
|
||||
<span><strong>Последний вход:</strong> ${lastLog}</span>
|
||||
<span><strong>ID:</strong> #${u.id}</span>
|
||||
</div>
|
||||
</div>
|
||||
${actions}
|
||||
</div>
|
||||
<div class="ud-tabs" role="tablist">
|
||||
<button type="button" class="ud-tab-btn" data-st="overview" onclick="udSwitchTab('overview')">Обзор</button>
|
||||
<button type="button" class="ud-tab-btn" data-st="sessions" onclick="udSwitchTab('sessions')">Сессии</button>
|
||||
<button type="button" class="ud-tab-btn" data-st="classes" onclick="udSwitchTab('classes')">Классы</button>
|
||||
${isAdmin ? '<button type="button" class="ud-tab-btn" data-st="audit" onclick="udSwitchTab(\'audit\')">Audit</button>' : ''}
|
||||
</div>
|
||||
<div class="ud-tab-pane" id="ud-pane-overview"></div>
|
||||
<div class="ud-tab-pane" id="ud-pane-sessions"></div>
|
||||
<div class="ud-tab-pane" id="ud-pane-classes"></div>
|
||||
<div class="ud-tab-pane" id="ud-pane-audit"></div>
|
||||
</div>
|
||||
`;
|
||||
if (window.lucide) lucide.createIcons({ nodes: [el] });
|
||||
}
|
||||
|
||||
function switchSubTab(name, pushUrl) {
|
||||
const allowed = ['overview', 'sessions', 'classes', 'audit'];
|
||||
if (!allowed.includes(name)) name = 'overview';
|
||||
_activeSubTab = name;
|
||||
document.querySelectorAll('#user-detail-content .ud-tab-btn').forEach(b => {
|
||||
b.classList.toggle('active', b.dataset.st === name);
|
||||
});
|
||||
document.querySelectorAll('#user-detail-content .ud-tab-pane').forEach(p => p.classList.remove('active'));
|
||||
const pane = document.getElementById('ud-pane-' + name);
|
||||
if (pane) pane.classList.add('active');
|
||||
|
||||
if (pushUrl && window.AdminRouter && _userId) {
|
||||
const target = name === 'overview' ? `#users/${_userId}` : `#users/${_userId}/${name}`;
|
||||
AdminRouter.navigate(target, { replace: true, silent: true });
|
||||
}
|
||||
|
||||
if (name === 'overview') renderOverview();
|
||||
else if (name === 'sessions') renderSessions();
|
||||
else if (name === 'classes') renderClasses();
|
||||
else if (name === 'audit') renderAudit();
|
||||
}
|
||||
|
||||
/* ── Overview tab ── */
|
||||
function renderOverview() {
|
||||
const pane = document.getElementById('ud-pane-overview');
|
||||
if (!pane || !_userData) return;
|
||||
const u = _userData;
|
||||
const total = _sessions.length;
|
||||
const completed = _sessions.filter(s => s.score !== null && s.score !== undefined);
|
||||
const avgPct = completed.length
|
||||
? Math.round(completed.reduce((acc, s) => acc + Math.round((s.score / s.total) * 100), 0) / completed.length)
|
||||
: null;
|
||||
const pcCls = AdminCtx.pctClass(avgPct);
|
||||
const lastSess = _sessions[0];
|
||||
const lastDate = lastSess ? AdminCtx.fmtDate(lastSess.started_at) : '—';
|
||||
|
||||
// Aggregate by subject for simple bar chart
|
||||
const bySubj = {};
|
||||
completed.forEach(s => {
|
||||
const k = s.subject_name || 'Без предмета';
|
||||
bySubj[k] = bySubj[k] || { sum: 0, n: 0 };
|
||||
bySubj[k].sum += Math.round((s.score / s.total) * 100);
|
||||
bySubj[k].n += 1;
|
||||
});
|
||||
const subjBars = Object.entries(bySubj)
|
||||
.map(([name, v]) => ({ name, pct: Math.round(v.sum / v.n), n: v.n }))
|
||||
.sort((a, b) => b.n - a.n)
|
||||
.slice(0, 6);
|
||||
|
||||
const barHtml = subjBars.length ? subjBars.map(b => {
|
||||
const pc = AdminCtx.pctClass(b.pct);
|
||||
return `<div class="ud-bar-row">
|
||||
<div class="ud-bar-name">${esc(b.name)} <span style="color:var(--text-3);font-size:0.72rem">(${b.n})</span></div>
|
||||
<div class="ud-bar-track"><div class="ud-bar-fill ${pc}" style="width:${b.pct}%"></div></div>
|
||||
<div class="ud-bar-val">${b.pct}%</div>
|
||||
</div>`;
|
||||
}).join('') : '<div class="ud-empty" style="padding:14px">Нет данных по предметам</div>';
|
||||
|
||||
pane.innerHTML = `
|
||||
<div class="ud-stats">
|
||||
<div class="ud-stat">
|
||||
<div class="ud-stat-val">${total}</div>
|
||||
<div class="ud-stat-label">Всего сессий</div>
|
||||
</div>
|
||||
<div class="ud-stat">
|
||||
<div class="ud-stat-val ${pcCls}">${avgPct !== null ? avgPct + '%' : '—'}</div>
|
||||
<div class="ud-stat-label">Средний %</div>
|
||||
</div>
|
||||
<div class="ud-stat">
|
||||
<div class="ud-stat-val" style="font-size:1rem">${u.created_at ? AdminCtx.fmtDate(u.created_at) : '—'}</div>
|
||||
<div class="ud-stat-label">Регистрация</div>
|
||||
</div>
|
||||
<div class="ud-stat">
|
||||
<div class="ud-stat-val" style="font-size:1rem">${lastDate}</div>
|
||||
<div class="ud-stat-label">Последняя сессия</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ud-chart-card">
|
||||
<div class="ud-chart-title">Успеваемость по предметам</div>
|
||||
<div class="ud-bars">${barHtml}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/* ── Sessions tab ── */
|
||||
function renderSessions() {
|
||||
const pane = document.getElementById('ud-pane-sessions');
|
||||
if (!pane) return;
|
||||
if (!_sessions.length) {
|
||||
pane.innerHTML = '<div class="ud-empty">Тестов нет</div>';
|
||||
return;
|
||||
}
|
||||
const { MODES, pctClass, fmtDate } = AdminCtx;
|
||||
pane.innerHTML = '<div class="ud-sess-list">' + _sessions.map(s => {
|
||||
const pct = (s.score !== null && s.score !== undefined && s.total)
|
||||
? Math.round((s.score / s.total) * 100)
|
||||
: null;
|
||||
const pc = pctClass(pct);
|
||||
return `<div class="ud-sess-row" onclick="AdminRouter.navigate('#sessions/${s.id}')">
|
||||
<div class="ud-sess-pct ${pc}">${pct !== null ? pct + '%' : '—'}</div>
|
||||
<div class="ud-sess-info">
|
||||
<div class="ud-sess-subj">${esc(s.subject_name || 'Тест')}</div>
|
||||
<div class="ud-sess-meta">${fmtDate(s.started_at)} · ${MODES[s.mode] || s.mode}</div>
|
||||
</div>
|
||||
<div class="ud-sess-score">${s.score ?? '—'} / ${s.total}</div>
|
||||
<div class="ud-sess-chev">${ICONS.chev}</div>
|
||||
</div>`;
|
||||
}).join('') + '</div>';
|
||||
}
|
||||
|
||||
/* ── Classes tab ── */
|
||||
/* No per-user "classes" endpoint exists; show empty state pointing to the
|
||||
* Classes section. Post-merge: add GET /admin/users/:id/classes for full list.
|
||||
*/
|
||||
function renderClasses() {
|
||||
const pane = document.getElementById('ud-pane-classes');
|
||||
if (!pane) return;
|
||||
pane.innerHTML = `<div class="ud-empty">
|
||||
Информация о классах пользователя пока недоступна.<br>
|
||||
<a href="/classes" style="color:var(--violet);font-weight:600;text-decoration:none">Открыть управление классами →</a>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/* ── Audit tab ── */
|
||||
/* audit_log is system-wide; filter client-side by target containing user_id
|
||||
* or by admin_id if this user IS an admin. */
|
||||
async function renderAudit() {
|
||||
const pane = document.getElementById('ud-pane-audit');
|
||||
if (!pane) return;
|
||||
pane.innerHTML = '<div class="spinner"></div>';
|
||||
try {
|
||||
const rows = await LS.api('/api/admin/audit-log?limit=500');
|
||||
const uid = _userId;
|
||||
// Match if target string includes "user:<uid>" or "userId=<uid>" or starts with uid,
|
||||
// or if admin_id equals uid (this user performed the action).
|
||||
const re = new RegExp(`(^|\\D)${uid}(\\D|$)`);
|
||||
const filtered = (rows || []).filter(r => {
|
||||
if (r.admin_id === uid) return true;
|
||||
if (r.target && re.test(String(r.target))) return true;
|
||||
return false;
|
||||
});
|
||||
if (!filtered.length) {
|
||||
pane.innerHTML = '<div class="ud-empty">Нет записей аудита, связанных с этим пользователем</div>';
|
||||
return;
|
||||
}
|
||||
const ACTION_LABELS = {
|
||||
'user.role_change': 'Смена роли', 'user.edit': 'Редактирование', 'user.ban': 'Блокировка',
|
||||
'user.unban': 'Разблокировка', 'user.delete': 'Удаление', 'user.clear_sessions': 'Очистка истории',
|
||||
'features.update': 'Фичи обновлены', 'topic.create': 'Создание темы',
|
||||
'topic.update': 'Редакт. темы', 'topic.delete': 'Удаление темы',
|
||||
'broadcast': 'Рассылка', 'session.delete': 'Удаление сессии',
|
||||
};
|
||||
pane.innerHTML = '<div class="ud-audit-list">' + filtered.map(r => {
|
||||
const dt = new Date(r.created_at);
|
||||
const when = dt.toLocaleDateString('ru', { day:'numeric', month:'short', year:'numeric' }) +
|
||||
' ' + dt.toLocaleTimeString('ru', { hour:'2-digit', minute:'2-digit' });
|
||||
const lbl = ACTION_LABELS[r.action] || r.action;
|
||||
const who = r.admin_id === uid ? '(сам пользователь)' : (r.admin_name ? `от ${esc(r.admin_name)}` : '');
|
||||
return `<div class="ud-audit-row">
|
||||
<span class="ud-audit-when">${when}</span>
|
||||
<span class="ud-audit-action" style="color:var(--violet)">${esc(lbl)}</span>
|
||||
<span class="ud-audit-detail">${esc(r.detail || '')} ${who}</span>
|
||||
</div>`;
|
||||
}).join('') + '</div>';
|
||||
} catch (e) {
|
||||
pane.innerHTML = `<div class="ud-empty" style="color:var(--pink)">Ошибка загрузки аудита: ${esc(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Reload after mutations (called from action handlers) ── */
|
||||
async function reload() {
|
||||
if (!_userId) return;
|
||||
try {
|
||||
const data = await LS.adminGetUserSessions(_userId);
|
||||
_userData = data.user;
|
||||
_sessions = Array.isArray(data.sessions) ? data.sessions : [];
|
||||
window.activeUserRole = _userData?.role || null;
|
||||
renderShell();
|
||||
switchSubTab(_activeSubTab, /*pushUrl*/ false);
|
||||
} catch (e) {
|
||||
LS.toast('Не удалось обновить: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Expose handlers used by inline onclicks ── */
|
||||
window.udSwitchTab = function (name) { switchSubTab(name, /*pushUrl*/ true); };
|
||||
|
||||
window.AdminSections = window.AdminSections || {};
|
||||
window.AdminSections['user-detail'] = {
|
||||
/* Called by admin.js dispatch. id REQUIRED. subTab optional. */
|
||||
init,
|
||||
reload,
|
||||
};
|
||||
})();
|
||||
@@ -176,9 +176,8 @@
|
||||
}
|
||||
|
||||
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');
|
||||
// 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);
|
||||
|
||||
@@ -52,8 +52,8 @@
|
||||
| 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 4: Palette | fullstack | ✅ Done | ✅ PASS w/ notes (limit param cleanup applied) | ✅ | ✅ f562fe4 |
|
||||
| Phase 5: Quick actions | frontend | ✅ Done | ⬜ | ✅ node --check + tests 32/35 (3 pre-existing auth fails) | ⬜ |
|
||||
| Phase 6: Deep pages | frontend | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
|
||||
| Phase 5: Quick actions | frontend | ✅ Done | ✅ PASS w/ notes | ✅ | ✅ 69113ab |
|
||||
| Phase 6: Deep pages | frontend | 🟡 In Progress | ⬜ | ✅ node --check | ⬜ |
|
||||
|
||||
## Final Review
|
||||
- [ ] Comprehensive code review (final-reviewer agent)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Phase 6: Deep entity pages
|
||||
|
||||
**Status:** ⬜ Not Started
|
||||
**Status:** 🟡 In Progress (sub-commit 1 of 2 done)
|
||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||
**Domain:** frontend
|
||||
|
||||
|
||||
Reference in New Issue
Block a user