5381679c68
Зафиксирована накопленная незакоммиченная работа рабочего дерева, КРОМЕ файлов учебника «Химия 7» (migration 046, chemistry_7_*.html, chem7_svg.js, тест — оставлены незакоммиченными по запросу). Включает: модуль биохимии (ядро BIO, 3D VSEPR, химдвижок, баланс, challenges, пути из БД), System Health Level 1 (вердикт/мониторинг), а также frontend- страницы и lab/textbooks-правки параллельной сессии. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
510 lines
23 KiB
HTML
510 lines
23 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="ru">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<title>Журнал оценок — LearnSpace</title>
|
||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||
<link href="https://fonts.googleapis.com/css2?family=Unbounded:wght@400;700;800&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||
<link rel="stylesheet" href="/css/ls.css" />
|
||
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
|
||
<style>
|
||
.sb-content { background: #f4f5f8; overflow-y: auto; }
|
||
|
||
/* ── page header ── */
|
||
.gb-header {
|
||
background: linear-gradient(140deg, #0a0220 0%, #1a0a3c 60%, #080818 100%);
|
||
padding: 32px 28px 28px; position: relative; overflow: hidden;
|
||
}
|
||
.gb-header-dots {
|
||
position: absolute; inset: 0;
|
||
background-image: radial-gradient(circle, rgba(255,255,255,0.04) 1px, transparent 1px);
|
||
background-size: 22px 22px; pointer-events: none;
|
||
}
|
||
.gb-header-inner { position: relative; z-index: 1; max-width: 1200px; margin: 0 auto; }
|
||
.gb-header-back {
|
||
display: inline-flex; align-items: center; gap: 6px;
|
||
font-size: 0.78rem; font-weight: 700; color: rgba(255,255,255,0.45);
|
||
text-decoration: none; margin-bottom: 14px; transition: color 0.15s;
|
||
}
|
||
.gb-header-back:hover { color: rgba(255,255,255,0.8); }
|
||
.gb-title-row { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
|
||
.gb-title {
|
||
font-family: 'Unbounded', sans-serif; font-size: 1.3rem; font-weight: 800;
|
||
color: #fff; flex: 1;
|
||
}
|
||
.gb-actions { display: flex; gap: 8px; flex-shrink: 0; }
|
||
.gb-action-btn {
|
||
padding: 8px 18px; border-radius: 999px; border: 1.5px solid rgba(255,255,255,0.2);
|
||
background: rgba(255,255,255,0.07); color: rgba(255,255,255,0.7);
|
||
font-family: 'Manrope', sans-serif; font-size: 0.8rem; font-weight: 700;
|
||
cursor: pointer; display: flex; align-items: center; gap: 6px; transition: all 0.15s;
|
||
}
|
||
.gb-action-btn:hover { background: rgba(255,255,255,0.14); color: #fff; }
|
||
|
||
/* class selector */
|
||
.gb-class-select {
|
||
padding: 8px 14px; border: 1.5px solid rgba(255,255,255,0.15); border-radius: 10px;
|
||
font-family: 'Manrope', sans-serif; font-size: 0.84rem; font-weight: 600;
|
||
color: #fff; background: rgba(255,255,255,0.08); cursor: pointer;
|
||
}
|
||
.gb-class-select option { color: #0F172A; background: #fff; }
|
||
|
||
/* ── summary chips ── */
|
||
.gb-summary {
|
||
display: flex; gap: 12px; flex-wrap: wrap; max-width: 1200px;
|
||
margin: -20px auto 0; padding: 0 28px; position: relative; z-index: 2;
|
||
}
|
||
.gb-chip {
|
||
flex: 1; min-width: 150px; background: #fff; border: 1.5px solid rgba(15,23,42,0.07);
|
||
border-radius: 16px; padding: 16px 18px; box-shadow: 0 2px 8px rgba(15,23,42,0.06);
|
||
}
|
||
.gb-chip-val {
|
||
font-family: 'Unbounded', sans-serif; font-size: 1.3rem; font-weight: 800;
|
||
}
|
||
.gb-chip-label { font-size: 0.72rem; color: var(--text-3); font-weight: 600; margin-top: 4px; }
|
||
|
||
/* ── table container ── */
|
||
.gb-container { max-width: 1200px; margin: 0 auto; padding: 24px 28px 80px; }
|
||
|
||
/* ── gradebook table ── */
|
||
.gb-table-wrap {
|
||
background: #fff; border: 1.5px solid rgba(15,23,42,0.07);
|
||
border-radius: 20px; overflow: hidden;
|
||
box-shadow: 0 2px 8px rgba(15,23,42,0.05);
|
||
}
|
||
.gb-table-scroll { overflow-x: auto; }
|
||
.gb-table {
|
||
width: 100%; border-collapse: collapse; font-size: 0.82rem;
|
||
min-width: 600px;
|
||
}
|
||
.gb-table thead th {
|
||
position: sticky; top: 0; z-index: 10;
|
||
background: #f8f9fc; border-bottom: 1.5px solid rgba(15,23,42,0.08);
|
||
padding: 12px 14px; text-align: left;
|
||
font-size: 0.7rem; font-weight: 700; color: var(--text-3);
|
||
text-transform: uppercase; letter-spacing: 0.04em;
|
||
white-space: nowrap;
|
||
}
|
||
.gb-table thead th.gb-th-student {
|
||
position: sticky; left: 0; z-index: 15; background: #f8f9fc;
|
||
min-width: 180px;
|
||
}
|
||
.gb-table thead th.gb-th-avg {
|
||
background: rgba(155,93,229,0.06); color: var(--violet);
|
||
}
|
||
.gb-table thead th.gb-th-rotate {
|
||
max-width: 80px; min-width: 60px;
|
||
}
|
||
.gb-th-text {
|
||
display: block; max-width: 90px; overflow: hidden;
|
||
text-overflow: ellipsis; white-space: nowrap;
|
||
}
|
||
.gb-table tbody td {
|
||
padding: 10px 14px; border-bottom: 1px solid rgba(15,23,42,0.05);
|
||
text-align: center; font-weight: 600; color: #3D4F6B;
|
||
}
|
||
.gb-table tbody tr:hover td { background: rgba(155,93,229,0.02); }
|
||
.gb-table tbody td.gb-td-student {
|
||
position: sticky; left: 0; z-index: 5;
|
||
background: #fff; text-align: left; font-weight: 700; color: #0F172A;
|
||
}
|
||
.gb-table tbody tr:hover td.gb-td-student { background: rgba(155,93,229,0.02); }
|
||
.gb-table tbody td.gb-td-avg {
|
||
background: rgba(155,93,229,0.04); font-weight: 800;
|
||
}
|
||
/* grade cells */
|
||
.gb-grade {
|
||
display: inline-block; min-width: 40px; padding: 3px 8px;
|
||
border-radius: 8px; font-size: 0.76rem; font-weight: 700;
|
||
text-align: center;
|
||
}
|
||
.gb-grade-high { background: rgba(6,214,160,0.12); color: #059652; }
|
||
.gb-grade-mid { background: rgba(255,209,102,0.18); color: #B8860B; }
|
||
.gb-grade-low { background: rgba(239,71,111,0.12); color: #EF476F; }
|
||
.gb-grade-none { color: #CBD5E1; font-weight: 500; }
|
||
.gb-grade-full { background: rgba(6,214,160,0.15); color: #059652; font-weight: 800; }
|
||
/* student name row */
|
||
.gb-student-row { display: flex; align-items: center; gap: 10px; }
|
||
.gb-student-avatar {
|
||
width: 28px; height: 28px; border-radius: 8px;
|
||
background: rgba(155,93,229,0.1); color: var(--violet);
|
||
font-size: 0.65rem; font-weight: 800;
|
||
display: flex; align-items: center; justify-content: center;
|
||
flex-shrink: 0;
|
||
}
|
||
.gb-student-name { font-weight: 700; font-size: 0.82rem; }
|
||
.gb-student-email { font-size: 0.68rem; color: var(--text-3); font-weight: 500; }
|
||
|
||
/* ── footer row ── */
|
||
.gb-table tfoot td {
|
||
padding: 10px 14px; border-top: 1.5px solid rgba(15,23,42,0.08);
|
||
font-weight: 800; font-size: 0.78rem; color: #0F172A;
|
||
background: #f8f9fc; text-align: center;
|
||
}
|
||
.gb-table tfoot td.gb-td-student {
|
||
position: sticky; left: 0; z-index: 5;
|
||
text-align: left; background: #f8f9fc;
|
||
}
|
||
|
||
/* ── course progress section ── */
|
||
.gb-section-title {
|
||
font-family: 'Unbounded', sans-serif; font-size: 0.82rem; font-weight: 800;
|
||
color: #0F172A; margin: 28px 0 14px; display: flex; align-items: center; gap: 8px;
|
||
}
|
||
|
||
/* ── empty state ── */
|
||
.gb-empty {
|
||
text-align: center; padding: 60px 20px; color: var(--text-3); font-size: 0.88rem;
|
||
}
|
||
.gb-empty-icon { font-size: 3rem; margin-bottom: 12px; opacity: 0.3; }
|
||
|
||
/* ── nav ── */
|
||
.nav-active { background: rgba(155,93,229,0.08) !important; border-color: var(--violet) !important; color: var(--violet) !important; cursor: default; pointer-events: none; }
|
||
.notif-badge { position: absolute; top: -4px; right: -4px; min-width: 18px; height: 18px; padding: 0 4px; background: var(--pink); color: #fff; border-radius: 99px; font-size: 0.65rem; font-weight: 700; display: flex; align-items: center; justify-content: center; }
|
||
.notif-drop-header { display: flex; align-items: center; justify-content: space-between; padding: 14px 16px 10px; border-bottom: 1px solid var(--border); }
|
||
.notif-drop-title { font-family: 'Unbounded', sans-serif; font-size: 0.78rem; font-weight: 800; }
|
||
.notif-read-all { background: none; border: none; font-size: 0.74rem; color: var(--violet); cursor: pointer; font-family: 'Manrope', sans-serif; font-weight: 600; }
|
||
.notif-item { display: flex; gap: 10px; padding: 11px 16px; border-bottom: 1px solid var(--border); cursor: pointer; transition: background var(--tr); text-decoration: none; color: inherit; }
|
||
.notif-item:last-child { border-bottom: none; }
|
||
.notif-item:hover { background: rgba(155,93,229,0.04); }
|
||
.notif-item.unread { background: rgba(155,93,229,0.05); }
|
||
.notif-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--violet); flex-shrink: 0; margin-top: 5px; }
|
||
.notif-dot.read { background: transparent; border: 1.5px solid var(--border-h); }
|
||
.notif-msg { font-size: 0.80rem; line-height: 1.45; flex: 1; }
|
||
.notif-time { font-size: 0.70rem; color: var(--text-3); margin-top: 2px; }
|
||
.notif-empty { padding: 28px 16px; text-align: center; color: var(--text-3); font-size: 0.84rem; }
|
||
|
||
@media (max-width: 768px) {
|
||
.gb-header { padding: 20px 16px 36px; }
|
||
.gb-title-row { gap: 10px; }
|
||
.gb-title { font-size: 1rem; }
|
||
.gb-class-select { width: 100%; }
|
||
.gb-summary { padding: 0 14px; gap: 8px; margin-top: -24px; }
|
||
.gb-chip { min-width: 0; flex: 1 1 calc(50% - 8px); padding: 12px 14px; }
|
||
.gb-chip-val { font-size: 1rem; }
|
||
.gb-container { padding: 18px 12px 80px; }
|
||
}
|
||
@media (max-width: 480px) {
|
||
.gb-chip { flex: 1 1 100%; }
|
||
.gb-action-btn { width: 100%; justify-content: center; }
|
||
.gb-title { font-size: 0.9rem; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="app-layout">
|
||
<aside class="sidebar" id="app-sidebar"></aside>
|
||
<div class="notif-drop" id="notif-drop"></div>
|
||
<div class="sb-content">
|
||
|
||
<!-- Header -->
|
||
<div class="gb-header">
|
||
<div class="gb-header-dots"></div>
|
||
<div class="gb-header-inner">
|
||
<a href="/classes" class="gb-header-back"><i data-lucide="arrow-left" style="width:14px;height:14px"></i> Классы</a>
|
||
<div class="gb-title-row">
|
||
<div class="gb-title"><i data-lucide="table" style="width:22px;height:22px;opacity:0.4"></i> Журнал оценок</div>
|
||
<select class="gb-class-select" id="class-select" onchange="loadJournal()">
|
||
<option value="">Выберите класс…</option>
|
||
</select>
|
||
<div class="gb-actions">
|
||
<button class="gb-action-btn" id="btn-csv" onclick="exportCsv()" style="display:none">
|
||
<i data-lucide="download" style="width:14px;height:14px"></i> Экспорт CSV
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Summary -->
|
||
<div class="gb-summary" id="gb-summary" style="display:none">
|
||
<div class="gb-chip">
|
||
<div class="gb-chip-val" id="gs-students" style="color:var(--violet)">0</div>
|
||
<div class="gb-chip-label">Учеников</div>
|
||
</div>
|
||
<div class="gb-chip">
|
||
<div class="gb-chip-val" id="gs-assignments" style="color:#06B6D4">0</div>
|
||
<div class="gb-chip-label">Заданий</div>
|
||
</div>
|
||
<div class="gb-chip">
|
||
<div class="gb-chip-val" id="gs-avg" style="color:#06D6A0">—</div>
|
||
<div class="gb-chip-label">Средний балл</div>
|
||
</div>
|
||
<div class="gb-chip">
|
||
<div class="gb-chip-val" id="gs-completion" style="color:#FFD166">—</div>
|
||
<div class="gb-chip-label">Выполнение</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Content -->
|
||
<div class="gb-container" id="gb-container">
|
||
<div class="gb-empty">
|
||
<div class="gb-empty-icon"><i data-lucide="table" style="width:48px;height:48px"></i></div>
|
||
Выберите класс, чтобы увидеть журнал оценок
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
|
||
<script src="/js/api.js"></script>
|
||
<script src="/js/sidebar.js"></script>
|
||
<script>
|
||
if (!LS.requireAuth()) throw new Error();
|
||
|
||
const user = LS.getUser();
|
||
document.getElementById('nav-user').textContent = user?.name || user?.email || '';
|
||
LS.renderNavAvatar(document.getElementById('nav-avatar'), user);
|
||
|
||
const isTeacher = ['admin','teacher'].includes(user?.role);
|
||
if (!isTeacher) { location.href = '/dashboard'; throw new Error(); }
|
||
if (user?.role === 'admin') document.getElementById('btn-admin').style.display = '';
|
||
lucide.createIcons();
|
||
|
||
/* ── sidebar ── */
|
||
function toggleSidebar() {
|
||
const layout = document.querySelector('.app-layout');
|
||
const collapsed = layout.classList.toggle('sb-collapsed');
|
||
localStorage.setItem('ls_sb_collapsed', collapsed ? '1' : '');
|
||
lucide.createIcons();
|
||
}
|
||
if (localStorage.getItem('ls_sb_collapsed'))
|
||
document.querySelector('.app-layout').classList.add('sb-collapsed');
|
||
|
||
/* ── notif ── */
|
||
function toggleNotifDrop() {
|
||
const btn = document.getElementById('notif-btn');
|
||
const drop = document.getElementById('notif-drop');
|
||
const r = btn.getBoundingClientRect();
|
||
drop.style.left = (r.right + 8) + 'px';
|
||
drop.style.top = r.top + 'px';
|
||
if (drop.classList.toggle('open')) loadNotifs();
|
||
}
|
||
async function loadNotifs() {
|
||
const drop = document.getElementById('notif-drop');
|
||
drop.innerHTML = '<div class="notif-drop-header"><span class="notif-drop-title">Уведомления</span><button class="notif-read-all" onclick="readAllNotifs()">Прочитать все</button></div><div class="notif-empty">Загрузка…</div>';
|
||
try {
|
||
const data = await LS.api('/api/notifications?limit=20');
|
||
const items = data.items || [];
|
||
const badge = document.getElementById('notif-badge');
|
||
const unread = items.filter(n => !n.is_read).length;
|
||
badge.textContent = unread; badge.style.display = unread ? '' : 'none';
|
||
if (!items.length) { drop.querySelector('.notif-empty').textContent = 'Нет уведомлений'; return; }
|
||
drop.innerHTML = '<div class="notif-drop-header"><span class="notif-drop-title">Уведомления</span><button class="notif-read-all" onclick="readAllNotifs()">Прочитать все</button></div>' +
|
||
items.map(n => `<a class="notif-item${n.is_read ? '' : ' unread'}" href="${LS.safeHref(n.link)}" onclick="markRead(${n.id})">
|
||
<div class="notif-dot${n.is_read ? ' read' : ''}"></div>
|
||
<div><div class="notif-msg">${esc(n.message)}</div><div class="notif-time">${fmtTime(n.created_at)}</div></div>
|
||
</a>`).join('');
|
||
} catch {}
|
||
}
|
||
async function markRead(id) { try { await LS.api('/api/notifications/' + id + '/read', { method:'POST' }); } catch {} }
|
||
async function readAllNotifs() { try { await LS.api('/api/notifications/read-all', { method:'POST' }); loadNotifs(); } catch {} }
|
||
document.addEventListener('click', e => {
|
||
const drop = document.getElementById('notif-drop');
|
||
if (drop.classList.contains('open') && !drop.contains(e.target) && !document.getElementById('notif-btn').contains(e.target))
|
||
drop.classList.remove('open');
|
||
});
|
||
|
||
/* ── helpers ── */
|
||
function fmtTime(s) {
|
||
const d = new Date(s && s.includes('T') ? s : (s||'').replace(' ','T')+'Z');
|
||
const diff = Date.now() - d.getTime();
|
||
if (diff < 60000) return 'только что';
|
||
if (diff < 3600000) return Math.floor(diff/60000) + ' мин назад';
|
||
return d.toLocaleDateString('ru', { day:'numeric', month:'short' });
|
||
}
|
||
function initials(name) {
|
||
return (name || '??').split(' ').slice(0,2).map(w => w[0]?.toUpperCase() || '').join('');
|
||
}
|
||
function gradeClass(pct) {
|
||
if (pct >= 80) return 'gb-grade-high';
|
||
if (pct >= 50) return 'gb-grade-mid';
|
||
return 'gb-grade-low';
|
||
}
|
||
function gradeCell(pct) {
|
||
if (pct === null || pct === undefined || pct === '') return '<span class="gb-grade gb-grade-none">—</span>';
|
||
if (pct === 100) return `<span class="gb-grade gb-grade-full">${pct}%</span>`;
|
||
return `<span class="gb-grade ${gradeClass(pct)}">${pct}%</span>`;
|
||
}
|
||
|
||
/* ── load classes ── */
|
||
let currentClassId = null;
|
||
async function loadClasses() {
|
||
try {
|
||
const classes = await LS.api('/api/classes');
|
||
const sel = document.getElementById('class-select');
|
||
(classes || []).forEach(c => {
|
||
const opt = document.createElement('option');
|
||
opt.value = c.id;
|
||
opt.textContent = c.name;
|
||
sel.appendChild(opt);
|
||
});
|
||
// auto-select from URL
|
||
const urlClass = new URLSearchParams(location.search).get('classId');
|
||
if (urlClass) {
|
||
sel.value = urlClass;
|
||
loadJournal();
|
||
}
|
||
} catch {}
|
||
}
|
||
|
||
/* ── load journal ── */
|
||
async function loadJournal() {
|
||
const classId = document.getElementById('class-select').value;
|
||
if (!classId) {
|
||
document.getElementById('gb-summary').style.display = 'none';
|
||
document.getElementById('btn-csv').style.display = 'none';
|
||
document.getElementById('gb-container').innerHTML = `<div class="gb-empty"><div class="gb-empty-icon"><i data-lucide="table" style="width:48px;height:48px"></i></div>Выберите класс, чтобы увидеть журнал оценок</div>`;
|
||
lucide.createIcons();
|
||
return;
|
||
}
|
||
currentClassId = classId;
|
||
document.getElementById('gb-container').innerHTML = '<div class="spinner" style="margin:60px auto"></div>';
|
||
|
||
try {
|
||
const data = await LS.api('/api/classes/' + classId + '/journal');
|
||
renderJournal(data);
|
||
} catch (e) {
|
||
document.getElementById('gb-container').innerHTML = `<div class="gb-empty">${esc(e.message || 'Ошибка загрузки')}</div>`;
|
||
}
|
||
}
|
||
|
||
function renderJournal(data) {
|
||
const { members, assignments, results, courses, courseProgress, studentStats } = data;
|
||
const container = document.getElementById('gb-container');
|
||
const summary = document.getElementById('gb-summary');
|
||
|
||
if (!members.length) {
|
||
summary.style.display = 'none';
|
||
document.getElementById('btn-csv').style.display = 'none';
|
||
container.innerHTML = '<div class="gb-empty"><div class="gb-empty-icon"><i data-lucide="users" style="width:48px;height:48px"></i></div>В классе пока нет учеников</div>';
|
||
lucide.createIcons();
|
||
return;
|
||
}
|
||
|
||
// Build result map
|
||
const rmap = {};
|
||
results.forEach(r => { rmap[r.user_id + '_' + r.assignment_id] = r; });
|
||
|
||
// Summary stats
|
||
summary.style.display = '';
|
||
document.getElementById('btn-csv').style.display = '';
|
||
document.getElementById('gs-students').textContent = members.length;
|
||
document.getElementById('gs-assignments').textContent = assignments.length;
|
||
|
||
const allPcts = studentStats.filter(s => s.avgPct !== null);
|
||
const classAvg = allPcts.length > 0
|
||
? Math.round(allPcts.reduce((s, x) => s + x.avgPct, 0) / allPcts.length)
|
||
: 0;
|
||
document.getElementById('gs-avg').textContent = classAvg + '%';
|
||
|
||
const totalSlots = members.length * assignments.length;
|
||
const filledSlots = results.length;
|
||
const completionPct = totalSlots > 0 ? Math.round(filledSlots / totalSlots * 100) : 0;
|
||
document.getElementById('gs-completion').textContent = completionPct + '%';
|
||
|
||
// Assignments table
|
||
let html = '';
|
||
if (assignments.length) {
|
||
html += `<div class="gb-table-wrap"><div class="gb-table-scroll"><table class="gb-table">
|
||
<thead><tr>
|
||
<th class="gb-th-student">Ученик</th>
|
||
${assignments.map(a => `<th class="gb-th-rotate" title="${esc(a.title)}"><span class="gb-th-text">${esc(a.title)}</span></th>`).join('')}
|
||
<th class="gb-th-avg">Среднее</th>
|
||
</tr></thead>
|
||
<tbody>`;
|
||
|
||
members.forEach(m => {
|
||
const stats = studentStats.find(s => s.userId === m.id);
|
||
html += `<tr>
|
||
<td class="gb-td-student">
|
||
<div class="gb-student-row">
|
||
<div class="gb-student-avatar">${esc(initials(m.name))}</div>
|
||
<div>
|
||
<div class="gb-student-name">${esc(m.name)}</div>
|
||
<div class="gb-student-email">${esc(m.email)}</div>
|
||
</div>
|
||
</div>
|
||
</td>`;
|
||
assignments.forEach(a => {
|
||
const r = rmap[m.id + '_' + a.id];
|
||
html += `<td>${gradeCell(r ? r.percent : null)}</td>`;
|
||
});
|
||
html += `<td class="gb-td-avg">${stats?.avgPct !== null && stats?.avgPct !== undefined ? gradeCell(stats.avgPct) : gradeCell(null)}</td>`;
|
||
html += `</tr>`;
|
||
});
|
||
|
||
// Footer: averages per assignment
|
||
html += `</tbody><tfoot><tr><td class="gb-td-student" style="font-weight:800">Среднее по заданию</td>`;
|
||
assignments.forEach(a => {
|
||
const aResults = results.filter(r => r.assignment_id === a.id);
|
||
const avg = aResults.length > 0
|
||
? Math.round(aResults.reduce((s, r) => s + r.percent, 0) / aResults.length)
|
||
: null;
|
||
html += `<td>${gradeCell(avg)}</td>`;
|
||
});
|
||
html += `<td class="gb-td-avg">${gradeCell(classAvg)}</td></tr></tfoot>`;
|
||
html += `</table></div></div>`;
|
||
} else {
|
||
html += `<div style="background:#fff;border:1.5px solid rgba(15,23,42,0.07);border-radius:16px;padding:40px;text-align:center;color:var(--text-3);margin-bottom:20px">
|
||
Нет заданий в этом классе. <a href="/classes" style="color:var(--violet)">Создать задание <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg></a>
|
||
</div>`;
|
||
}
|
||
|
||
// Course progress section
|
||
if (courses && courses.length) {
|
||
html += `<div class="gb-section-title"><i data-lucide="brain" style="width:15px;height:15px;opacity:0.5"></i> Прогресс по курсам теории</div>`;
|
||
html += `<div class="gb-table-wrap"><div class="gb-table-scroll"><table class="gb-table">
|
||
<thead><tr>
|
||
<th class="gb-th-student">Ученик</th>
|
||
${courses.map(c => `<th class="gb-th-rotate" title="${esc(c.title)}"><span class="gb-th-text">${esc(c.title)}</span></th>`).join('')}
|
||
</tr></thead><tbody>`;
|
||
|
||
const cpMap = {};
|
||
(courseProgress || []).forEach(p => { cpMap[p.userId + '_' + p.courseId] = p; });
|
||
|
||
members.forEach(m => {
|
||
html += `<tr><td class="gb-td-student"><div class="gb-student-row"><div class="gb-student-avatar">${esc(initials(m.name))}</div><div class="gb-student-name">${esc(m.name)}</div></div></td>`;
|
||
courses.forEach(c => {
|
||
const p = cpMap[m.id + '_' + c.id];
|
||
html += `<td>${p ? gradeCell(p.percent) : gradeCell(null)}</td>`;
|
||
});
|
||
html += `</tr>`;
|
||
});
|
||
html += `</tbody></table></div></div>`;
|
||
}
|
||
|
||
container.innerHTML = html;
|
||
lucide.createIcons();
|
||
}
|
||
|
||
/* ── CSV export ── */
|
||
function exportCsv() {
|
||
if (!currentClassId) return;
|
||
const token = localStorage.getItem('ls_token');
|
||
const url = `/api/classes/${currentClassId}/journal/csv`;
|
||
// Use fetch with auth header then trigger download
|
||
fetch(url, { headers: { 'Authorization': 'Bearer ' + token } })
|
||
.then(r => {
|
||
if (!r.ok) throw new Error('Export failed');
|
||
return r.blob();
|
||
})
|
||
.then(blob => {
|
||
const a = document.createElement('a');
|
||
a.href = URL.createObjectURL(blob);
|
||
a.download = 'gradebook.csv';
|
||
a.click();
|
||
URL.revokeObjectURL(a.href);
|
||
})
|
||
.catch(e => LS.toast(e.message || 'Ошибка экспорта', 'error'));
|
||
}
|
||
|
||
loadClasses();
|
||
</script>
|
||
<script src="/js/notifications.js"></script>
|
||
<script src="/js/search.js"></script>
|
||
<script src="/js/mobile.js"></script>
|
||
</body>
|
||
</html>
|