'use strict'; /* admin → overview (Phase 3 dashboard) — landing page "что требует внимания". * Lazy-init via AdminSections.overview.init(); reloads via .reload(). */ (function () { 'use strict'; let inited = false; let _lastLoadTs = 0; let _tsInterval = null; /* ── one-time CSS injection (overview-specific bento layout) ────────── */ function ensureOvStyles() { if (document.getElementById('ov-style')) return; const s = document.createElement('style'); s.id = 'ov-style'; s.textContent = ` /* ── main grid ─────────────────────────────────────────────── */ .ov-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-bottom: 28px; } @media (min-width: 720px) { .ov-grid.ov-grid-main { grid-template-columns: 2fr 1fr 1fr 1fr; } } .ov-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-lg); padding: 22px 20px; position: relative; overflow: hidden; } .ov-card::before { content: ''; position: absolute; top: 0; left: 0; right: 0; height: 3px; background: var(--ov-top, var(--violet)); opacity: 0.7; } .ov-card-icon { width: 38px; height: 38px; border-radius: 12px; display: flex; align-items: center; justify-content: center; margin-bottom: 12px; background: rgba(155,93,229,0.1); color: var(--violet); } .ov-card.hero .ov-card-icon { width: 52px; height: 52px; border-radius: 14px; } .ov-card-val { font-family: 'Unbounded', sans-serif; font-size: 1.9rem; font-weight: 800; line-height: 1.1; margin-bottom: 4px; } .ov-card.hero .ov-card-val { font-size: 2.6rem; } .ov-card-label { font-size: 0.82rem; color: var(--text-3); font-weight: 600; } .ov-card-spark { margin-top: 6px; opacity: 0.7; } .ov-zero { color: var(--text-3); opacity: 0.55; } .ov-card.warn { border-color: rgba(255,179,71,0.4); } .ov-card.warn::before { background: var(--amber); } .ov-card.warn .ov-card-icon { background: rgba(255,179,71,0.12); color: var(--amber); } .ov-card.danger { border-color: rgba(241,91,181,0.35); } .ov-card.danger::before { background: var(--pink); } .ov-card.danger .ov-card-icon { background: rgba(241,91,181,0.1); color: var(--pink); } /* ── section header ─────────────────────────────────────────── */ .ov-section-title { font-family: 'Unbounded', sans-serif; font-weight: 700; font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-3); margin: 28px 0 12px; } .ov-header { display: flex; justify-content: space-between; align-items: center; margin: 28px 0 12px; } .ov-header .ov-section-title { margin: 0; } .ov-refresh { display: flex; align-items: center; gap: 8px; font-size: 0.78rem; color: var(--text-3); } .ov-refresh-btn { background: transparent; border: 1px solid var(--border); border-radius: 8px; width: 28px; height: 28px; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; color: var(--text-3); transition: color .12s, border-color .12s; } .ov-refresh-btn:hover { color: var(--violet); border-color: rgba(155,93,229,0.35); } /* ── banned / alert list ────────────────────────────────────── */ .ov-banned-list { display: flex; flex-direction: column; gap: 6px; } .ov-banned-row { display: flex; align-items: center; gap: 10px; padding: 10px 14px; background: rgba(241,91,181,0.06); border: 1px solid rgba(241,91,181,0.18); border-radius: 10px; font-size: 0.86rem; } .ov-banned-row .ov-bn-name { font-weight: 600; } .ov-banned-row .ov-bn-email { color: var(--text-3); font-size: 0.78rem; } .ov-banned-row .ov-bn-date { margin-left: auto; color: var(--text-3); font-size: 0.76rem; } /* ── stuck-session list ─────────────────────────────────────── */ .ov-stuck-list { display: flex; flex-direction: column; gap: 6px; } .ov-stuck-row { display: flex; align-items: center; gap: 10px; padding: 8px 12px; background: rgba(255,179,71,0.07); border: 1px solid rgba(255,179,71,0.22); border-radius: 10px; font-size: 0.84rem; } .ov-stuck-row .ov-st-name { font-weight: 600; flex: 1; } .ov-stuck-row .ov-st-subj { color: var(--text-3); font-size: 0.78rem; } .ov-stuck-row .ov-st-since { margin-left: auto; color: var(--text-3); font-size: 0.76rem; white-space: nowrap; } /* ── content inventory ──────────────────────────────────────── */ .ov-inv-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 12px; margin-bottom: 28px; } .ov-inv-item { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 12px 16px; display: flex; flex-direction: column; gap: 2px; } .ov-inv-n { font-family: 'Unbounded', sans-serif; font-size: 1.4rem; font-weight: 800; color: var(--text); } .ov-inv-l { font-size: 0.76rem; color: var(--text-3); font-weight: 600; } /* ── subject distribution bar ───────────────────────────────── */ .ov-subj-bar-track { height: 10px; border-radius: 5px; overflow: hidden; display: flex; margin-bottom: 10px; background: var(--border); } .ov-subj-seg { height: 100%; transition: width .3s; } .ov-subj-legend { display: flex; flex-wrap: wrap; gap: 8px 16px; font-size: 0.78rem; color: var(--text-3); } .ov-subj-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 4px; vertical-align: middle; } /* ── top/worst tables side by side ──────────────────────────── */ .ov-results-grid { display: grid; grid-template-columns: 1fr; gap: 28px; } @media (min-width: 1100px) { .ov-results-grid { grid-template-columns: 1fr 1fr; } } .ov-top-table { width: 100%; border-collapse: collapse; } .ov-top-table th { text-align: left; font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-3); font-weight: 700; padding: 8px 10px; border-bottom: 1px solid var(--border); } .ov-top-table td { padding: 10px; font-size: 0.86rem; border-bottom: 1px solid var(--border); } .ov-top-table tr:last-child td { border-bottom: none; } .ov-pct { font-family: 'Unbounded', sans-serif; font-weight: 700; } .ov-pct.hi { color: var(--green); } .ov-pct.mid { color: var(--amber); } .ov-pct.lo { color: var(--pink); } /* ── quick-nav ──────────────────────────────────────────────── */ .ov-quick-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; } .ov-quick-btn { display: flex; align-items: center; gap: 10px; padding: 14px 16px; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; cursor: pointer; font-family: inherit; font-size: 0.88rem; font-weight: 600; color: var(--text); text-align: left; transition: background .12s, border-color .12s, transform .12s; } .ov-quick-btn:hover { background: rgba(155,93,229,0.06); border-color: rgba(155,93,229,0.3); color: var(--violet); transform: translateY(-1px); } .ov-quick-btn svg { width: 16px; height: 16px; flex-shrink: 0; } /* ── avatar pills ───────────────────────────────────────────── */ .ov-avatar { display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 24px; border-radius: 50%; font-size: 10px; font-weight: 700; color: #fff; margin-right: 6px; vertical-align: middle; flex-shrink: 0; } .ov-cell-user { display: flex; align-items: center; } /* ── skeleton loader ────────────────────────────────────────── */ @keyframes ov-shimmer { 0%{background-position:-400px 0} 100%{background-position:400px 0} } .ov-skel-box { border-radius: 12px; background: linear-gradient(90deg,var(--border) 25%,var(--surface) 50%,var(--border) 75%); background-size: 400px 100%; animation: ov-shimmer 1.4s infinite linear; } .ov-skel-cards { display: grid; grid-template-columns: 2fr 1fr 1fr 1fr; gap: 16px; margin-bottom: 28px; } .ov-skel-card { height: 110px; } .ov-skel-rows { display: flex; flex-direction: column; gap: 10px; margin-top: 12px; } .ov-skel-row { height: 38px; } /* ── misc ───────────────────────────────────────────────────── */ .ov-empty { padding: 18px; text-align: center; color: var(--text-3); font-size: 0.85rem; } /* ── mobile breakpoints ─────────────────────────────────────── */ @media (max-width: 640px) { .ov-grid.ov-grid-main { grid-template-columns: 1fr 1fr; } .ov-card.hero .ov-card-val { font-size: 1.9rem; } .ov-card.hero .ov-card-icon { width: 38px; height: 38px; border-radius: 12px; } .ov-results-grid { grid-template-columns: 1fr; } .ov-quick-grid { grid-template-columns: 1fr 1fr; } .ov-skel-cards { grid-template-columns: 1fr 1fr; } } `; document.head.appendChild(s); } /* ── sparkline renderer ──────────────────────────────────────────────── */ /* Takes array of {d: 'YYYY-MM-DD', n: N}, fills 7-day window, returns SVG */ function renderSparkline(rawData, color) { const W = 50, H = 18, PAD = 2; // Build date map for last 7 days const map = {}; (rawData || []).forEach(function (r) { map[r.d] = r.n; }); const points = []; for (let i = 6; i >= 0; i--) { const d = new Date(); d.setDate(d.getDate() - i); const key = d.toISOString().slice(0, 10); points.push(map[key] || 0); } const max = Math.max.apply(null, points) || 1; const xs = points.map(function (_, i) { return PAD + (i / 6) * (W - 2 * PAD); }); const ys = points.map(function (v) { return H - PAD - (v / max) * (H - 2 * PAD); }); const polyline = xs.map(function (x, i) { return x.toFixed(1) + ',' + ys[i].toFixed(1); }).join(' '); return '' + '' + ''; } /* ── subject bar colors (hue cycle) ─────────────────────────────────── */ var SUBJ_COLORS = [ '#9B5DE5','#06D6E0','#06D664','#FFB347','#F15BB5', '#4FC3F7','#81C784','#FFD54F','#FF8A65','#BA68C8', ]; function renderSubjectBar(subjects) { if (!subjects || !subjects.length) { return '
Нет сессий за последние 24 часа
'; } const total = subjects.reduce(function (s, r) { return s + r.n; }, 0) || 1; let segs = '', legend = ''; subjects.forEach(function (r, i) { const pct = (r.n / total * 100).toFixed(1); const col = SUBJ_COLORS[i % SUBJ_COLORS.length]; segs += '
'; legend += '' + r.name + ' ' + r.n + ''; }); return '
' + segs + '
' + legend + '
'; } /* ── avatar helpers ─────────────────────────────────────────────────── */ function initialsOf(name) { if (!name) return '?'; return name.trim().split(/\s+/).slice(0, 2).map(function (w) { return w[0] ? w[0].toUpperCase() : ''; }).join('') || '?'; } function hashHue(str) { var h = 0; for (var i = 0; i < (str || '').length; i++) { h = ((h << 5) - h + (str || '').charCodeAt(i)) | 0; } return Math.abs(h) % 360; } function avatarHtml(name) { var hue = hashHue(name); var bg = 'hsl(' + hue + ',55%,60%)'; return '' + initialsOf(name) + ''; } /* ── skeleton loader ────────────────────────────────────────────────── */ function renderSkeleton(el) { ensureOvStyles(); el.innerHTML = '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
'; } function pctClassNum(p) { if (p === null || p === undefined) return ''; return p >= 75 ? 'hi' : p >= 50 ? 'mid' : 'lo'; } function fmtNum(n) { if (n === null || n === undefined) return '—'; if (n === 0) return '0'; return String(n); } function fmtBannedDate(s) { if (!s) return ''; try { const d = new Date(s.replace(' ', 'T') + 'Z'); return d.toLocaleDateString('ru', { day: 'numeric', month: 'short' }); } catch (e) { return ''; } } function fmtFinished(s) { if (!s) return '—'; try { const d = new Date(s.replace(' ', 'T') + 'Z'); return d.toLocaleString('ru', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }); } catch (e) { return s; } } function fmtAgo(ms) { const sec = Math.floor((Date.now() - ms) / 1000); if (sec < 10) return 'только что'; if (sec < 60) return sec + ' сек назад'; const min = Math.floor(sec / 60); if (min < 60) return min + ' мин назад'; const hr = Math.floor(min / 60); return hr + ' ч назад'; } function startTsInterval() { if (_tsInterval) return; _tsInterval = setInterval(function () { const el = document.getElementById('ov-ts'); if (el && _lastLoadTs) el.textContent = fmtAgo(_lastLoadTs); }, 30000); } function navigateTo(hash) { if (window.AdminRouter) AdminRouter.navigate(hash); else window.location.hash = hash; } function renderSessionRows(sessions, e) { return sessions.map(function (s) { var name = s.user_name || '—'; return '' + '' + avatarHtml(name) + e(name) + '' + '' + e(s.subject_name || '—') + '' + '' + (s.score != null ? s.score : 0) + ' / ' + (s.total != null ? s.total : 0) + '' + '' + (s.percent != null ? s.percent : '—') + '%' + '' + fmtFinished(s.finished_at) + '' + ''; }).join(''); } function render(data, stats) { const el = document.getElementById('overview-content'); if (!el) return; ensureOvStyles(); const e = LS.esc; const bannedCount = Array.isArray(data.bannedThisWeek) ? data.bannedThisWeek.length : 0; const top = Array.isArray(data.topSessions24h) ? data.topSessions24h : []; const worst = Array.isArray(data.worstSessions24h) ? data.worstSessions24h : []; const stuck = Array.isArray(data.stuckSessions) ? data.stuckSessions : []; const abandoned = data.abandonedSessions24h || 0; const sparks = data.sparks || {}; const inv = data.inventory || {}; const subjects24h = Array.isArray(data.sessionsBySubject24h) ? data.sessionsBySubject24h : []; /* ── alerts section ────────────────────────────────────────── */ let alertsHtml = ''; const hasBanned = bannedCount > 0; const hasAbandoned = abandoned > 0; const hasStuck = stuck.length > 0; if (hasBanned || hasAbandoned || hasStuck) { const bannedCard = hasBanned ? `
${bannedCount}
Заблокированы за неделю
${data.bannedThisWeek.map(function (u) { return '
' + '' + e(u.name || '—') + '' + '' + e(u.email || '') + '' + '' + fmtBannedDate(u.banned_at) + '' + '
'; }).join('')}
` : ''; const abandonedCard = hasAbandoned ? `
${abandoned}
Брошено сессий за 24ч
` : ''; const stuckCard = hasStuck ? `
${stuck.length}
Сессий висят >1ч
${stuck.map(function (st) { return '
' + '' + e(st.user_name || '—') + '' + '' + e(st.subject_name || '—') + '' + '' + fmtFinished(st.started_at) + '' + '
'; }).join('')}
` : ''; alertsHtml = `
Требует внимания
${bannedCard}${abandonedCard}${stuckCard}
`; } /* ── inventory section ─────────────────────────────────────── */ const invHtml = `
Контент проекта
${inv.questions != null ? inv.questions : 0}вопросов
${inv.tests != null ? inv.tests : 0}тестов
${inv.courses != null ? inv.courses : 0}курсов
${inv.classes != null ? inv.classes : 0}классов
`; /* ── subject bar ───────────────────────────────────────────── */ const subjHtml = `
По предметам (24ч)
${renderSubjectBar(subjects24h)}`; /* ── all-time totals (перенесено из бывшей вкладки «Статистика») ── */ const allTimeHtml = stats ? `
Итоги за всё время
${fmtNum(stats.totalUsers)}
Пользователей
${fmtNum(stats.totalTests)}
Тестов пройдено
${stats.avgScore != null ? stats.avgScore + '%' : '—'}
Средний результат
` : ''; /* ── per-subject all-time performance (перенесено из «Статистики») ── */ const subjAllTimeHtml = (stats && Array.isArray(stats.bySubject) && stats.bySubject.length) ? `
Результаты по предметам (всё время)
${stats.bySubject.map(function (b) { const p = b.avg_pct == null ? 0 : b.avg_pct; const barColor = p >= 75 ? 'var(--green)' : p >= 50 ? 'var(--amber)' : 'var(--pink)'; return '
' + '
' + e(b.name) + '
' + b.tests + ' тестов
' + '
' + (b.avg_pct == null ? '—' : b.avg_pct) + '%
' + '
' + '
'; }).join('')}
` : ''; /* ── results tables ────────────────────────────────────────── */ const topTableHtml = top.length ? `${renderSessionRows(top, e)}
УченикПредметСчёт%Завершён
` : '
Нет завершённых сессий за 24ч
'; const worstTableHtml = worst.length ? `${renderSessionRows(worst, e)}
УченикПредметСчёт%Завершён
` : '
Нет завершённых сессий за 24ч
'; const tsText = _lastLoadTs ? fmtAgo(_lastLoadTs) : 'только что'; el.innerHTML = `
Активность за 24 часа
${tsText}
${fmtNum(data.newSessions24h)}
Сессий запущено
${renderSparkline(sparks.sessions, 'var(--violet)')}
${fmtNum(data.newUsers24h)}
Новых регистраций
${renderSparkline(sparks.users, 'var(--cyan)')}
${fmtNum(data.activeUsers24h)}
Активных юзеров
${renderSparkline(sparks.active, 'var(--green)')}
${fmtNum(data.classesTotal)}
Всего классов
${alertsHtml} ${invHtml} ${allTimeHtml} ${subjHtml} ${subjAllTimeHtml}
Топ-5 сегодня
${topTableHtml}
Худшие 5 сегодня
${worstTableHtml}
Быстрый переход
Опасная зона
Сброс системы «чистый запуск»
Удаляет всех пользователей (кроме вас), классы, сессии, задания, прогресс, уведомления и историю. Учебники, вопросы, тесты, курсы и настройки сохраняются — авторский контент переназначается на ваш аккаунт. Перед сбросом автоматически создаётся резервная копия БД. Действие необратимо.
`; /* ── wire quick-links via event delegation ───────────────── */ el.querySelectorAll('.ov-quick-btn[data-go]').forEach(function (btn) { btn.addEventListener('click', function () { navigateTo(btn.dataset.go); }); }); const resetBtn = el.querySelector('#ov-reset-system-btn'); if (resetBtn) resetBtn.addEventListener('click', openResetModal); if (window.lucide) lucide.createIcons({ nodes: [el] }); } /* ── Сброс системы «чистый запуск» — модалка с предпросмотром + вводом «СБРОС» ── */ async function openResetModal() { const e = LS.esc; const m = LS.modal({ title: 'Сброс системы — чистый запуск', size: 'md', content: '
Загрузка плана…
', actions: [{ label: 'Отмена' }], }); let plan; try { plan = await LS.api('/api/admin/reset-system/plan'); } catch (err) { m.setBody('
Не удалось загрузить план: ' + e(err.message) + '
'); return; } const kept = plan.keptAdmin || {}; const delUsers = Math.max(0, (plan.totalUsers || 0) - 1); const wipeRows = plan.wipeRows || 0; const reassignRows = (plan.reassign || []).reduce(function (a, r) { return a + (typeof r.rows === 'number' ? r.rows : 0); }, 0); const unknownNote = (plan.unknown && plan.unknown.length) ? '
' + 'Неизвестные таблицы (не трогаются): ' + e(plan.unknown.join(', ')) + '
' : ''; m.setBody( '
' + '
' + 'Это действие необратимо. Перед сбросом будет создан бэкап БД.' + '
' + '
Останется один администратор:
' + '
' + '' + e(kept.name || '—') + ' · ' + e(kept.email || '') + ' (вы)
' + '' + unknownNote + '
Для подтверждения введите СБРОС:
' + '' + '
' ); const inp = m.body.querySelector('#ov-reset-confirm-inp'); function syncBtn() { const ok = inp && inp.value.trim() === 'СБРОС'; const btn = document.getElementById('ov-reset-go'); if (btn) btn.disabled = !ok; } function setReadyActions() { m.setActions([ { label: 'Отмена' }, { label: 'Сбросить систему', danger: true, id: 'ov-reset-go', close: false, onClick: doReset, }, ]); const btn = document.getElementById('ov-reset-go'); if (btn) btn.disabled = true; } async function doReset() { const btn = document.getElementById('ov-reset-go'); if (!inp || inp.value.trim() !== 'СБРОС') return; if (btn) { btn.disabled = true; btn.textContent = 'Выполняется…'; } m.setError(''); let res; try { res = await LS.api('/api/admin/reset-system', { method: 'POST', body: { confirm: 'СБРОС' } }); } catch (err) { m.setError('Ошибка: ' + (err.message || 'сброс не выполнен')); if (btn) { btn.disabled = false; btn.textContent = 'Сбросить систему'; } return; } m.setBody( '
' + '
' + '
' + '
Система сброшена
' + '
' + 'Удалено пользователей: ' + (res.deletedUsers || 0) + ', осталось: ' + (res.remainingUsers || 1) + '.
' + 'Бэкап сохранён: ' + LS.esc(res.backup || '—') + '' + (res.fkDangling ? '
Висячих ссылок: ' + res.fkDangling + '' : '') + '
' + '
' ); m.setActions([{ label: 'Перезагрузить', primary: true, close: false, onClick: function () { location.reload(); } }]); if (window.lucide) lucide.createIcons({ nodes: [m.body] }); } setReadyActions(); if (inp) { inp.addEventListener('input', syncBtn); setTimeout(function () { inp.focus(); }, 60); } } async function load() { const el = document.getElementById('overview-content'); if (!el) return; renderSkeleton(el); try { // Обзор + перенесённые из бывшей вкладки «Статистика» итоги за всё время. const [data, stats] = await Promise.all([ LS.adminGetOverview(), LS.adminGetStats().catch(() => null), ]); _lastLoadTs = Date.now(); render(data, stats); startTsInterval(); } catch (e) { LS.state.error(el, e, () => load()); } } window.AdminSections = window.AdminSections || {}; window.AdminSections.overview = { init: async () => { if (inited) return; inited = true; await load(); }, reload: load, }; })();