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 => `