Files
SchoolVue/public/app.js
2026-06-19 11:24:17 +05:00

496 lines
18 KiB
JavaScript
Raw 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.
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 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';
} 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';
}
selectedFile = null;
fileInput.value = '';
uploadBtn.disabled = true;
dropZone.querySelector('.drop-text').textContent = 'Перетащите PDF сюда или кликните для выбора';
});
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 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 (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();
}
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);
} 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('gfReset').addEventListener('click', () => {
document.getElementById('gfStudent').value = '';
document.getElementById('gfSubject').value = '';
document.getElementById('gfTeacher').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);
document.getElementById('pivotInfo').textContent = 'Загрузка...';
try {
const res = await fetch('/api/pivot?' + qs);
const data = await res.json();
if (data.error) {
document.getElementById('pivotTable').innerHTML = '<tr><td class="empty-state">' + data.error + '</td></tr>';
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');
}
};
loadFilters();
loadAll();