if (typeof window.fetch === 'function' && !window.__fetchPatched) { window.__fetchPatched = true; const _origFetch = window.fetch.bind(window); window.fetch = function(input, init) { init = init || {}; init.credentials = 'include'; init.cache = 'no-store'; const method = (init.method || 'GET').toUpperCase(); if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) { const token = window.__CSRF_TOKEN; if (token) { if (!init.headers) init.headers = {}; if (typeof init.headers === 'object' && !(init.headers instanceof Headers)) { init.headers['X-CSRF-Token'] = token; } else if (init.headers instanceof Headers) { init.headers.set('X-CSRF-Token', token); } } } return _origFetch(input, init); }; } const dropZone = document.getElementById('dropZone'); const fileInput = document.getElementById('fileInput'); const uploadBtn = document.getElementById('uploadBtn'); const uploadStatus = document.getElementById('uploadStatus'); const statusEl = document.getElementById('status'); let selectedFile = null; dropZone.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.classList.add('drag-over'); }); dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over')); dropZone.addEventListener('drop', (e) => { e.preventDefault(); dropZone.classList.remove('drag-over'); if (e.dataTransfer.files.length) { selectedFile = e.dataTransfer.files[0]; uploadBtn.disabled = false; dropZone.querySelector('.drop-text').textContent = 'Выбран: ' + selectedFile.name; } }); fileInput.addEventListener('change', () => { if (fileInput.files.length) { selectedFile = fileInput.files[0]; uploadBtn.disabled = false; dropZone.querySelector('.drop-text').textContent = 'Выбран: ' + selectedFile.name; } }); document.getElementById('uploadForm').addEventListener('submit', async (e) => { e.preventDefault(); if (!selectedFile) return; uploadBtn.disabled = true; uploadStatus.textContent = 'Загрузка...'; uploadStatus.className = ''; const progressEl = document.getElementById('uploadProgress'); const progressFill = progressEl.querySelector('.progress-fill'); const progressText = progressEl.querySelector('.progress-text'); progressEl.classList.remove('hidden'); const startTime = Date.now(); let timer = setInterval(() => { const elapsed = Math.floor((Date.now() - startTime) / 1000); progressText.textContent = `Обработка... ${elapsed}с`; }, 1000); const formData = new FormData(); formData.append('pdf', selectedFile); try { const res = await fetch('/api/upload', { method: 'POST', body: formData }); const data = await res.json(); if (data.error) { uploadStatus.textContent = 'Ошибка: ' + data.error; uploadStatus.className = 'error'; clearInterval(timer); progressEl.classList.add('hidden'); resetUpload(); return; } if (data.taskId) { clearInterval(timer); progressFill.classList.add('determinate'); await pollTask(data.taskId, progressFill, progressText, startTime); progressFill.classList.remove('determinate'); } else { uploadStatus.textContent = data.message; uploadStatus.className = 'success'; statusEl.textContent = 'Данные загружены'; statusEl.classList.add('loaded'); loadFilters(); loadAll(); } } catch (err) { uploadStatus.textContent = 'Ошибка: ' + err.message; uploadStatus.className = 'error'; } clearInterval(timer); progressEl.classList.add('hidden'); resetUpload(); }); let _pollTimer = null; async function pollTask(taskId, progressFill, progressText, startTime) { const updateProgress = (task) => { const pct = task.total ? Math.round(task.done / task.total * 100) : 0; progressFill.style.width = pct + '%'; let txt = task.done + '/' + task.total + ' файлов · ' + task.elapsed + 'с'; if (task.lastFile) { txt = '[Загружен] ' + task.lastFile + ' → ' + (task.lastResult || '') + ' · ' + task.elapsed + 'с'; } progressText.textContent = txt; }; while (true) { await new Promise(r => setTimeout(r, 3000)); try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 30000); const res = await fetch('/api/task/' + taskId, { signal: controller.signal }); clearTimeout(timeoutId); if (!res.ok) { const text = await res.text(); if (text.startsWith('<')) throw new Error('Сервер временно недоступен'); throw new Error('HTTP ' + res.status); } const task = await res.json(); updateProgress(task); if (task.status === 'done' || task.status === 'partial') { progressFill.style.width = '100%'; uploadStatus.textContent = task.result.message; uploadStatus.className = 'success'; statusEl.textContent = 'Данные загружены'; statusEl.classList.add('loaded'); loadFilters(); loadAll(); return; } if (task.status === 'error') { uploadStatus.textContent = 'Ошибка: ' + (task.error || ''); uploadStatus.className = 'error'; return; } } catch (err) { if (err.name === 'AbortError') { progressText.textContent = 'Таймаут запроса, повтор...'; } else { progressText.textContent = 'Ожидание сервера... ' + err.message; } } } } function resetUpload() { selectedFile = null; fileInput.value = ''; uploadBtn.disabled = true; dropZone.querySelector('.drop-text').textContent = 'Перетащите PDF или ZIP сюда или кликните для выбора'; } document.querySelectorAll('.tab').forEach(tab => { tab.addEventListener('click', () => { document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active')); tab.classList.add('active'); document.getElementById(tab.dataset.tab).classList.add('active'); }); }); function getFilterParams() { const params = {}; const student = document.getElementById('gfStudent').value.trim(); const subject = document.getElementById('gfSubject').value.trim(); const teacher = document.getElementById('gfTeacher').value.trim(); const classVal = document.getElementById('gfClass').value.trim(); const parallel = document.getElementById('gfParallel').value.trim(); const from = document.getElementById('gfDateFrom').value; const to = document.getElementById('gfDateTo').value; if (student) params.student = student; if (subject) params.subject = subject; if (teacher) params.teacher = teacher; if (classVal) params.class = classVal; if (parallel) params.parallel = parallel; if (from) params.date_from = from.slice(5); if (to) params.date_to = to.slice(5); return params; } function buildQuery(params) { const q = new URLSearchParams(); for (const [k, v] of Object.entries(params)) q.set(k, v); return q.toString(); } async function loadAll() { const params = getFilterParams(); const qs = buildQuery(params); const [statsRes, dailyRes] = await Promise.all([ fetch('/api/stats?' + qs), fetch('/api/daily?' + qs + '&limit=150&offset=0') ]); const stats = await statsRes.json(); const daily = await dailyRes.json(); renderSummary(stats.summary); renderAlerts(stats); renderSubjectsTable(stats); renderTeachersTable(stats); renderClassesTable(stats); renderDailyTable(daily); loadPivot(); loadControl(); } async function loadFilters() { try { const res = await fetch('/api/filters'); const data = await res.json(); if (data.error) return; const fill = (id, items) => { const dl = document.getElementById(id); if (dl) dl.innerHTML = (items || []).map(v => ``).join(''); }; fill('dlStudents', data.students); fill('dlSubjects', data.subjects); fill('dlTeachers', data.teachers); fill('dlClasses', data.classes); fill('dlParallels', data.parallels); } catch (e) { console.error(e); } } function renderSummary(summary) { const cards = document.getElementById('summaryCards'); if (!summary || !summary.totalStudents) { cards.innerHTML = 'Нет данных. Загрузите PDF журнала.'; return; } cards.innerHTML = ` ${summary.totalClasses}Классов ${summary.totalStudents}Учеников ${summary.totalTeachers}Учителей ${summary.totalSubjects}Предметов ${summary.totalDaily}Подневных оценок `; } function renderAlerts(stats) { const alerts = document.getElementById('alerts'); let html = ''; if (stats.subjectsWithFewGrades && stats.subjectsWithFewGrades.length > 0) { for (const s of stats.subjectsWithFewGrades) { html += `⚠️ ${s.warning}`; } } if (stats.teachersWithNoGrades && stats.teachersWithNoGrades.length > 0) { for (const t of stats.teachersWithNoGrades) { html += `❌ Учитель «${t.name}» (${t.subject_name}) — нет отметок`; } } if (!html) { const total = stats.summary ? stats.summary.totalDaily : 0; if (total > 0) html = 'Все предметы имеют достаточное количество оценок. Проблем не обнаружено.'; else html = 'Загрузите PDF журнала для отображения предупреждений.'; } alerts.innerHTML = html; } function renderSubjectsTable(stats) { const tbody = document.querySelector('#subjectsTable tbody'); const arr = stats.gradeCountBySubject || []; if (!arr.length) { tbody.innerHTML = 'Нет данных'; return; } const threshold = 10; tbody.innerHTML = arr.map(s => { const cls = s.student_count < threshold ? 'low-grades' : ''; return ` ${s.subject} ${s.total} ${s.fives} ${s.fours} ${s.threes} ${s.twos} ${s.n_count || 0} ${s.b_count || 0} ${s.u_count || 0} ${s.avg_grade !== null ? s.avg_grade : '—'} ${s.student_count} `; }).join(''); } function renderTeachersTable(stats) { const tbody = document.querySelector('#teachersTable tbody'); const arr = stats.gradeCountByTeacher || []; const noGrades = new Set((stats.teachersWithNoGrades || []).map(t => t.name)); let html = ''; for (const t of arr) { const cls = noGrades.has(t.teacher) ? 'no-grades' : ''; html += ` ${t.teacher} ${t.subject_name} ${t.total} ${t.fives} ${t.fours} ${t.threes} ${t.twos} ${t.n_count || 0} ${t.b_count || 0} ${t.u_count || 0} ${t.student_count || '—'} `; } if ((stats.teachersWithNoGrades || []).length > 0 && !arr.length) { for (const t of stats.teachersWithNoGrades) { html += ` ${t.name} ${t.subject_name} 0 00000000 `; } } if (!html) html = 'Нет данных'; tbody.innerHTML = html; } function renderClassesTable(stats) { const tbody = document.querySelector('#classesTable tbody'); const arr = stats.gradeCountByClass || []; if (!arr.length) { tbody.innerHTML = 'Нет данных'; return; } tbody.innerHTML = arr.map(c => ` ${c.class} ${c.student_count} ${c.total} ${c.fives} ${c.fours} ${c.threes} ${c.twos} ${c.n_count || 0} ${c.b_count || 0} ${c.u_count || 0} `).join(''); } function renderDailyTable(data) { const tbody = document.querySelector('#dailyTable tbody'); if (!data.rows || !data.rows.length) { tbody.innerHTML = 'Нет данных'; document.getElementById('dailyPagination').innerHTML = ''; return; } tbody.innerHTML = data.rows.map(r => { const g = r.grade || r.symbol || '—'; return ` ${r.grade_date} ${r.student} ${r.class} ${r.subject} ${r.teacher} ${g} `; }).join(''); const totalPages = Math.ceil(data.total / 150); document.getElementById('dailyPagination').innerHTML = totalPages > 1 ? ` ← стр. 1 / ${totalPages} 1 ? '' : 'disabled'} onclick="dailyNav(1)">→ ` : ''; } window.dailyPage = 0; window.dailyNav = async function(page) { window.dailyPage = page; const params = getFilterParams(); const qs = buildQuery(params) + '&limit=150&offset=' + (page * 150); const res = await fetch('/api/daily?' + qs); const data = await res.json(); renderDailyTable(data); document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active')); document.querySelector('[data-tab="daily"]').classList.add('active'); document.getElementById('daily').classList.add('active'); }; document.getElementById('gfApply').addEventListener('click', () => loadAll()); document.getElementById('gfParallel').addEventListener('change', () => loadAll()); document.getElementById('gfParallel').addEventListener('keydown', (e) => { if (e.key === 'Enter') loadAll(); }); document.getElementById('gfReset').addEventListener('click', () => { document.getElementById('gfStudent').value = ''; document.getElementById('gfSubject').value = ''; document.getElementById('gfTeacher').value = ''; document.getElementById('gfClass').value = ''; document.getElementById('gfParallel').value = ''; document.getElementById('gfDateFrom').value = ''; document.getElementById('gfDateTo').value = ''; loadAll(); }); const modal = document.getElementById('clearModal'); document.getElementById('clearBtn').addEventListener('click', () => { modal.classList.add('open'); loadClassList(); }); document.getElementById('modalClose').addEventListener('click', () => modal.classList.remove('open')); modal.addEventListener('click', (e) => { if (e.target === modal) modal.classList.remove('open'); }); async function loadClassList() { try { const res = await fetch('/api/stats'); const stats = await res.json(); const dl = document.getElementById('dlClasses'); if (dl && stats.classes) { dl.innerHTML = stats.classes.map(c => ``).join(''); } } catch (e) {} } document.getElementById('clearClassBtn').addEventListener('click', async () => { const name = document.getElementById('clearClass').value.trim(); if (!name) return; const msg = document.getElementById('clearClassMsg'); if (!confirm(`Удалить класс ${name} и все его данные?`)) return; msg.className = ''; msg.textContent = '...'; try { const res = await fetch('/api/classes/' + encodeURIComponent(name), { method: 'DELETE' }); const data = await res.json(); msg.className = data.error ? 'error' : 'success'; msg.textContent = data.message || data.error; if (data.ok) { loadFilters(); loadAll(); } } catch (e) { msg.className = 'error'; msg.textContent = e.message; } }); document.getElementById('clearParallelBtn').addEventListener('click', async () => { const grade = document.getElementById('clearParallel').value.trim(); if (!grade) return; const msg = document.getElementById('clearParallelMsg'); if (!confirm(`Удалить ВСЕ классы параллели ${grade}?`)) return; msg.className = ''; msg.textContent = '...'; try { const res = await fetch('/api/parallel/' + encodeURIComponent(grade), { method: 'DELETE' }); const data = await res.json(); msg.className = data.error ? 'error' : 'success'; msg.textContent = data.message || data.error; if (data.ok) { loadFilters(); loadAll(); } } catch (e) { msg.className = 'error'; msg.textContent = e.message; } }); document.getElementById('clearAllBtn').addEventListener('click', async () => { const msg = document.getElementById('clearAllMsg'); if (!confirm('Удалить ВСЕ данные без возможности восстановления?')) return; if (!confirm('Точно?')) return; msg.className = ''; msg.textContent = '...'; try { const res = await fetch('/api/data', { method: 'DELETE' }); const data = await res.json(); msg.className = data.error ? 'error' : 'success'; msg.textContent = data.message || data.error || 'Всё удалено'; if (data.ok) { loadFilters(); loadAll(); } } catch (e) { msg.className = 'error'; msg.textContent = e.message; } }); let pivotMode = 'month'; document.querySelectorAll('.per-btn').forEach(btn => { btn.addEventListener('click', () => { document.querySelectorAll('.per-btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); pivotMode = btn.dataset.per; loadPivot(); }); }); async function loadPivot() { const params = getFilterParams(); params.period = pivotMode; const qs = buildQuery(params); const infoEl = document.getElementById('pivotInfo'); const start = Date.now(); const timer = setInterval(() => { infoEl.textContent = 'Загрузка... ' + Math.floor((Date.now() - start) / 1000) + 'с'; }, 500); try { const res = await fetch('/api/pivot?' + qs); clearInterval(timer); const data = await res.json(); if (data.error) { document.querySelector('#pivotTable tbody').innerHTML = '' + data.error + ''; document.getElementById('pivotInfo').textContent = ''; return; } renderPivot(data); } catch (e) { console.error(e); } } function renderPivot(data) { const thead = document.querySelector('#pivotTable thead'); const tbody = document.querySelector('#pivotTable tbody'); document.getElementById('pivotInfo').textContent = data.rows.length + ' строк, ' + data.periods.length + ' периодов'; let headHtml = 'Предмет / Учитель / Ученик'; for (const lbl of data.periodLabels) { headHtml += '' + lbl + ''; } headHtml += ''; thead.innerHTML = headHtml; if (!data.rows.length) { tbody.innerHTML = 'Нет данных'; return; } const subjectGroups = {}; for (const row of data.rows) { if (!subjectGroups[row.subject]) subjectGroups[row.subject] = []; subjectGroups[row.subject].push(row); } let html = ''; for (const [subject, rows] of Object.entries(subjectGroups).sort()) { const subjId = 's_' + subject.replace(/[^a-zа-яё0-9]/gi, '_'); html += ''; html += '▶ ' + subject + ''; for (let i = 0; i < data.periods.length; i++) html += ''; html += ''; const teacherGroups = {}; for (const row of rows) { if (!teacherGroups[row.teacher]) teacherGroups[row.teacher] = []; teacherGroups[row.teacher].push(row); } for (const [teacher, trows] of Object.entries(teacherGroups)) { const tchId = subjId + '_t_' + teacher.replace(/[^a-zа-яё0-9]/gi, '_'); html += ''; html += '▶ ' + teacher + ''; for (let i = 0; i < data.periods.length; i++) html += ''; html += ''; for (const row of trows) { html += ''; html += '' + row.student + ''; for (let i = 0; i < data.periods.length; i++) { const val = row.cells[i] || ''; html += '' + val + ''; } html += ''; } } } tbody.innerHTML = html; } window.toggleGroup = function(groupId) { const rows = document.querySelectorAll('.' + groupId); const tog = document.getElementById('tog_' + groupId); if (!rows.length) return; const firstRow = rows[0]; if (firstRow.classList.contains('pivot-hidden')) { rows.forEach(r => r.classList.remove('pivot-hidden')); if (tog) tog.classList.add('open'); } else { rows.forEach(r => r.classList.add('pivot-hidden')); if (tog) tog.classList.remove('open'); } }; let controlMode = 'homework'; document.querySelectorAll('.ctrl-tab').forEach(btn => { btn.addEventListener('click', () => { document.querySelectorAll('.ctrl-tab').forEach(b => b.classList.remove('active')); btn.classList.add('active'); controlMode = btn.dataset.ctrl; document.getElementById('ctrlFiltersHW').style.display = controlMode === 'homework' ? '' : 'none'; document.getElementById('ctrlFiltersGrades').style.display = controlMode === 'grades' ? '' : 'none'; document.getElementById('ctrlFiltersDvoiki').style.display = controlMode === 'dvoiki' ? '' : 'none'; document.getElementById('ctrlTableWrap').style.display = controlMode === 'dvoiki' ? 'none' : ''; document.getElementById('dvoikiCards').style.display = controlMode === 'dvoiki' ? '' : 'none'; loadControl(); }); }); document.getElementById('ctrlNoTopic').addEventListener('change', loadControl); document.getElementById('ctrlNoHomework').addEventListener('change', loadControl); document.getElementById('ctrlMinGrades').addEventListener('change', loadControl); async function loadControl() { const params = getFilterParams(); if (controlMode === 'dvoiki') { const qs = buildQuery(params); const container = document.getElementById('dvoikiCards'); container.innerHTML = ' Загрузка...'; const loadText = document.getElementById('ctrlLoadText'); const start = Date.now(); const timer = setInterval(() => { if (loadText) loadText.textContent = 'Загрузка... ' + Math.floor((Date.now() - start) / 1000) + 'с'; }, 500); try { const res = await fetch('/api/dvoiki?' + qs); clearInterval(timer); const data = await res.json(); if (data.error) return; const allRows = data.rows || []; const openCount = allRows.filter(r => !r.resolved).length; const closedCount = allRows.filter(r => r.resolved).length; const unfixedCount = allRows.filter(r => !r.resolved && !r.objective).length; const objectiveCount = allRows.filter(r => !r.resolved && r.objective).length; const teacherStats = {}; for (const r of allRows) { if (!r.resolved) { const t = r.teacher || '—'; if (!teacherStats[t]) teacherStats[t] = { open: 0, closed: 0, total: 0 }; teacherStats[t].total++; teacherStats[t].open++; } } const teacherList = Object.entries(teacherStats) .map(([name, s]) => ({ name, open: s.open, closed: s.closed, total: s.total })) .sort((a, b) => b.open - a.open || a.name.localeCompare(b.name)); renderDvoikiPills(allRows.length, openCount, unfixedCount, objectiveCount, closedCount, teacherList); let rows = allRows; if (window._dvoikiFilter === 'unfixed') rows = rows.filter(r => !r.resolved && !r.objective); else if (window._dvoikiFilter === 'objective') rows = rows.filter(r => !r.resolved && r.objective); else if (window._dvoikiFilter === 'closed') rows = rows.filter(r => r.resolved); if (window._dvoikiTeacher && window._dvoikiTeacher !== 'all') { rows = rows.filter(r => (r.teacher || '—') === window._dvoikiTeacher); } renderDvoikiCards(rows); } catch (e) { console.error(e); } return; } if (controlMode === 'homework') { if (document.getElementById('ctrlNoTopic').checked) params.no_topic = 'true'; if (document.getElementById('ctrlNoHomework').checked) params.no_homework = 'true'; } else { params.low_grades = 'true'; params.min_grades = document.getElementById('ctrlMinGrades').value || '3'; } const qs = buildQuery(params); const thead = document.querySelector('#controlTable thead'); thead.innerHTML = 'ДатаПредметУчительТемаДЗВсегоЧисл.Симв.Оценки'; document.querySelector('#controlTable tbody').innerHTML = ' Загрузка...'; const loadText = document.getElementById('ctrlLoadText'); const start = Date.now(); const timer = setInterval(() => { if (loadText) loadText.textContent = 'Загрузка... ' + Math.floor((Date.now() - start) / 1000) + 'с'; }, 500); try { const res = await fetch('/api/lessons?' + qs); clearInterval(timer); const data = await res.json(); if (data.error) return; const tbody = document.querySelector('#controlTable tbody'); if (!data.rows || !data.rows.length) { tbody.innerHTML = 'Нет данных'; return; } const minNumeric = data.min_grades || 3; tbody.innerHTML = data.rows.map(r => { let topicCls = ''; let hwCls = ''; let rowCls = ''; if (controlMode === 'homework') { if (!r.topic) topicCls = 'missing'; if (!r.homework) hwCls = 'missing'; } else { if (r.grades_numeric < minNumeric) rowCls = 'red-row'; } return ` ${r.lesson_date} ${r.subject} ${r.teacher} ${r.topic || '—'} ${r.homework || '—'} ${r.grades_total} ${r.grades_numeric} ${r.grades_symbol} ${r.grades_list || ''} `; }).join(''); } catch (e) { console.error(e); } } let _dvoikiFilter = 'all'; function renderDvoikiPills(total, open, unfixed, objective, closed, teacherList) { window._dvoikiFilter = window._dvoikiFilter || 'unfixed'; window._dvoikiTeacher = window._dvoikiTeacher || 'all'; const pills = [ { key: 'all', label: 'Все', count: total, active: window._dvoikiFilter === 'all' }, { key: 'unfixed', label: '⚠️ Не исправили', count: unfixed, active: window._dvoikiFilter === 'unfixed' }, { key: 'objective', label: '🚫 Не могли исправить', count: objective, active: window._dvoikiFilter === 'objective' }, { key: 'closed', label: '✅ Закрыто', count: closed, active: window._dvoikiFilter === 'closed' } ]; let html = '' + pills.map(p => `${p.label} (${p.count})` ).join('') + ''; html += '' + `Все учителя` + teacherList.map(t => ` ${t.name} ${t.open > 0 ? '' + t.open + '' : ''} ` ).join('') + ''; document.getElementById('dvoikiPills').innerHTML = html; document.querySelectorAll('#dvoikiPills .pill').forEach(btn => { btn.addEventListener('click', () => { if (btn.dataset.filter) { window._dvoikiFilter = btn.dataset.filter; } if (btn.dataset.teacher) { window._dvoikiTeacher = btn.dataset.teacher; } loadControl(); }); }); } function renderDvoikiCards(rows) { const container = document.getElementById('dvoikiCards'); if (!rows.length) { container.innerHTML = 'Нет цепочек'; return; } const gradeClass = (g) => { if (g === '2') return 's-2'; if (g === '3') return 's-3'; if (g === '4' || g === '5') return 's-45'; if (g === 'X') return 's-stop'; if (g === '—') return 's-empty'; return 's-sym'; }; container.innerHTML = rows.map(r => { const cardCls = r.resolved ? 'd-card resolved' : 'd-card unresolved'; const statusIcon = r.resolved ? '✅' : '⚠️'; const statusText = r.resolved ? `Закрыт (${r.reason})` : `Не закрыт — ${r.reason}`; const stepsHtml = r.chain.map(c => ` ${c.date} ${c.grade} ` ).join(''); return ` ${statusIcon} ${statusText} ${r.chain.length} шаг${r.chain.length > 1 ? (r.chain.length < 5 ? 'а' : 'ов') : ''} ${r.student} ${r.subject} · ${r.teacher} ${stepsHtml} `; }).join(''); } document.addEventListener('DOMContentLoaded', function() { document.getElementById('login-form').addEventListener('submit', login); checkAuth(); });