Files
SchoolVue/public/app.js
2026-07-07 10:37:51 +05:00

834 lines
31 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 => `<option value="${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 = '<div class="empty-state">Нет данных. Загрузите PDF журнала.</div>';
return;
}
cards.innerHTML = `
<div class="card"><div class="value">${summary.totalClasses}</div><div class="label">Классов</div></div>
<div class="card"><div class="value">${summary.totalStudents}</div><div class="label">Учеников</div></div>
<div class="card"><div class="value">${summary.totalTeachers}</div><div class="label">Учителей</div></div>
<div class="card"><div class="value">${summary.totalSubjects}</div><div class="label">Предметов</div></div>
<div class="card"><div class="value">${summary.totalDaily}</div><div class="label">Подневных оценок</div></div>
`;
}
function renderAlerts(stats) {
const alerts = document.getElementById('alerts');
let html = '';
if (stats.subjectsWithFewGrades && stats.subjectsWithFewGrades.length > 0) {
for (const s of stats.subjectsWithFewGrades) {
html += `<div class="alert warning">⚠️ ${s.warning}</div>`;
}
}
if (stats.teachersWithNoGrades && stats.teachersWithNoGrades.length > 0) {
for (const t of stats.teachersWithNoGrades) {
html += `<div class="alert danger">❌ Учитель «${t.name}» (${t.subject_name}) — нет отметок</div>`;
}
}
if (!html) {
const total = stats.summary ? stats.summary.totalDaily : 0;
if (total > 0) html = '<div class="alert info">Все предметы имеют достаточное количество оценок. Проблем не обнаружено.</div>';
else html = '<div class="alert info">Загрузите PDF журнала для отображения предупреждений.</div>';
}
alerts.innerHTML = html;
}
function renderSubjectsTable(stats) {
const tbody = document.querySelector('#subjectsTable tbody');
const arr = stats.gradeCountBySubject || [];
if (!arr.length) {
tbody.innerHTML = '<tr><td colspan="11" class="empty-state">Нет данных</td></tr>';
return;
}
const threshold = 10;
tbody.innerHTML = arr.map(s => {
const cls = s.student_count < threshold ? 'low-grades' : '';
return `<tr>
<td class="${cls}">${s.subject}</td>
<td class="num ${cls}">${s.total}</td>
<td class="num">${s.fives}</td>
<td class="num">${s.fours}</td>
<td class="num">${s.threes}</td>
<td class="num">${s.twos}</td>
<td class="num">${s.n_count || 0}</td>
<td class="num">${s.b_count || 0}</td>
<td class="num">${s.u_count || 0}</td>
<td class="num">${s.avg_grade !== null ? s.avg_grade : '—'}</td>
<td class="num">${s.student_count}</td>
</tr>`;
}).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 += `<tr>
<td class="${cls}">${t.teacher}</td>
<td>${t.subject_name}</td>
<td class="num ${cls}">${t.total}</td>
<td class="num">${t.fives}</td>
<td class="num">${t.fours}</td>
<td class="num">${t.threes}</td>
<td class="num">${t.twos}</td>
<td class="num">${t.n_count || 0}</td>
<td class="num">${t.b_count || 0}</td>
<td class="num">${t.u_count || 0}</td>
<td class="num">${t.student_count || '—'}</td>
</tr>`;
}
if ((stats.teachersWithNoGrades || []).length > 0 && !arr.length) {
for (const t of stats.teachersWithNoGrades) {
html += `<tr>
<td class="no-grades">${t.name}</td>
<td>${t.subject_name}</td>
<td class="num no-grades">0</td>
<td class="num">0</td><td class="num">0</td><td class="num">0</td><td class="num">0</td><td class="num">0</td><td class="num">0</td><td class="num">0</td><td class="num">0</td>
</tr>`;
}
}
if (!html) html = '<tr><td colspan="11" class="empty-state">Нет данных</td></tr>';
tbody.innerHTML = html;
}
function renderClassesTable(stats) {
const tbody = document.querySelector('#classesTable tbody');
const arr = stats.gradeCountByClass || [];
if (!arr.length) {
tbody.innerHTML = '<tr><td colspan="10" class="empty-state">Нет данных</td></tr>';
return;
}
tbody.innerHTML = arr.map(c => `<tr>
<td>${c.class}</td>
<td class="num">${c.student_count}</td>
<td class="num">${c.total}</td>
<td class="num">${c.fives}</td>
<td class="num">${c.fours}</td>
<td class="num">${c.threes}</td>
<td class="num">${c.twos}</td>
<td class="num">${c.n_count || 0}</td>
<td class="num">${c.b_count || 0}</td>
<td class="num">${c.u_count || 0}</td>
</tr>`).join('');
}
function renderDailyTable(data) {
const tbody = document.querySelector('#dailyTable tbody');
if (!data.rows || !data.rows.length) {
tbody.innerHTML = '<tr><td colspan="6" class="empty-state">Нет данных</td></tr>';
document.getElementById('dailyPagination').innerHTML = '';
return;
}
tbody.innerHTML = data.rows.map(r => {
const g = r.grade || r.symbol || '—';
return `<tr>
<td>${r.grade_date}</td>
<td>${r.student}</td>
<td>${r.class}</td>
<td>${r.subject}</td>
<td>${r.teacher}</td>
<td class="num">${g}</td>
</tr>`;
}).join('');
const totalPages = Math.ceil(data.total / 150);
document.getElementById('dailyPagination').innerHTML = totalPages > 1 ? `
<button ${true ? 'disabled' : ''} onclick="dailyNav(0)">←</button>
<span>стр. 1 / ${totalPages}</span>
<button ${totalPages > 1 ? '' : 'disabled'} onclick="dailyNav(1)">→</button>
` : '';
}
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 => `<option value="${c.name}">`).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 = '<tr><td class="empty-state">' + data.error + '</td></tr>';
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 = '<tr><th>Предмет / Учитель / Ученик</th>';
for (const lbl of data.periodLabels) {
headHtml += '<th>' + lbl + '</th>';
}
headHtml += '</tr>';
thead.innerHTML = headHtml;
if (!data.rows.length) {
tbody.innerHTML = '<tr><td colspan="' + (data.periods.length + 1) + '" class="empty-state">Нет данных</td></tr>';
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 += '<tr class="pivot-subject-row" data-group="' + subjId + '">';
html += '<td class="pivot-subject" onclick="toggleGroup(\'' + subjId + '\')"><span class="toggle" id="tog_' + subjId + '">▶</span> ' + subject + '</td>';
for (let i = 0; i < data.periods.length; i++) html += '<td></td>';
html += '</tr>';
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 += '<tr class="pivot-hidden ' + subjId + ' pivot-teacher-row" data-group="' + tchId + '">';
html += '<td class="pivot-teacher" onclick="toggleGroup(\'' + tchId + '\')"><span class="toggle" id="tog_' + tchId + '">▶</span> ' + teacher + '</td>';
for (let i = 0; i < data.periods.length; i++) html += '<td></td>';
html += '</tr>';
for (const row of trows) {
html += '<tr class="pivot-hidden ' + subjId + ' ' + tchId + '">';
html += '<td class="pivot-student">' + row.student + '</td>';
for (let i = 0; i < data.periods.length; i++) {
const val = row.cells[i] || '';
html += '<td class="pivot-cell">' + val + '</td>';
}
html += '</tr>';
}
}
}
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 = '<div class="empty-state"><span class="spinner"></span> <span id="ctrlLoadText">Загрузка...</span></div>';
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 = '<tr><th>Дата</th><th>Предмет</th><th>Учитель</th><th>Тема</th><th>ДЗ</th><th>Всего</th><th>Числ.</th><th>Симв.</th><th>Оценки</th></tr>';
document.querySelector('#controlTable tbody').innerHTML = '<tr><td colspan="9" class="empty-state"><span class="spinner"></span> <span id="ctrlLoadText">Загрузка...</span></td></tr>';
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 = '<tr><td colspan="9" class="empty-state">Нет данных</td></tr>';
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 `<tr class="${rowCls}">
<td>${r.lesson_date}</td>
<td>${r.subject}</td>
<td>${r.teacher}</td>
<td class="${topicCls}">${r.topic || '—'}</td>
<td class="${hwCls}">${r.homework || '—'}</td>
<td class="num">${r.grades_total}</td>
<td class="num">${r.grades_numeric}</td>
<td class="num">${r.grades_symbol}</td>
<td class="grades-cell">${r.grades_list || ''}</td>
</tr>`;
}).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 = '<div class="dvoiki-pills">' +
pills.map(p =>
`<button class="pill ${p.active ? 'active' : ''}" data-filter="${p.key}">${p.label} (${p.count})</button>`
).join('') +
'</div>';
html += '<div class="dvoiki-pills" style="margin-top:6px">' +
`<button class="pill ${window._dvoikiTeacher === 'all' ? 'active' : ''}" data-teacher="all">Все учителя</button>` +
teacherList.map(t =>
`<button class="pill ${window._dvoikiTeacher === t.name ? 'active' : ''}" data-teacher="${t.name}">
${t.name} ${t.open > 0 ? '<span class="pill-badge">' + t.open + '</span>' : ''}
</button>`
).join('') +
'</div>';
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 = '<div class="empty-state">Нет цепочек</div>';
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 =>
`<div class="step">
<div class="step-date">${c.date}</div>
<div class="step-grade ${gradeClass(c.grade)}">${c.grade}</div>
</div>`
).join('');
return `<div class="${cardCls}">
<div class="d-card-top">
<span class="d-card-status">${statusIcon} ${statusText}</span>
<span class="d-card-steps">${r.chain.length} шаг${r.chain.length > 1 ? (r.chain.length < 5 ? 'а' : 'ов') : ''}</span>
</div>
<div class="d-card-student">${r.student}</div>
<div class="d-card-subject">${r.subject} · ${r.teacher}</div>
<div class="step-grid">${stepsHtml}</div>
</div>`;
}).join('');
}
document.addEventListener('DOMContentLoaded', function() {
document.getElementById('login-form').addEventListener('submit', login);
checkAuth();
});