/
schoolproject
/
offlinewebschollproject
Обзор
Документация
Войти
/
schoolproject
/
offlinewebschollproject
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
app.js
2 278 строк
88 KB
schoolproject
create: app.js, index.html, package.json, server.js, start_server.bat, start_server.py, start_server.sh, styles.css
14 мар 2026, 20:29
Верифицирован
14 мар 2026, 20:29
ea4e370
Код
Авторство
О чём код?
// Структура данных для 5 таблиц: 4 четверти + годовая let quartersData = { quarter1: [], // Первая четверть quarter2: [], // Вторая четверть quarter3: [], // Третья четверть quarter4: [], // Четвертая четверть yearly: [] // Годовая }; // Текущая выбранная таблица let currentTable = 'quarter1'; // Геттер для получения текущего массива классов function getCurrentClasses() { return quartersData[currentTable]; } // Сеттер для установки текущего массива классов function setCurrentClasses(newClasses) { quartersData[currentTable] = newClasses; } // Все фиксируемые причины (для детализированных отчетов и графиков) const reasons = [ 'event', 'olympiad', 'competition', 'doctor', 'military', 'sanatorium', 'training', 'camp', 'documents', 'kdn', 'frost', 'transport', 'diagnostic', 'medosmotr', 'dov', 'virus' ]; // Причины, которые считаются именно уважительными (идут в поле valid) const validReasons = [ 'event', 'olympiad', 'competition', 'doctor', 'military', 'sanatorium', 'training', 'camp', 'documents', 'kdn', 'frost', 'transport', 'diagnostic', 'medosmotr' // ВНИМАНИЕ: 'dov' и 'virus' здесь НЕТ — они считаются отдельно, как и "без причины" ]; const reasonLabels = { event: 'Мероприятия', olympiad: 'Олимпиады', competition: 'Соревнования', doctor: 'Визит к врачу', military: 'Военкомат', sanatorium: 'Санаторий', training: 'Военные сборы', camp: 'Лагерь', documents: 'Документы', kdn: 'КДН', frost: 'Мороз', transport: 'Подвоз', diagnostic: 'Диагн. центр', medosmotr: 'Медосмотр', dov: 'Заявление родителей', virus: 'Болезнь' }; // История изменений для Undo/Redo let history = []; let historyIndex = -1; function pushHistory() { if (historyIndex < history.length - 1) { history = history.slice(0, historyIndex + 1); } const snapshot = JSON.stringify(quartersData); if (historyIndex >= 0 && history[historyIndex] === snapshot) { return; } history.push(snapshot); historyIndex = history.length - 1; updateHistoryButtons(); } function restoreFromHistory(index) { if (index < 0 || index >= history.length) return; try { quartersData = JSON.parse(history[index]); historyIndex = index; saveData(); autoSaveToFile(); renderTables(); updateAnalytics(); } catch (e) { console.error('Ошибка восстановления из истории:', e); } finally { updateHistoryButtons(); } } function undo() { if (historyIndex > 0) { restoreFromHistory(historyIndex - 1); } } function redo() { if (historyIndex < history.length - 1) { restoreFromHistory(historyIndex + 1); } } function updateHistoryButtons() { // Кнопки всегда активны, проверки в undo/redo } document.addEventListener('DOMContentLoaded', () => { loadData(); setupEventListeners(); updateYearlyTableFromQuarters(); switchQuarterTable(currentTable); renderTables(); updateAnalytics(); }); function setupEventListeners() { // Вкладки для переключения между основными разделами document.querySelectorAll('.tab-btn').forEach(btn => { btn.addEventListener('click', (e) => { const tab = e.currentTarget.dataset.tab; switchTab(tab); }); }); // Вкладки для переключения между таблицами document.querySelectorAll('.quarter-tab-btn').forEach(btn => { btn.addEventListener('click', (e) => { const quarter = e.currentTarget.dataset.quarter; switchQuarterTable(quarter); }); }); document.getElementById('addClassBtn').addEventListener('click', () => openClassModal()); const deleteAllBtn = document.getElementById('deleteAllClassesBtn'); if (deleteAllBtn) { deleteAllBtn.addEventListener('click', deleteAllClasses); } document.getElementById('saveClassBtn').addEventListener('click', saveClass); document.getElementById('cancelClassBtn').addEventListener('click', closeClassModal); document.getElementById('closeClassModal').addEventListener('click', closeClassModal); const saveBtn = document.getElementById('saveBtn'); if (saveBtn) { saveBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); saveStudent(); }); } document.getElementById('cancelBtn').addEventListener('click', closeModal); document.getElementById('closeModal').addEventListener('click', closeModal); // Экспорт/импорт document.getElementById('selectSaveFileBtn').addEventListener('click', selectAutoSaveFile); document.getElementById('loadFromFileBtn').addEventListener('click', loadFromAutoSaveFile); // ★★★ ОБНОВЛЕННЫЙ ОБРАБОТЧИК ДЛЯ ЭКСПОРТА В EXCEL ★★★ document.getElementById('exportExcelBtn').addEventListener('click', exportToExcel); document.getElementById('importBtn').addEventListener('click', () => { document.getElementById('importFile').click(); }); document.getElementById('importFile').addEventListener('change', importData); // Кнопки отмены / повтора const undoBtn = document.getElementById('undoBtn'); const redoBtn = document.getElementById('redoBtn'); if (undoBtn) undoBtn.addEventListener('click', undo); if (redoBtn) redoBtn.addEventListener('click', redo); initializeAutoSave(); document.getElementById('modalInvalid').addEventListener('input', updateModalTotal); reasons.forEach(reason => { const input = document.getElementById(`modal${capitalize(reason)}`); if (input) { input.addEventListener('input', updateModalTotal); } }); document.getElementById('classModal').addEventListener('click', (e) => { if (e.target.id === 'classModal') { closeClassModal(); } }); document.getElementById('studentModal').addEventListener('click', (e) => { if (e.target.id === 'studentModal') { closeModal(); } }); } function switchTab(tab) { document.querySelectorAll('.tab-btn').forEach(btn => { btn.classList.remove('active'); }); document.querySelectorAll('.tab-content').forEach(content => { content.classList.remove('active'); }); document.querySelector(`[data-tab="${tab}"]`).classList.add('active'); document.getElementById(`${tab}Tab`).classList.add('active'); if (tab === 'analytics') { setTimeout(() => { updateCharts(); }, 100); } } function switchQuarterTable(quarter) { currentTable = quarter; document.querySelectorAll('.quarter-tab-btn').forEach(btn => { btn.classList.remove('active'); }); document.querySelector(`[data-quarter="${quarter}"]`).classList.add('active'); const titles = { quarter1: 'Первая четверть - Классы и учащиеся', quarter2: 'Вторая четверть - Классы и учащиеся', quarter3: 'Третья четверть - Классы и учащиеся', quarter4: 'Четвертая четверть - Классы и учащиеся', yearly: 'Годовая - Классы и учащиеся (сумма всех четвертей)' }; const titleElement = document.getElementById('currentTableTitle'); if (titleElement) { titleElement.textContent = titles[quarter] || 'Классы и учащиеся'; } const addClassBtn = document.getElementById('addClassBtn'); const deleteAllBtn = document.getElementById('deleteAllClassesBtn'); if (quarter === 'yearly') { if (addClassBtn) addClassBtn.style.display = 'inline-flex'; if (deleteAllBtn) deleteAllBtn.style.display = 'inline-flex'; } else { if (addClassBtn) addClassBtn.style.display = 'none'; if (deleteAllBtn) deleteAllBtn.style.display = 'none'; } if (quarter === 'yearly') { updateYearlyTableFromQuarters(); } renderTables(); } function openClassModal(classData = null) { if (currentTable !== 'yearly') { alert('Добавлять и редактировать классы можно только в годовой таблице. Переключитесь на годовую таблицу.'); return; } const modal = document.getElementById('classModal'); const title = document.getElementById('classModalTitle'); if (classData) { title.textContent = 'Редактировать класс'; document.getElementById('modalClassName').value = classData.name; modal.dataset.classId = classData.id; } else { title.textContent = 'Добавить класс'; document.getElementById('modalClassName').value = ''; delete modal.dataset.classId; } modal.classList.add('active'); } function closeClassModal() { document.getElementById('classModal').classList.remove('active'); document.getElementById('modalClassName').value = ''; document.getElementById('classValidationMessage').textContent = ''; document.getElementById('classValidationMessage').className = 'validation-message'; } function saveClass() { const modal = document.getElementById('classModal'); const classId = modal.dataset.classId; const className = document.getElementById('modalClassName').value.trim(); const classes = getCurrentClasses(); if (!className) { showClassValidation('Введите название класса', 'error'); return; } const existingClass = classes.find(c => c.name.toLowerCase() === className.toLowerCase() && c.id !== classId); if (existingClass) { showClassValidation('Класс с таким названием уже существует', 'error'); return; } let newClassId = classId; if (classId) { const classIndex = classes.findIndex(c => c.id === classId); if (classIndex !== -1) { classes[classIndex].name = className; syncClassNameAcrossTables(classId, className); } } else { newClassId = Date.now().toString(); const newClass = { id: newClassId, name: className, students: [] }; classes.push(newClass); if (currentTable === 'yearly') { ['quarter1', 'quarter2', 'quarter3', 'quarter4'].forEach(quarter => { const quarterClasses = quartersData[quarter]; if (!quarterClasses.find(c => c.id === newClassId)) { quarterClasses.push({ id: newClassId, name: className, students: [] }); } }); } else { const yearlyClasses = quartersData.yearly; if (!yearlyClasses.find(c => c.id === newClassId)) { yearlyClasses.push({ id: newClassId, name: className, students: [] }); } } } setCurrentClasses(classes); pushHistory(); saveData(); autoSaveToFile(); renderTables(); closeClassModal(); updateAnalytics(); showClassValidation('Класс успешно сохранен', 'success'); setTimeout(() => { document.getElementById('classValidationMessage').className = 'validation-message'; }, 2000); } function syncClassNameAcrossTables(classId, className) { ['quarter1', 'quarter2', 'quarter3', 'quarter4', 'yearly'].forEach(quarter => { if (quarter === currentTable) return; const quarterClasses = quartersData[quarter]; const quarterClass = quarterClasses.find(c => c.id === classId); if (quarterClass) { quarterClass.name = className; } }); } function showClassValidation(message, type) { const msgEl = document.getElementById('classValidationMessage'); msgEl.textContent = message; msgEl.className = `validation-message ${type}`; } function deleteClass(id) { if (confirm('Вы уверены, что хотите удалить этот класс? Все данные об учениках будут удалены из всех таблиц.')) { const classes = getCurrentClasses(); const filtered = classes.filter(c => c.id !== id); setCurrentClasses(filtered); if (currentTable === 'yearly') { ['quarter1', 'quarter2', 'quarter3', 'quarter4'].forEach(quarter => { const quarterClasses = quartersData[quarter]; const filteredQuarter = quarterClasses.filter(c => c.id !== id); quartersData[quarter] = filteredQuarter; }); } else { const yearlyClasses = quartersData.yearly; quartersData.yearly = yearlyClasses.filter(c => c.id !== id); } pushHistory(); saveData(); autoSaveToFile(); renderTables(); updateAnalytics(); } } function deleteAllClasses() { if (currentTable !== 'yearly') { alert('Удалять все классы можно только из годовой таблицы.'); return; } const classes = getCurrentClasses(); if (classes.length === 0) { alert('Нет классов для удаления'); return; } if (confirm('Вы уверены, что хотите удалить ВСЕ классы и всех учащихся из всех таблиц? Это действие невозможно отменить, кроме как через кнопку "Отменить".')) { quartersData.quarter1 = []; quartersData.quarter2 = []; quartersData.quarter3 = []; quartersData.quarter4 = []; quartersData.yearly = []; setCurrentClasses([]); pushHistory(); saveData(); autoSaveToFile(); renderTables(); updateAnalytics(); } } function editClass(id) { const classes = getCurrentClasses(); const classData = classes.find(c => c.id === id); if (classData) { openClassModal(classData); } } function openModal(classId, student = null) { const modal = document.getElementById('studentModal'); const title = document.getElementById('modalTitle'); if (!classId) { alert('Ошибка: класс не выбран'); console.error('classId is missing in openModal'); return; } const classes = getCurrentClasses(); const classData = classes.find(c => c.id === classId); if (!classData) { alert('Класс не найден'); console.error('Class not found with id:', classId); return; } if (!Array.isArray(classData.students)) { classData.students = []; } if (student) { title.textContent = 'Редактировать учащегося'; if (currentTable === 'yearly') { updateYearlyTableFromQuarters(); const updatedStudent = classData.students.find(s => s.id === student.id); if (updatedStudent) { fillModalForm(updatedStudent); } else { fillModalForm(student); } } else { fillModalForm(student); } modal.dataset.studentId = student.id; modal.dataset.classId = classId; } else { title.textContent = 'Добавить учащегося'; clearModalForm(); document.getElementById('modalClass').value = classData.name; delete modal.dataset.studentId; modal.dataset.classId = classId; if (currentTable === 'yearly') { document.getElementById('modalClass').readOnly = false; document.getElementById('modalClass').style.background = ''; document.getElementById('modalClass').style.cursor = ''; document.getElementById('modalFullName').readOnly = false; document.getElementById('modalFullName').style.background = ''; document.getElementById('modalFullName').style.cursor = ''; document.getElementById('modalInvalid').readOnly = true; document.getElementById('modalInvalid').style.background = 'rgba(128, 128, 128, 0.2)'; document.getElementById('modalInvalid').style.cursor = 'not-allowed'; reasons.forEach(reason => { const input = document.getElementById(`modal${capitalize(reason)}`); if (input) { input.readOnly = true; input.style.background = 'rgba(128, 128, 128, 0.2)'; input.style.cursor = 'not-allowed'; } }); const validationMsg = document.getElementById('validationMessage'); if (validationMsg) { validationMsg.textContent = 'В годовой таблице числовые данные автоматически суммируются из четвертей. Редактируйте данные в таблицах четвертей.'; validationMsg.className = 'validation-message'; validationMsg.style.color = 'var(--primary-teal-light)'; } } else { document.getElementById('modalClass').readOnly = true; document.getElementById('modalClass').style.background = 'rgba(128, 128, 128, 0.2)'; document.getElementById('modalClass').style.cursor = 'not-allowed'; document.getElementById('modalFullName').readOnly = true; document.getElementById('modalFullName').style.background = 'rgba(128, 128, 128, 0.2)'; document.getElementById('modalFullName').style.cursor = 'not-allowed'; document.getElementById('modalInvalid').readOnly = false; document.getElementById('modalInvalid').style.background = ''; document.getElementById('modalInvalid').style.cursor = ''; reasons.forEach(reason => { const input = document.getElementById(`modal${capitalize(reason)}`); if (input) { input.readOnly = false; input.style.background = ''; input.style.cursor = ''; } }); const validationMsg = document.getElementById('validationMessage'); if (validationMsg) { validationMsg.textContent = 'В четвертных таблицах можно редактировать только пропуски. ФИО и класс редактируются в годовой таблице.'; validationMsg.className = 'validation-message'; validationMsg.style.color = 'var(--primary-teal-light)'; } setTimeout(() => updateModalTotal(), 0); } } modal.classList.add('active'); } function closeModal() { const modal = document.getElementById('studentModal'); modal.classList.remove('active'); clearModalForm(); delete modal.dataset.classId; delete modal.dataset.studentId; } function clearModalForm() { const modalClass = document.getElementById('modalClass'); const modalFullName = document.getElementById('modalFullName'); const modalTotal = document.getElementById('modalTotal'); const modalInvalid = document.getElementById('modalInvalid'); if (modalClass) modalClass.value = ''; if (modalFullName) modalFullName.value = ''; if (modalTotal) modalTotal.value = ''; if (modalInvalid) { modalInvalid.value = '0'; modalInvalid.readOnly = false; modalInvalid.style.background = ''; modalInvalid.style.cursor = ''; } reasons.forEach(reason => { const input = document.getElementById(`modal${capitalize(reason)}`); if (input) { input.value = '0'; input.readOnly = false; input.style.background = ''; input.style.cursor = ''; } }); const validationMessage = document.getElementById('validationMessage'); if (validationMessage) { validationMessage.textContent = ''; validationMessage.className = 'validation-message'; validationMessage.style.color = ''; } } function fillModalForm(student) { const classes = getCurrentClasses(); const classData = classes.find(c => c.students.some(s => s.id === student.id)); if (classData) { document.getElementById('modalClass').value = classData.name; } document.getElementById('modalFullName').value = student.fullName; if (currentTable === 'yearly') { document.getElementById('modalInvalid').readOnly = true; document.getElementById('modalInvalid').style.background = 'rgba(128, 128, 128, 0.2)'; document.getElementById('modalInvalid').style.cursor = 'not-allowed'; document.getElementById('modalInvalid').value = student.invalid || 0; document.getElementById('modalTotal').value = student.total || 0; reasons.forEach(reason => { const input = document.getElementById(`modal${capitalize(reason)}`); if (input) { input.readOnly = true; input.style.background = 'rgba(128, 128, 128, 0.2)'; input.style.cursor = 'not-allowed'; input.value = student.reasons[reason] || 0; } }); const validationMsg = document.getElementById('validationMessage'); if (validationMsg) { validationMsg.textContent = 'В годовой таблице числовые данные автоматически суммируются из четвертей. Редактируйте данные в таблицах четвертей.'; validationMsg.className = 'validation-message'; validationMsg.style.color = 'var(--primary-teal-light)'; } } else { document.getElementById('modalClass').readOnly = true; document.getElementById('modalClass').style.background = 'rgba(128, 128, 128, 0.2)'; document.getElementById('modalClass').style.cursor = 'not-allowed'; document.getElementById('modalFullName').readOnly = true; document.getElementById('modalFullName').style.background = 'rgba(128, 128, 128, 0.2)'; document.getElementById('modalFullName').style.cursor = 'not-allowed'; document.getElementById('modalInvalid').readOnly = false; document.getElementById('modalInvalid').style.background = ''; document.getElementById('modalInvalid').style.cursor = ''; document.getElementById('modalInvalid').value = student.invalid || 0; reasons.forEach(reason => { const input = document.getElementById(`modal${capitalize(reason)}`); if (input) { input.readOnly = false; input.style.background = ''; input.style.cursor = ''; input.value = student.reasons[reason] || 0; } }); const validationMsg = document.getElementById('validationMessage'); if (validationMsg) { validationMsg.textContent = 'В четвертных таблицах можно редактировать только пропуски. ФИО и класс редактируются в годовой таблице.'; validationMsg.className = 'validation-message'; validationMsg.style.color = 'var(--primary-teal-light)'; } updateModalTotal(); } } function updateModalTotal() { let validTotal = 0; validReasons.forEach(reason => { const input = document.getElementById(`modal${capitalize(reason)}`); const value = parseInt(input ? input.value : '0') || 0; validTotal += value; }); const dovInput = document.getElementById('modalDov'); const virusInput = document.getElementById('modalVirus'); const dov = parseInt(dovInput ? dovInput.value : '0') || 0; const virus = parseInt(virusInput ? virusInput.value : '0') || 0; const other = dov + virus; const invalid = parseInt(document.getElementById('modalInvalid').value) || 0; const total = validTotal + invalid + other; document.getElementById('modalTotal').value = total; } function saveStudent() { const modal = document.getElementById('studentModal'); const classId = modal.dataset.classId; let studentId = modal.dataset.studentId; if (studentId === '' || studentId === 'undefined' || !studentId) { studentId = undefined; } if (!classId) { showValidation('Ошибка: класс не выбран', 'error'); console.error('classId is missing:', classId); return; } const classes = getCurrentClasses(); const classData = classes.find(c => c.id === classId); if (!classData) { showValidation('Класс не найден', 'error'); console.error('Class not found with id:', classId, 'Available classes:', classes); return; } if (!Array.isArray(classData.students)) { classData.students = []; } let fullName; if (currentTable === 'yearly') { fullName = document.getElementById('modalFullName').value.trim(); if (!fullName) { showValidation('Введите ФИО учащегося', 'error'); return; } const existingStudent = classData.students.find(s => s.fullName.toLowerCase() === fullName.toLowerCase() && s.id !== studentId ); if (existingStudent) { showValidation('Учащийся с таким ФИО уже существует в этом классе', 'error'); return; } } else { if (studentId) { const existingStudent = classData.students.find(s => s.id === studentId); if (existingStudent) { fullName = existingStudent.fullName; } else { showValidation('Учащийся не найден', 'error'); return; } } else { showValidation('Ошибка: не указан ID учащегося', 'error'); return; } } const invalid = parseInt(document.getElementById('modalInvalid').value) || 0; const reasonsData = {}; let validTotal = 0; let otherTotal = 0; reasons.forEach(reason => { const input = document.getElementById(`modal${capitalize(reason)}`); const value = parseInt(input ? input.value : '0') || 0; reasonsData[reason] = value; if (validReasons.includes(reason)) { validTotal += value; } if (reason === 'dov' || reason === 'virus') { otherTotal += value; } }); const isNewStudent = !studentId || studentId === ''; const newStudentId = isNewStudent ? `${Date.now()}-${Math.random().toString(36).substr(2, 9)}` : studentId; let studentData; if (currentTable === 'yearly') { studentData = { id: newStudentId, fullName: fullName, total: 0, invalid: 0, valid: 0, other: 0, reasons: {} }; } else { const total = validTotal + invalid + otherTotal; studentData = { id: newStudentId, fullName: fullName, total: total, invalid: invalid, valid: validTotal, other: otherTotal, reasons: reasonsData }; } if (!isNewStudent && studentId) { const studentIndex = classData.students.findIndex(s => s.id === studentId); if (studentIndex !== -1) { if (currentTable === 'yearly') { classData.students[studentIndex].fullName = fullName; } else { classData.students[studentIndex] = studentData; } } } else { if (!Array.isArray(classData.students)) { classData.students = []; } const alreadyExists = classData.students.some(s => s.id === studentData.id); if (!alreadyExists) { classData.students.push(studentData); } else { console.warn('Student already exists with this ID:', studentData.id); showValidation('Ошибка: ученик с таким ID уже существует', 'error'); return; } classData.expanded = true; } setCurrentClasses(classes); syncStudentAcrossTables(classId, studentData, isNewStudent); pushHistory(); saveData(); if (currentTable === 'yearly') { updateYearlyTableFromQuarters(); } if (currentTable !== 'yearly') { updateYearlyTableFromQuarters(); } autoSaveToFile(); renderTables(); showValidation('Данные успешно сохранены', 'success'); setTimeout(() => { closeModal(); updateAnalytics(); const msg = document.getElementById('validationMessage'); if (msg) msg.className = 'validation-message'; }, 500); } function syncStudentAcrossTables(classId, studentData, isNewStudent) { if (currentTable === 'yearly') { ['quarter1', 'quarter2', 'quarter3', 'quarter4'].forEach(quarter => { const quarterClasses = quartersData[quarter]; let quarterClass = quarterClasses.find(c => c.id === classId); if (!quarterClass) { const yearlyClass = quartersData.yearly.find(c => c.id === classId); if (yearlyClass) { quarterClass = { id: classId, name: yearlyClass.name, students: [] }; quarterClasses.push(quarterClass); } } if (quarterClass) { const existingStudent = quarterClass.students.find(s => s.id === studentData.id); if (!existingStudent) { if (!Array.isArray(quarterClass.students)) { quarterClass.students = []; } quarterClass.students.push({ id: studentData.id, fullName: studentData.fullName, total: 0, invalid: 0, valid: 0, other: 0, reasons: {} }); } else { existingStudent.fullName = studentData.fullName; } } }); } else if (currentTable !== 'yearly') { const yearlyClasses = quartersData.yearly; let yearlyClass = yearlyClasses.find(c => c.id === classId); if (!yearlyClass) { const currentClasses = getCurrentClasses(); const currentClass = currentClasses.find(c => c.id === classId); if (currentClass) { yearlyClass = { id: classId, name: currentClass.name, students: [] }; yearlyClasses.push(yearlyClass); } } if (yearlyClass) { if (!yearlyClass.students.find(s => s.id === studentData.id)) { if (!Array.isArray(yearlyClass.students)) { yearlyClass.students = []; } yearlyClass.students.push({ id: studentData.id, fullName: studentData.fullName, total: 0, invalid: 0, valid: 0, other: 0, reasons: {} }); } else { const yearlyStudent = yearlyClass.students.find(s => s.id === studentData.id); if (yearlyStudent) { yearlyStudent.fullName = studentData.fullName; } } } } } function updateYearlyTableFromQuarters() { const yearlyClasses = quartersData.yearly; const allClassIds = new Set(); ['quarter1', 'quarter2', 'quarter3', 'quarter4'].forEach(quarter => { quartersData[quarter].forEach(classData => { allClassIds.add(classData.id); }); }); yearlyClasses.forEach(classData => { allClassIds.add(classData.id); }); allClassIds.forEach(classId => { let className = ''; const quarter1Class = quartersData.quarter1.find(c => c.id === classId); const quarter2Class = quartersData.quarter2.find(c => c.id === classId); const quarter3Class = quartersData.quarter3.find(c => c.id === classId); const quarter4Class = quartersData.quarter4.find(c => c.id === classId); if (quarter1Class) className = quarter1Class.name; else if (quarter2Class) className = quarter2Class.name; else if (quarter3Class) className = quarter3Class.name; else if (quarter4Class) className = quarter4Class.name; let yearlyClass = yearlyClasses.find(c => c.id === classId); if (!yearlyClass) { yearlyClass = { id: classId, name: className, students: [] }; yearlyClasses.push(yearlyClass); } else { yearlyClass.name = className; } const allStudentIds = new Set(); [quarter1Class, quarter2Class, quarter3Class, quarter4Class].forEach(quarterClass => { if (quarterClass && Array.isArray(quarterClass.students)) { quarterClass.students.forEach(student => { allStudentIds.add(student.id); }); } }); if (yearlyClass && Array.isArray(yearlyClass.students)) { yearlyClass.students.forEach(student => { allStudentIds.add(student.id); }); } allStudentIds.forEach(studentId => { let totalTotal = 0; let totalValid = 0; let totalInvalid = 0; let totalOther = 0; const totalReasons = {}; let fullName = ''; reasons.forEach(reason => { totalReasons[reason] = 0; }); [quarter1Class, quarter2Class, quarter3Class, quarter4Class].forEach(quarterClass => { if (quarterClass && Array.isArray(quarterClass.students)) { const quarterStudent = quarterClass.students.find(s => s.id === studentId); if (quarterStudent) { if (!fullName && quarterStudent.fullName) { fullName = quarterStudent.fullName; } totalTotal += quarterStudent.total || 0; totalValid += quarterStudent.valid || 0; totalInvalid += quarterStudent.invalid || 0; totalOther += quarterStudent.other || 0; reasons.forEach(reason => { totalReasons[reason] += (quarterStudent.reasons && quarterStudent.reasons[reason]) || 0; }); } } }); let yearlyStudent = yearlyClass.students.find(s => s.id === studentId); if (!yearlyStudent) { yearlyStudent = { id: studentId, fullName: fullName, total: 0, invalid: 0, valid: 0, other: 0, reasons: {} }; yearlyClass.students.push(yearlyStudent); } yearlyStudent.total = totalTotal; yearlyStudent.valid = totalValid; yearlyStudent.invalid = totalInvalid; yearlyStudent.other = totalOther; yearlyStudent.reasons = totalReasons; if (fullName) { yearlyStudent.fullName = fullName; } else if (!yearlyStudent.fullName) { yearlyStudent.fullName = ''; } }); }); } function showValidation(message, type) { const msgEl = document.getElementById('validationMessage'); msgEl.textContent = message; msgEl.className = `validation-message ${type}`; } function deleteStudent(classId, studentId) { if (currentTable !== 'yearly') { alert('Удалять учеников можно только из годовой таблицы.'); return; } if (confirm('Вы уверены, что хотите удалить этого учащегося? Он будет удален из всех таблиц.')) { const classes = getCurrentClasses(); const classData = classes.find(c => c.id === classId); if (classData) { classData.students = classData.students.filter(s => s.id !== studentId); setCurrentClasses(classes); } ['quarter1', 'quarter2', 'quarter3', 'quarter4'].forEach(quarter => { const quarterClasses = quartersData[quarter]; const quarterClass = quarterClasses.find(c => c.id === classId); if (quarterClass) { quarterClass.students = quarterClass.students.filter(s => s.id !== studentId); } }); pushHistory(); saveData(); autoSaveToFile(); renderTables(); updateAnalytics(); } } function editStudent(classId, studentId) { const classes = getCurrentClasses(); const classData = classes.find(c => c.id === classId); if (classData) { const student = classData.students.find(s => s.id === studentId); if (student) { openModal(classId, student); } } } function renderTables() { renderMainTable(); } function renderMainTable() { const tbody = document.getElementById('mainTableBody'); tbody.innerHTML = ''; const classes = getCurrentClasses(); const isYearly = currentTable === 'yearly'; const isQuarter = !isYearly; if (classes.length === 0) { const message = isYearly ? 'Нет данных. Нажмите "Добавить класс" для начала работы.' : 'Нет данных. Добавьте классы и учеников в годовой таблице.'; tbody.innerHTML = `<tr><td colspan="22" style="text-align: center; padding: 40px; color: var(--text-secondary);">${message}</td></tr>`; return; } classes.forEach(classData => { // ★★★ АВТОМАТИЧЕСКАЯ СОРТИРОВКА УЧЕНИКОВ ПО АЛФАВИТУ ★★★ if (Array.isArray(classData.students)) { classData.students.sort((a, b) => a.fullName.localeCompare(b.fullName, 'ru')); } const classStats = calculateClassStats(classData); const students = Array.isArray(classData.students) ? classData.students : []; const studentCount = students.length; const isExpanded = classData.expanded || false; const classRow = document.createElement('tr'); classRow.className = `class-row ${isExpanded ? 'expanded' : ''}`; classRow.dataset.classId = classData.id; let actionButtons = ''; if (isYearly) { actionButtons = ` <button class="btn btn-edit" onclick="addStudentToClass('${classData.id}')"> <span class="material-icons">person_add</span> Добавить ученика </button> <button class="icon-btn" onclick="editClass('${classData.id}')" title="Редактировать класс"> <span class="material-icons">edit</span> </button> <button class="icon-btn icon-btn-danger" onclick="deleteClass('${classData.id}')" title="Удалить класс"> <span class="material-icons">delete</span> </button> `; } else { actionButtons = ''; } classRow.innerHTML = ` <td> <span class="material-icons expand-icon" style="cursor: pointer; user-select: none;">${isExpanded ? 'expand_less' : 'expand_more'}</span> </td> <td> <div class="class-info"> <strong>${escapeHtml(classData.name)}</strong> <div class="row-actions"> ${actionButtons} </div> </div> </td> <td> <span class="students-count">Учеников: ${studentCount}</span> </td> <td class="numeric-cell"><strong>${classStats.total}</strong></td> <td class="numeric-cell"><strong>${classStats.valid}</strong></td> <td class="numeric-cell"><strong>${classStats.invalid}</strong></td> <td class="numeric-cell"><strong>${classStats.event}</strong></td> <td class="numeric-cell"><strong>${classStats.olympiad}</strong></td> <td class="numeric-cell"><strong>${classStats.competition}</strong></td> <td class="numeric-cell"><strong>${classStats.doctor}</strong></td> <td class="numeric-cell"><strong>${classStats.military}</strong></td> <td class="numeric-cell"><strong>${classStats.sanatorium}</strong></td> <td class="numeric-cell"><strong>${classStats.training}</strong></td> <td class="numeric-cell"><strong>${classStats.camp}</strong></td> <td class="numeric-cell"><strong>${classStats.documents}</strong></td> <td class="numeric-cell"><strong>${classStats.kdn}</strong></td> <td class="numeric-cell"><strong>${classStats.frost}</strong></td> <td class="numeric-cell"><strong>${classStats.transport}</strong></td> <td class="numeric-cell"><strong>${classStats.diagnostic}</strong></td> <td class="numeric-cell"><strong>${classStats.medosmotr}</strong></td> <td class="numeric-cell"><strong>${classStats.dov}</strong></td> <td class="numeric-cell"><strong>${classStats.virus}</strong></td> `; tbody.appendChild(classRow); classRow.querySelector('.expand-icon').addEventListener('click', () => { toggleClassExpansion(classData.id); }); if (isExpanded && studentCount > 0) { students.forEach(student => { const studentRow = document.createElement('tr'); studentRow.className = 'students-row'; studentRow.innerHTML = ` <td></td> <td></td> <td> <div class="student-info"> <span class="student-name">${escapeHtml(student.fullName)}</span> <div class="row-actions"> <button class="icon-btn" onclick="editStudent('${classData.id}', '${student.id}')" title="Редактировать учащегося"> <span class="material-icons">edit</span> </button> ${isYearly ? ` <button class="icon-btn icon-btn-danger" onclick="deleteStudent('${classData.id}', '${student.id}')" title="Удалить учащегося"> <span class="material-icons">delete</span> </button> ` : ''} </div> </div> </td> <td class="numeric-cell">${student.total}</td> <td class="numeric-cell">${student.valid}</td> <td class="numeric-cell ${isYearly ? '' : 'numeric-cell--input'}"> ${isYearly ? `<span>${student.invalid}</span>` : `<input type="number" class="input-cell editable-invalid" value="${student.invalid}" min="0" data-field="invalid" data-class-id="${classData.id}" data-student-id="${student.id}">` } </td> <td class="numeric-cell">${(student.reasons && student.reasons.event) || 0}</td> <td class="numeric-cell">${(student.reasons && student.reasons.olympiad) || 0}</td> <td class="numeric-cell">${(student.reasons && student.reasons.competition) || 0}</td> <td class="numeric-cell">${(student.reasons && student.reasons.doctor) || 0}</td> <td class="numeric-cell">${(student.reasons && student.reasons.military) || 0}</td> <td class="numeric-cell">${(student.reasons && student.reasons.sanatorium) || 0}</td> <td class="numeric-cell">${(student.reasons && student.reasons.training) || 0}</td> <td class="numeric-cell">${(student.reasons && student.reasons.camp) || 0}</td> <td class="numeric-cell">${(student.reasons && student.reasons.documents) || 0}</td> <td class="numeric-cell">${(student.reasons && student.reasons.kdn) || 0}</td> <td class="numeric-cell">${(student.reasons && student.reasons.frost) || 0}</td> <td class="numeric-cell">${(student.reasons && student.reasons.transport) || 0}</td> <td class="numeric-cell">${(student.reasons && student.reasons.diagnostic) || 0}</td> <td class="numeric-cell">${(student.reasons && student.reasons.medosmotr) || 0}</td> <td class="numeric-cell">${(student.reasons && student.reasons.dov) || 0}</td> <td class="numeric-cell">${(student.reasons && student.reasons.virus) || 0}</td> `; tbody.appendChild(studentRow); }); } else if (isExpanded && studentCount === 0) { const emptyRow = document.createElement('tr'); emptyRow.className = 'students-row'; const emptyMessage = isYearly ? 'Нет учеников. Нажмите "Добавить ученика" для добавления.' : 'Нет учеников. Добавьте ученика в таблице "Годовая".'; emptyRow.innerHTML = ` <td></td> <td colspan="21" style="text-align: center; padding: 20px; color: var(--text-secondary);"> ${emptyMessage} </td> `; tbody.appendChild(emptyRow); } }); setupInlineEditors(); } function toggleClassExpansion(classId) { const classes = getCurrentClasses(); const classData = classes.find(c => c.id === classId); if (classData) { classData.expanded = !classData.expanded; setCurrentClasses(classes); saveData(); renderTables(); } } function addStudentToClass(classId) { if (currentTable !== 'yearly') { alert('Добавлять учеников можно только в годовой таблице. Переключитесь на годовую таблицу.'); return; } const classes = getCurrentClasses(); const classData = classes.find(c => c.id === classId); if (!classData) { alert('Ошибка: класс не найден'); console.error('Class not found:', classId); return; } openModal(classId); } function calculateClassStats(classData) { if (!classData || !Array.isArray(classData.students)) { return { total: 0, valid: 0, invalid: 0, event: 0, olympiad: 0, competition: 0, doctor: 0, military: 0, sanatorium: 0, training: 0, camp: 0, documents: 0, kdn: 0, frost: 0, transport: 0, diagnostic: 0, medosmotr: 0, dov: 0, virus: 0 }; } return classData.students.reduce((stats, student) => { stats.total += student.total || 0; stats.valid += student.valid || 0; stats.invalid += student.invalid || 0; const rs = student.reasons || {}; reasons.forEach(reason => { stats[reason] += rs[reason] || 0; }); return stats; }, { total: 0, valid: 0, invalid: 0, event: 0, olympiad: 0, competition: 0, doctor: 0, military: 0, sanatorium: 0, training: 0, camp: 0, documents: 0, kdn: 0, frost: 0, transport: 0, diagnostic: 0, medosmotr: 0, dov: 0, virus: 0 }); } function setupInlineEditors() { document.querySelectorAll('.editable-invalid').forEach(input => { input.addEventListener('change', function() { const classId = this.dataset.classId; const studentId = this.dataset.studentId; const newValue = parseInt(this.value) || 0; if (currentTable === 'yearly') { updateYearlyTableFromQuarters(); renderTables(); alert('В годовой таблице данные автоматически суммируются из четвертей. Редактируйте данные в таблицах четвертей.'); return; } const classes = getCurrentClasses(); const classData = classes.find(c => c.id === classId); if (classData) { const student = classData.students.find(s => s.id === studentId); if (student) { student.invalid = newValue; student.total = student.valid + student.other + newValue; setCurrentClasses(classes); updateYearlyTableFromQuarters(); pushHistory(); saveData(); autoSaveToFile(); renderTables(); updateAnalytics(); } } }); }); } function updateAnalytics() { updateStats(); if (chartUpdateTimeout) { clearTimeout(chartUpdateTimeout); } chartUpdateTimeout = setTimeout(() => { const analyticsTab = document.getElementById('analyticsTab'); if (analyticsTab && analyticsTab.classList.contains('active')) { requestAnimationFrame(() => { updateCharts(); }); } }, 300); } function updateStats() { const classes = getCurrentClasses(); const allStudents = classes.flatMap(c => c.students); const totalClasses = classes.length; const totalStudents = allStudents.length; const totalValid = allStudents.reduce((sum, s) => sum + (s.valid || 0), 0); const totalInvalid = allStudents.reduce((sum, s) => sum + (s.invalid || 0), 0); const totalAbsences = allStudents.reduce((sum, s) => sum + (s.total || 0), 0); const validPercentage = totalAbsences > 0 ? Math.round((totalValid / totalAbsences) * 100) : 0; const avgValid = totalStudents > 0 ? Math.round((totalValid / totalStudents) * 10) / 10 : 0; const avgInvalid = totalStudents > 0 ? Math.round((totalInvalid / totalStudents) * 10) / 10 : 0; const avgTotal = totalStudents > 0 ? Math.round((totalAbsences / totalStudents) * 10) / 10 : 0; const avgPerClass = totalClasses > 0 ? Math.round((totalStudents / totalClasses) * 10) / 10 : 0; const classStats = {}; classes.forEach(classData => { const stats = calculateClassStats(classData); classStats[classData.name] = stats; }); let maxAbsences = 0; let maxAbsencesClass = '—'; let minAbsences = Infinity; let minAbsencesClass = '—'; let maxPercentage = 0; Object.entries(classStats).forEach(([className, stats]) => { if (stats.total > maxAbsences) { maxAbsences = stats.total; maxAbsencesClass = className; } if (stats.total < minAbsences) { minAbsences = stats.total; minAbsencesClass = className; } const classPercentage = stats.total > 0 ? Math.round((stats.valid / stats.total) * 100) : 0; if (classPercentage > maxPercentage) { maxPercentage = classPercentage; } }); if (minAbsences === Infinity) minAbsences = 0; document.getElementById('totalStudents').textContent = totalClasses; document.getElementById('totalValidAbsences').textContent = totalValid; document.getElementById('totalInvalidAbsences').textContent = totalInvalid; document.getElementById('totalAbsences').textContent = totalAbsences; document.getElementById('validPercentage').textContent = validPercentage + '%'; document.getElementById('totalStudentsCount').textContent = totalStudents; document.getElementById('totalRecords').textContent = totalStudents; document.getElementById('avgValid').textContent = avgValid; document.getElementById('avgInvalid').textContent = avgInvalid; document.getElementById('avgTotal').textContent = avgTotal; document.getElementById('maxPercentage').textContent = maxPercentage + '%'; document.getElementById('avgPerClass').textContent = avgPerClass; document.getElementById('maxAbsences').textContent = maxAbsences; document.getElementById('maxAbsencesClass').textContent = maxAbsencesClass; document.getElementById('minAbsences').textContent = minAbsences; document.getElementById('minAbsencesClass').textContent = minAbsencesClass; } let chartUpdateTimeout = null; function updateCharts() { updateClassChart(); updateComparisonChart(); updateReasonsChart(); updateAverageChart(); updateTopClassesChart(); } // ★★★ ОБНОВЛЕННАЯ ФУНКЦИЯ ЭКСПОРТА В EXCEL ★★★ function exportToExcel() { const classes = getCurrentClasses(); if (classes.length === 0) { alert('Нет данных для экспорта'); return; } const tableNames = { quarter1: 'Первая четверть', quarter2: 'Вторая четверть', quarter3: 'Третья четверть', quarter4: 'Четвертая четверть', yearly: 'Годовая' }; try { const wb = XLSX.utils.book_new(); const quarterClasses = getCurrentClasses(); // Создаем основную таблицу с детализацией const mainTableData = [ ['Класс', 'Учащийся (ФИО)', 'Всего пропущено', 'Уважительные', 'Без причины', 'Мероприятия', 'Олимпиады', 'Соревнования', 'Врач', 'Военкомат', 'Санаторий', 'Сборы', 'Лагерь', 'Документы', 'КДН', 'Мороз', 'Подвоз', 'Диагностика', 'Медосмотр', 'Заявление род.', 'Болезнь'] ]; quarterClasses.forEach(classData => { if (!classData.students || classData.students.length === 0) { mainTableData.push([ classData.name, '-', 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 ]); } else { classData.students.forEach(student => { mainTableData.push([ classData.name, student.fullName, student.total || 0, student.valid || 0, student.invalid || 0, (student.reasons && student.reasons.event) || 0, (student.reasons && student.reasons.olympiad) || 0, (student.reasons && student.reasons.competition) || 0, (student.reasons && student.reasons.doctor) || 0, (student.reasons && student.reasons.military) || 0, (student.reasons && student.reasons.sanatorium) || 0, (student.reasons && student.reasons.training) || 0, (student.reasons && student.reasons.camp) || 0, (student.reasons && student.reasons.documents) || 0, (student.reasons && student.reasons.kdn) || 0, (student.reasons && student.reasons.frost) || 0, (student.reasons && student.reasons.transport) || 0, (student.reasons && student.reasons.diagnostic) || 0, (student.reasons && student.reasons.medosmotr) || 0, (student.reasons && student.reasons.dov) || 0, (student.reasons && student.reasons.virus) || 0 ]); }); } }); // Создаем сводную таблицу по классам const summaryTableData = [ ['Класс', 'Количество учеников', 'Всего пропусков', 'Уважительные', 'Без причины', 'Мероприятия', 'Олимпиады', 'Соревнования', 'Врач', 'Военкомат', 'Санаторий', 'Сборы', 'Лагерь', 'Документы', 'КДН', 'Мороз', 'Подвоз', 'Диагностика', 'Медосмотр', 'Заявление род.', 'Болезнь'] ]; quarterClasses.forEach(classData => { const stats = calculateClassStats(classData); summaryTableData.push([ classData.name, classData.students.length, stats.total, stats.valid, stats.invalid, stats.event, stats.olympiad, stats.competition, stats.doctor, stats.military, stats.sanatorium, stats.training, stats.camp, stats.documents, stats.kdn, stats.frost, stats.transport, stats.diagnostic, stats.medosmotr, stats.dov, stats.virus ]); }); // Создаем лист с детальной таблицей const ws1 = XLSX.utils.aoa_to_sheet(mainTableData); // Устанавливаем ширину столбцов ws1['!cols'] = [ { wch: 15 }, // Класс { wch: 30 }, // ФИО { wch: 18 }, // Всего пропущено { wch: 15 }, // Уважительные { wch: 15 }, // Без причины { wch: 12 }, // Мероприятия { wch: 12 }, // Олимпиады { wch: 12 }, // Соревнования { wch: 12 }, // Врач { wch: 12 }, // Военкомат { wch: 12 }, // Санаторий { wch: 12 }, // Сборы { wch: 12 }, // Лагерь { wch: 12 }, // Документы { wch: 10 }, // КДН { wch: 10 }, // Мороз { wch: 10 }, // Подвоз { wch: 12 }, // Диагностика { wch: 12 }, // Медосмотр { wch: 15 }, // Заявление род. { wch: 10 } // Болезнь ]; // Форматируем заголовки const headerRange = XLSX.utils.decode_range(ws1['!ref']); for (let C = headerRange.s.c; C <= headerRange.e.c; ++C) { const cellAddress = XLSX.utils.encode_cell({ r: 0, c: C }); if (!ws1[cellAddress]) continue; ws1[cellAddress].s = { font: { bold: true, color: { rgb: "FFFFFF" } }, fill: { fgColor: { rgb: "4A90E2" } }, alignment: { horizontal: "center", vertical: "center", wrapText: true } }; } // Добавляем лист с детальной таблицей XLSX.utils.book_append_sheet(wb, ws1, `Детализация - ${tableNames[currentTable] || currentTable}`); // Создаем лист со сводной таблицей const ws2 = XLSX.utils.aoa_to_sheet(summaryTableData); ws2['!cols'] = [ { wch: 15 }, // Класс { wch: 20 }, // Количество учеников { wch: 18 }, // Всего пропусков { wch: 15 }, // Уважительные { wch: 15 }, // Без причины { wch: 12 }, // Мероприятия { wch: 12 }, // Олимпиады { wch: 12 }, // Соревнования { wch: 12 }, // Врач { wch: 12 }, // Военкомат { wch: 12 }, // Санаторий { wch: 12 }, // Сборы { wch: 12 }, // Лагерь { wch: 12 }, // Документы { wch: 10 }, // КДН { wch: 10 }, // Мороз { wch: 10 }, // Подвоз { wch: 12 }, // Диагностика { wch: 12 }, // Медосмотр { wch: 15 }, // Заявление род. { wch: 10 } // Болезнь ]; // Форматируем заголовки сводной таблицы const headerRange2 = XLSX.utils.decode_range(ws2['!ref']); for (let C = headerRange2.s.c; C <= headerRange2.e.c; ++C) { const cellAddress = XLSX.utils.encode_cell({ r: 0, c: C }); if (!ws2[cellAddress]) continue; ws2[cellAddress].s = { font: { bold: true, color: { rgb: "FFFFFF" } }, fill: { fgColor: { rgb: "52C9A2" } }, alignment: { horizontal: "center", vertical: "center", wrapText: true } }; } XLSX.utils.book_append_sheet(wb, ws2, `Сводная - ${tableNames[currentTable] || currentTable}`); // Генерируем имя файла с текущей датой и названием таблицы const currentDate = new Date().toISOString().split('T')[0]; const tableName = tableNames[currentTable] || currentTable; const fileName = `пропуски_${tableName}_${currentDate}.xlsx`; // Сохраняем файл XLSX.writeFile(wb, fileName); console.log('Excel файл успешно экспортирован:', fileName); } catch (error) { console.error('Ошибка при экспорте в Excel:', error); alert('Ошибка при экспорте в Excel: ' + error.message); } } function updateClassChart() { const ctx = document.getElementById('classChart'); if (!ctx || !ctx.getContext) return; const classes = getCurrentClasses(); const classData = {}; classes.forEach(classDataItem => { const stats = calculateClassStats(classDataItem); classData[classDataItem.name] = stats; }); const classesNames = Object.keys(classData); const validData = classesNames.map(c => classData[c].valid); const invalidData = classesNames.map(c => classData[c].invalid); if (window.classChartInstance) { window.classChartInstance.destroy(); window.classChartInstance = null; } window.classChartInstance = new Chart(ctx, { type: 'bar', data: { labels: classesNames, datasets: [ { label: 'Уважительные', data: validData, backgroundColor: 'rgba(32, 178, 170, 0.7)', borderColor: 'rgba(32, 178, 170, 1)', borderWidth: 1 }, { label: 'Без причины', data: invalidData, backgroundColor: 'rgba(72, 209, 204, 0.7)', borderColor: 'rgba(72, 209, 204, 1)', borderWidth: 1 } ] }, options: { responsive: true, maintainAspectRatio: false, animation: { duration: 0 }, plugins: { legend: { position: 'top' }, title: { display: false } }, scales: { y: { beginAtZero: true } } } }); } function updateComparisonChart() { const ctx = document.getElementById('comparisonChart'); if (!ctx || !ctx.getContext) return; const classes = getCurrentClasses(); const allStudents = classes.flatMap(c => c.students); const totalValid = allStudents.reduce((sum, s) => sum + (s.valid || 0), 0); const totalInvalid = allStudents.reduce((sum, s) => sum + (s.invalid || 0), 0); if (window.comparisonChartInstance) { window.comparisonChartInstance.destroy(); window.comparisonChartInstance = null; } window.comparisonChartInstance = new Chart(ctx, { type: 'doughnut', data: { labels: ['Уважительные', 'Без причины'], datasets: [{ data: [totalValid, totalInvalid], backgroundColor: [ 'rgba(32, 178, 170, 0.7)', 'rgba(72, 209, 204, 0.7)' ], borderColor: [ 'rgba(32, 178, 170, 1)', 'rgba(72, 209, 204, 1)' ], borderWidth: 2 }] }, options: { responsive: true, maintainAspectRatio: false, animation: { duration: 0 }, plugins: { legend: { position: 'bottom' } } } }); } function updateReasonsChart() { const ctx = document.getElementById('reasonsChart'); if (!ctx || !ctx.getContext) return; const classes = getCurrentClasses(); const classData = {}; classes.forEach(classDataItem => { classData[classDataItem.name] = {}; reasons.forEach(reason => { classData[classDataItem.name][reason] = 0; }); classDataItem.students.forEach(student => { reasons.forEach(reason => { classData[classDataItem.name][reason] += (student.reasons[reason] || 0); }); }); }); const classesNames = Object.keys(classData).sort(); const labels = reasons.map(r => reasonLabels[r]); const datasets = classesNames.map((className, index) => { const colors = [ 'rgba(32, 178, 170, 0.7)', 'rgba(72, 209, 204, 0.7)', 'rgba(175, 238, 238, 0.7)', 'rgba(0, 139, 139, 0.7)', 'rgba(32, 178, 170, 0.5)', 'rgba(72, 209, 204, 0.5)' ]; const color = colors[index % colors.length]; return { label: className, data: reasons.map(r => classData[className][r] || 0), backgroundColor: color, borderColor: color.replace('0.7', '1').replace('0.5', '1'), borderWidth: 1 }; }); if (window.reasonsChartInstance) { window.reasonsChartInstance.destroy(); window.reasonsChartInstance = null; } window.reasonsChartInstance = new Chart(ctx, { type: 'bar', data: { labels: labels, datasets: datasets }, options: { responsive: true, maintainAspectRatio: false, animation: { duration: 0 }, plugins: { legend: { position: 'top' } }, scales: { x: { stacked: false }, y: { beginAtZero: true, stacked: false } } } }); } function updateAverageChart() { const ctx = document.getElementById('averageChart'); if (!ctx || !ctx.getContext) return; const classes = getCurrentClasses(); const classData = {}; classes.forEach(classDataItem => { const stats = calculateClassStats(classDataItem); const studentCount = classDataItem.students.length; classData[classDataItem.name] = { avgTotal: studentCount > 0 ? Math.round(stats.total / studentCount) : 0, avgValid: studentCount > 0 ? Math.round(stats.valid / studentCount) : 0, avgInvalid: studentCount > 0 ? Math.round(stats.invalid / studentCount) : 0 }; }); const classesNames = Object.keys(classData).sort(); const avgTotal = classesNames.map(c => classData[c].avgTotal); const avgValid = classesNames.map(c => classData[c].avgValid); const avgInvalid = classesNames.map(c => classData[c].avgInvalid); if (window.averageChartInstance) { window.averageChartInstance.destroy(); window.averageChartInstance = null; } window.averageChartInstance = new Chart(ctx, { type: 'line', data: { labels: classesNames, datasets: [ { label: 'Среднее всего', data: avgTotal, backgroundColor: 'rgba(32, 178, 170, 0.1)', borderColor: 'rgba(32, 178, 170, 1)', borderWidth: 3, fill: true, tension: 0.4 }, { label: 'Среднее уважительных', data: avgValid, backgroundColor: 'rgba(72, 209, 204, 0.1)', borderColor: 'rgba(72, 209, 204, 1)', borderWidth: 3, fill: true, tension: 0.4 }, { label: 'Среднее без причины', data: avgInvalid, backgroundColor: 'rgba(175, 238, 238, 0.1)', borderColor: 'rgba(175, 238, 238, 1)', borderWidth: 3, fill: true, tension: 0.4 } ] }, options: { responsive: true, maintainAspectRatio: false, animation: { duration: 0 }, plugins: { legend: { position: 'top' } }, scales: { y: { beginAtZero: true } } } }); } function updateTopClassesChart() { const ctx = document.getElementById('topClassesChart'); if (!ctx || !ctx.getContext) return; const classes = getCurrentClasses(); const classData = {}; classes.forEach(classDataItem => { const stats = calculateClassStats(classDataItem); classData[classDataItem.name] = stats; }); const sortedClasses = Object.entries(classData) .sort((a, b) => b[1].total - a[1].total) .slice(0, 8); const labels = sortedClasses.map(([cls]) => cls); const totalData = sortedClasses.map(([, data]) => data.total); const validData = sortedClasses.map(([, data]) => data.valid); const invalidData = sortedClasses.map(([, data]) => data.invalid); if (window.topClassesChartInstance) { window.topClassesChartInstance.destroy(); window.topClassesChartInstance = null; } window.topClassesChartInstance = new Chart(ctx, { type: 'bar', data: { labels: labels, datasets: [ { label: 'Всего', data: totalData, backgroundColor: 'rgba(32, 178, 170, 0.7)', borderColor: 'rgba(32, 178, 170, 1)', borderWidth: 1 }, { label: 'Уважительные', data: validData, backgroundColor: 'rgba(72, 209, 204, 0.7)', borderColor: 'rgba(72, 209, 204, 1)', borderWidth: 1 }, { label: 'Без причины', data: invalidData, backgroundColor: 'rgba(175, 238, 238, 0.7)', borderColor: 'rgba(175, 238, 238, 1)', borderWidth: 1 } ] }, options: { responsive: true, maintainAspectRatio: false, animation: { duration: 0 }, plugins: { legend: { position: 'top' } }, scales: { y: { beginAtZero: true } } } }); } // ===== ФУНКЦИИ ДЛЯ РАБОТЫ С ФАЙЛАМИ ===== let fileHandle = null; function initializeAutoSave() { updateFileButtons(); window.addEventListener('beforeunload', () => { if (fileHandle) { autoSaveToFile().catch(() => {}); } }); setInterval(() => { if (fileHandle) { autoSaveToFile(); } }, 30000); if (!localStorage.getItem('autoSaveFileSelected') && !localStorage.getItem('autoSaveHintShown')) { setTimeout(() => { if (confirm('Для автоматического сохранения и загрузки данных на компьютер нажмите "Выбрать файл данных" в верхней панели. Хотите выбрать файл сейчас?')) { selectAutoSaveFile(); } localStorage.setItem('autoSaveHintShown', 'true'); }, 2000); } } async function autoSaveToFile() { try { if (fileHandle) { const writable = await fileHandle.createWritable(); const dataStr = JSON.stringify(quartersData, null, 2); await writable.write(dataStr); await writable.close(); console.log('Данные автоматически сохранены в файл'); } } catch (error) { console.error('Ошибка автосохранения:', error); } } function saveToFile() { const dataStr = JSON.stringify(quartersData, null, 2); const dataBlob = new Blob([dataStr], { type: 'application/json' }); const url = URL.createObjectURL(dataBlob); const link = document.createElement('a'); link.href = url; link.download = 'absence_data.json'; link.style.display = 'none'; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); } async function selectAutoSaveFile() { try { if ('showOpenFilePicker' in window) { const options = { suggestedName: 'absence_data.json', types: [{ description: 'JSON файлы', accept: { 'application/json': ['.json'] } }] }; fileHandle = await window.showSaveFilePicker(options); localStorage.setItem('autoSaveFileSelected', 'true'); try { const file = await fileHandle.getFile(); const text = await file.text(); if (text.trim()) { const loadedData = JSON.parse(text); if (Array.isArray(loadedData)) { if (confirm('В выбранном файле есть данные. Загрузить их сейчас? (Текущие данные будут заменены)')) { await loadDataFromFileContent(loadedData); } else { await autoSaveToFile(); } } } else { await autoSaveToFile(); } } catch (loadError) { await autoSaveToFile(); } updateFileButtons(); alert('Файл выбран. Данные будут автоматически сохраняться в этот файл при каждом изменении. Используйте кнопку "Загрузить из файла" для загрузки данных.'); } else { saveToFile(); alert('Ваш браузер не поддерживает прямой доступ к файлам. Используйте функцию "Импорт" для загрузки данных и регулярно экспортируйте в Excel для резервного копирования.'); } } catch (error) { if (error.name !== 'AbortError') { console.error('Ошибка выбора файла:', error); alert('Не удалось выбрать файл. Данные сохраняются в браузере (localStorage).'); } } } async function loadFromAutoSaveFile() { if (!fileHandle) { alert('Сначала выберите файл данных через кнопку "Выбрать файл данных"'); return; } try { const file = await fileHandle.getFile(); const text = await file.text(); if (!text.trim()) { alert('Файл пуст'); return; } const loadedData = JSON.parse(text); await loadDataFromFileContent(loadedData); } catch (error) { console.error('Ошибка загрузки из файла:', error); alert('Ошибка при загрузке данных из файла: ' + error.message); } } async function loadDataFromFileContent(loadedData) { try { if (loadedData.quarter1 !== undefined || loadedData.quarter2 !== undefined) { quartersData = loadedData; if (!quartersData.quarter1) quartersData.quarter1 = []; if (!quartersData.quarter2) quartersData.quarter2 = []; if (!quartersData.quarter3) quartersData.quarter3 = []; if (!quartersData.quarter4) quartersData.quarter4 = []; if (!quartersData.yearly) quartersData.yearly = []; } else if (Array.isArray(loadedData)) { if (loadedData.length > 0 && loadedData[0].students !== undefined) { quartersData.quarter1 = loadedData; } else { quartersData.quarter1 = loadedData.map((item, index) => ({ id: item.id || `${Date.now()}-${index}-${Math.random().toString(36).substr(2, 9)}`, name: item.class || 'Неизвестный класс', students: [{ id: item.id || `${Date.now()}-${index}-student-${Math.random().toString(36).substr(2, 9)}`, fullName: item.name || 'Учащийся', total: item.total || 0, invalid: item.invalid || 0, valid: item.valid || 0, reasons: item.reasons || {} }] })); } } else { alert('Неверный формат данных в файле'); return; } Object.keys(quartersData).forEach(key => { quartersData[key].forEach(classData => { if (!Array.isArray(classData.students)) { classData.students = []; } }); }); saveData(); renderTables(); updateAnalytics(); alert('Данные успешно загружены из файла!'); } catch (error) { console.error('Ошибка обработки данных:', error); alert('Ошибка при обработке данных: ' + error.message); } } function updateFileButtons() { const selectBtn = document.getElementById('selectSaveFileBtn'); const loadBtn = document.getElementById('loadFromFileBtn'); if (fileHandle) { selectBtn.innerHTML = '<span class="material-icons">check_circle</span> Файл выбран'; selectBtn.style.background = 'linear-gradient(135deg, var(--primary-teal) 0%, var(--primary-teal-dark) 100%)'; if (loadBtn) loadBtn.style.display = 'inline-flex'; } else { selectBtn.innerHTML = '<span class="material-icons">folder</span> Выбрать файл данных'; selectBtn.style.background = ''; if (loadBtn) loadBtn.style.display = 'none'; } } function saveData() { try { const dataToSave = JSON.stringify(quartersData, null, 2); localStorage.setItem('absenceTracker', dataToSave); } catch (error) { console.error('Error saving data:', error); } } function loadData() { const saved = localStorage.getItem('absenceTracker'); if (saved) { try { const data = JSON.parse(saved); if (data.quarter1 !== undefined || data.quarter2 !== undefined) { quartersData = data; if (!quartersData.quarter1) quartersData.quarter1 = []; if (!quartersData.quarter2) quartersData.quarter2 = []; if (!quartersData.quarter3) quartersData.quarter3 = []; if (!quartersData.quarter4) quartersData.quarter4 = []; if (!quartersData.yearly) quartersData.yearly = []; } else if (Array.isArray(data)) { if (data.length > 0 && !data[0].students) { quartersData.quarter1 = data.map(item => ({ id: item.id || Date.now().toString() + Math.random(), name: item.class || 'Неизвестный класс', students: [{ id: item.id || Date.now().toString(), fullName: item.name || 'Учащийся', total: item.total || 0, invalid: item.invalid || 0, valid: item.valid || 0, reasons: item.reasons || {} }] })); } else { quartersData.quarter1 = data; } } else { quartersData = { quarter1: [], quarter2: [], quarter3: [], quarter4: [], yearly: [] }; } Object.keys(quartersData).forEach(key => { quartersData[key].forEach(classData => { if (!Array.isArray(classData.students)) { classData.students = []; } classData.students.forEach(student => { if (!student.reasons) { student.reasons = {}; } let validTotal = 0; let otherTotal = 0; reasons.forEach(reason => { const value = student.reasons[reason] || 0; if (validReasons.includes(reason)) { validTotal += value; } if (reason === 'dov' || reason === 'virus') { otherTotal += value; } }); const invalid = student.invalid || 0; student.valid = validTotal; student.other = otherTotal; student.total = validTotal + invalid + otherTotal; }); }); }); saveData(); } catch (e) { console.error('Ошибка загрузки данных:', e); quartersData = { quarter1: [], quarter2: [], quarter3: [], quarter4: [], yearly: [] }; } } try { const snapshot = JSON.stringify(quartersData); history = [snapshot]; historyIndex = 0; } catch (e) { history = []; historyIndex = -1; console.error('Ошибка инициализации истории:', e); } updateHistoryButtons(); } function importData(event) { const file = event.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = (e) => { try { const imported = JSON.parse(e.target.result); if (imported.quarter1 !== undefined || imported.quarter2 !== undefined) { quartersData = imported; if (!quartersData.quarter1) quartersData.quarter1 = []; if (!quartersData.quarter2) quartersData.quarter2 = []; if (!quartersData.quarter3) quartersData.quarter3 = []; if (!quartersData.quarter4) quartersData.quarter4 = []; if (!quartersData.yearly) quartersData.yearly = []; } else if (Array.isArray(imported)) { if (imported.length > 0 && imported[0].students !== undefined) { quartersData.quarter1 = imported; } else { quartersData.quarter1 = imported.map((item, index) => ({ id: item.id || `${Date.now()}-${index}-${Math.random().toString(36).substr(2, 9)}`, name: item.class || 'Неизвестный класс', students: [{ id: item.id || `${Date.now()}-${index}-student-${Math.random().toString(36).substr(2, 9)}`, fullName: item.name || 'Учащийся', total: item.total || 0, invalid: item.invalid || 0, valid: item.valid || 0, reasons: item.reasons || {} }] })); } } else { alert('Неверный формат файла'); return; } Object.keys(quartersData).forEach(key => { quartersData[key].forEach(classData => { if (!Array.isArray(classData.students)) { classData.students = []; } }); }); saveData(); autoSaveToFile(); renderTables(); updateAnalytics(); alert('Данные успешно импортированы!'); } catch (error) { alert('Ошибка при импорте данных: ' + error.message); } }; reader.readAsText(file); event.target.value = ''; } // ===== ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ ===== function capitalize(str) { const specialCases = { 'kdn': 'KDN' }; if (specialCases[str]) { return specialCases[str]; } return str.charAt(0).toUpperCase() + str.slice(1); } function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } // Глобальные функции для вызова из HTML window.editClass = editClass; window.deleteClass = deleteClass; window.addStudentToClass = addStudentToClass; window.editStudent = editStudent; window.deleteStudent = deleteStudent; window.undo = undo; window.redo = redo;