Files
Learn_System/frontend/js/admin/sections/sessions.js
T
Maxim Dolgolyov 92030b462c feat(admin): phase 2 — split admin.js into 13 section modules
Replace ~3500L admin.js monolith with thin orchestrator (~700L) +

14 IIFE-wrapped per-section modules under /js/admin/sections/.

Section modules expose AdminSections.<name>.init/reload (lazy init via

switchTab/router) and re-expose onclick handlers via window.X for

backward compat. Shared helpers (MODES/DIFFS, fmtDate, pctClass,

renderMath, qTypeBadge, pagination) live in /js/admin/_shared.js

exposed on window.AdminCtx.

switchTab now dispatches to AdminSections via ROUTE_TO_SECTION map;

non-extracted system tabs (topics/audit/errors/health/classroom/avatars)

remain inline in admin.js. user-panel overlay markup untouched — Phase 6

will remove it.
2026-05-16 22:50:14 +03:00

160 lines
8.3 KiB
JavaScript

'use strict';
/* admin → sessions section: sessions timeline + drawer detail */
(function () {
'use strict';
let inited = false;
let allSessions = [];
let openDrawerId = null;
async function load() {
const subject = document.getElementById('t-subject').value;
document.getElementById('t-body').innerHTML = '<div class="spinner"></div>';
openDrawerId = null;
try {
allSessions = await LS.adminGetSessions({ subject: subject || undefined });
renderSessions();
} catch (e) {
document.getElementById('t-body').innerHTML = `<div class="error">Ошибка: ${esc(e.message)}</div>`;
}
}
function sessPctRing(pct) {
const { pctClass } = AdminCtx;
const pc = pctClass(pct);
const colorMap = {'pct-hi':'var(--green)','pct-mid':'var(--amber)','pct-lo':'var(--pink)'};
const color = colorMap[pc] || 'var(--text-3)';
const circ = 106.8;
const dash = (pct / 100 * circ).toFixed(1);
return `<svg class="sess-tl-ring" width="48" height="48" viewBox="0 0 48 48">
<circle cx="24" cy="24" r="17" fill="none" stroke="rgba(15,23,42,0.08)" stroke-width="4"/>
<circle cx="24" cy="24" r="17" fill="none" stroke="${color}" stroke-width="4"
stroke-dasharray="${dash} ${circ}" stroke-dashoffset="26.7" stroke-linecap="round"
transform="rotate(-90 24 24)"/>
<text x="24" y="28" text-anchor="middle" font-family="Unbounded,sans-serif" font-size="8" font-weight="800" fill="${color}">${pct}%</text>
</svg>`;
}
function renderSessions() {
const { MODES, fmtDate, fmtTime } = AdminCtx;
const modeF = document.getElementById('t-mode').value;
const searchF = document.getElementById('t-search').value.toLowerCase();
const filtered = allSessions.filter(s => {
if (modeF && s.mode !== modeF) return false;
if (searchF && !s.user_name.toLowerCase().includes(searchF) && !s.user_email.toLowerCase().includes(searchF)) return false;
return true;
});
document.getElementById('t-count').textContent = `${filtered.length} тестов`;
if (!filtered.length) {
document.getElementById('t-body').innerHTML = '<div class="empty">Нет тестов</div>';
return;
}
const groups = {};
filtered.forEach(s => {
const key = fmtDate(s.started_at);
(groups[key] = groups[key] || []).push(s);
});
document.getElementById('t-body').innerHTML = Object.entries(groups).map(([date, sessions]) =>
`<div class="sess-tl-day">${date}</div>
<div class="sess-tl-wrap">${sessions.map(s => {
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})">
${ring}
<div class="sess-tl-user">
<div class="sess-tl-name">${esc(s.user_name)}</div>
<div class="sess-tl-meta">${esc(s.subject_name||'?')} · <span class="mode-badge mode-${s.mode}">${MODES[s.mode]||s.mode}</span></div>
</div>
<div class="sess-tl-score">${s.score??'—'} / ${s.total}</div>
<div class="sess-tl-time">${fmtTime(s.duration_sec)}</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();
}
// Expose handlers
window.loadSessions = load;
window.renderSessions = renderSessions;
window.toggleDrawer = toggleDrawer;
window.AdminSections = window.AdminSections || {};
window.AdminSections.sessions = {
init: async () => { if (inited) return; inited = true; await load(); },
reload: load,
};
})();