496 lines
18 KiB
JavaScript
496 lines
18 KiB
JavaScript
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();
|