834 lines
31 KiB
JavaScript
834 lines
31 KiB
JavaScript
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();
|
||
});
|