import { api, getSession } from '../api.js'; import { escapeHtml, hideAlert, showAlert } from '../utils.js'; const MANAGER_ROLES = new Set(['ADMIN', 'EDUCATION_OFFICE', 'DEPARTMENT']); export async function initTeacherAbsences() { const session = getSession(); const role = session?.role; if (!MANAGER_ROLES.has(role)) return; const form = document.getElementById('teacher-absence-form'); const teacherSelect = document.getElementById('teacher-absence-teacher'); const startInput = document.getElementById('teacher-absence-start'); const endInput = document.getElementById('teacher-absence-end'); const reasonInput = document.getElementById('teacher-absence-reason'); const statusFilter = document.getElementById('teacher-absence-status-filter'); const registry = document.getElementById('teacher-absence-registry'); const overlay = document.getElementById('absence-wizard-overlay'); const wizardLessons = document.getElementById('absence-wizard-lessons'); let absences = []; let currentWizard = null; setDefaultPeriod(startInput, endInput); form?.addEventListener('submit', createAbsence); statusFilter?.addEventListener('change', loadAbsences); document.getElementById('teacher-absence-refresh')?.addEventListener('click', loadAbsences); document.getElementById('absence-wizard-close')?.addEventListener('click', closeWizard); document.getElementById('absence-wizard-cancel')?.addEventListener('click', closeWizard); document.getElementById('absence-wizard-apply')?.addEventListener('click', applyWizard); overlay?.addEventListener('click', event => { if (event.target === overlay) closeWizard(); }); registry?.addEventListener('click', handleRegistryAction); await Promise.all([loadTeachers(), loadAbsences()]); async function loadTeachers() { try { let teachers = await api.get('/api/users/teachers'); if (role === 'DEPARTMENT' && session?.departmentId) { teachers = teachers.filter(item => String(item.departmentId) === String(session.departmentId)); } teacherSelect.innerHTML = '' + teachers.map(item => ` `).join(''); } catch (error) { teacherSelect.innerHTML = ``; } } async function loadAbsences() { registry.innerHTML = '
Загрузка...
'; try { const params = statusFilter.value ? `?status=${encodeURIComponent(statusFilter.value)}` : ''; absences = await api.get(`/api/teacher-absences${params}`); renderRegistry(); updateStats(absences); } catch (error) { registry.innerHTML = `
${escapeHtml(error.message || 'Не удалось загрузить отсутствия')}
`; } } async function createAbsence(event) { event.preventDefault(); hideAlert('teacher-absence-form-alert'); if (!teacherSelect.value || !startInput.value || !endInput.value || !reasonInput.value.trim()) { showAlert('teacher-absence-form-alert', 'Заполните преподавателя, период и причину', 'error'); return; } if (endInput.value < startInput.value) { showAlert('teacher-absence-form-alert', 'Дата окончания не может быть раньше даты начала', 'error'); return; } try { await api.post('/api/teacher-absences', { teacherId: Number(teacherSelect.value), startDate: startInput.value, endDate: endInput.value, reason: reasonInput.value.trim() }); showAlert('teacher-absence-form-alert', 'Отсутствие зарегистрировано', 'success'); reasonInput.value = ''; await loadAbsences(); } catch (error) { showAlert('teacher-absence-form-alert', error.message || 'Не удалось зарегистрировать отсутствие', 'error'); } } function renderRegistry() { if (!absences.length) { registry.innerHTML = '
Инцидентов не найденоИзмените фильтр или зарегистрируйте отсутствие.
'; return; } registry.innerHTML = absences.map(item => `
${escapeHtml(dayOfMonth(item.startDate))} ${escapeHtml(monthShort(item.startDate))} ${escapeHtml(dayOfMonth(item.endDate))} ${escapeHtml(monthShort(item.endDate))}

${escapeHtml(item.teacherName || 'Преподаватель не указан')}

${escapeHtml(item.reason || 'Причина не указана')}

${escapeHtml(statusLabel(item.status))}
${escapeHtml(formatDate(item.startDate))} — ${escapeHtml(formatDate(item.endDate))} ${escapeHtml(decisionCountLabel(item.decisionCount || 0))} ${item.reviewComment ? `${escapeHtml(item.reviewComment)}` : ''}
${item.status === 'PENDING' ? ` ` : ''} ${['APPROVED', 'RESOLVED'].includes(item.status) ? ` ` : ''} ${['PENDING', 'APPROVED'].includes(item.status) ? ` ` : ''}
`).join(''); } async function handleRegistryAction(event) { const button = event.target.closest('[data-action][data-id]'); if (!button) return; const id = Number(button.dataset.id); button.disabled = true; try { if (button.dataset.action === 'approve' || button.dataset.action === 'reject') { await api.post(`/api/teacher-absences/${id}/review`, { approved: button.dataset.action === 'approve', comment: button.dataset.action === 'approve' ? 'Подтверждено кафедрой или ответственным сотрудником' : 'Заявка отклонена ответственным сотрудником' }); await loadAbsences(); } else if (button.dataset.action === 'cancel') { await api.delete(`/api/teacher-absences/${id}`); await loadAbsences(); } else if (button.dataset.action === 'wizard') { await openWizard(id); } } catch (error) { window.alert(error.message || 'Не удалось выполнить действие'); } finally { button.disabled = false; } } async function openWizard(id) { overlay.hidden = false; document.body.classList.add('absence-wizard-open'); wizardLessons.innerHTML = '
Подбираем свободные ресурсы...
'; hideAlert('absence-wizard-alert'); try { currentWizard = await api.get(`/api/teacher-absences/${id}/wizard`); renderWizard(); } catch (error) { showAlert('absence-wizard-alert', error.message || 'Не удалось открыть мастер замены', 'error'); } } function closeWizard() { overlay.hidden = true; document.body.classList.remove('absence-wizard-open'); currentWizard = null; } function renderWizard() { const absence = currentWizard.absence; document.getElementById('absence-wizard-title').textContent = absence.teacherName || 'Мастер замены'; document.getElementById('absence-wizard-summary').textContent = `${formatDate(absence.startDate)} — ${formatDate(absence.endDate)} · ${absence.reason}`; const applyButton = document.getElementById('absence-wizard-apply'); applyButton.hidden = absence.status !== 'APPROVED'; wizardLessons.innerHTML = currentWizard.lessons.length ? currentWizard.lessons.map((lesson, index) => renderLessonDecision(lesson, index, absence.status)).join('') : '
Затронутых занятий нетИнцидент можно завершить без точечных изменений.
'; renderJournal(currentWizard.journal || []); } function renderLessonDecision(lesson, index, absenceStatus) { const options = decisionOptions(lesson); const locked = absenceStatus !== 'APPROVED' || Boolean(lesson.decisionStatus); return `
${escapeHtml(trimTime(lesson.startTime))} ${escapeHtml(trimTime(lesson.endTime))} ${escapeHtml(formatDate(lesson.lessonDate))}
${escapeHtml(lesson.lessonTypeName || 'Занятие')}

${escapeHtml(lesson.subjectName || 'Дисциплина не указана')}

${escapeHtml((lesson.groupNames || []).join(', ') || 'Группы не указаны')} · ${escapeHtml(lesson.classroomName || 'Без аудитории')}

${lesson.existingOverrideId ? 'У пары уже есть точечное изменение — доступна только запись отклонения.' : ''}
${lesson.decisionStatus ? `${escapeHtml(decisionStatusLabel(lesson.decisionStatus))}` : ''}
`; } async function applyWizard() { if (!currentWizard) return; const decisions = Array.from(wizardLessons.querySelectorAll('.absence-lesson')).map(card => { const value = card.querySelector('.absence-decision-select')?.value; if (!value) return null; return { baseRuleSlotId: Number(card.dataset.slotId), lessonDate: card.dataset.date, ...parseDecisionOption(value) }; }).filter(Boolean); if (!decisions.length && currentWizard.lessons.length) { showAlert('absence-wizard-alert', 'Выберите хотя бы одно решение', 'error'); return; } const button = document.getElementById('absence-wizard-apply'); button.disabled = true; hideAlert('absence-wizard-alert'); try { currentWizard = await api.post(`/api/teacher-absences/${currentWizard.absence.id}/resolve`, { decisions }); renderWizard(); showAlert('absence-wizard-alert', 'Выбранные решения применены к расписанию', 'success'); await loadAbsences(); } catch (error) { showAlert('absence-wizard-alert', error.message || 'Не удалось применить решения', 'error'); } finally { button.disabled = false; } } } export function decisionOptions(lesson) { const options = []; (lesson.teacherCandidates || []).forEach(candidate => options.push({ value: `REPLACE_TEACHER|${candidate.id}`, label: `Заменить: ${candidate.name}` })); (lesson.moveCandidates || []).forEach(candidate => options.push({ value: `MOVE_TIME|${candidate.targetDate}|${candidate.timeSlotId}`, label: `Перенести: ${formatDate(candidate.targetDate)}, ${trimTime(candidate.startTime)}–${trimTime(candidate.endTime)}` })); (lesson.classroomCandidates || []).forEach(candidate => options.push({ value: `CHANGE_CLASSROOM|${candidate.id}`, label: `Другая аудитория: ${candidate.name}` })); if (lesson.cancellationAvailable) { options.push({ value: 'CANCEL', label: 'Отменить занятие' }); } options.push({ value: 'REJECT', label: 'Отклонить предложения' }); return options; } export function parseDecisionOption(value) { const [resolution, first, second] = String(value || '').split('|'); if (resolution === 'REPLACE_TEACHER') return { resolution, newTeacherId: Number(first) }; if (resolution === 'MOVE_TIME') { return { resolution, targetLessonDate: first, newTimeSlotId: Number(second) }; } if (resolution === 'CHANGE_CLASSROOM') return { resolution, newClassroomId: Number(first) }; if (resolution === 'CANCEL' || resolution === 'REJECT') return { resolution }; throw new Error('Неизвестное решение мастера замены'); } function renderJournal(journal) { document.getElementById('absence-journal-count').textContent = decisionCountLabel(journal.length); document.getElementById('absence-journal-list').innerHTML = journal.length ? journal.map(item => `
${escapeHtml(resolutionLabel(item.resolution))}${escapeHtml(formatDate(item.lessonDate))}
${escapeHtml(decisionStatusLabel(item.status))}${item.scheduleOverrideId ? ` · override №${escapeHtml(item.scheduleOverrideId)}` : ''}
`).join('') : '
Решений пока нет
'; } function updateStats(items) { document.getElementById('absence-stat-active').textContent = items.filter(item => item.status === 'APPROVED').length; document.getElementById('absence-stat-pending').textContent = items.filter(item => item.status === 'PENDING').length; document.getElementById('absence-stat-resolved').textContent = items.filter(item => item.status === 'RESOLVED').length; } function setDefaultPeriod(startInput, endInput) { const today = new Date(); startInput.value = isoDate(today); endInput.value = isoDate(today); } function isoDate(date) { return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; } function formatDate(value) { if (!value) return '—'; const [year, month, day] = String(value).split('-'); return `${day}.${month}.${year}`; } function dayOfMonth(value) { return String(value || '').split('-')[2] || '—'; } function monthShort(value) { const month = Number(String(value || '').split('-')[1]); return ['янв', 'фев', 'мар', 'апр', 'май', 'июн', 'июл', 'авг', 'сен', 'окт', 'ноя', 'дек'][month - 1] || ''; } function trimTime(value) { return String(value || '').slice(0, 5); } function statusLabel(status) { return { PENDING: 'На подтверждении', APPROVED: 'Требует решения', REJECTED: 'Отклонено', RESOLVED: 'Разрешено', CANCELLED: 'Отменено' }[status] || status || '—'; } function statusClass(status) { return `is-${String(status || '').toLowerCase()}`; } function resolutionLabel(resolution) { return { REPLACE_TEACHER: 'Замена преподавателя', MOVE_TIME: 'Перенос занятия', CHANGE_CLASSROOM: 'Замена аудитории', CANCEL: 'Отмена занятия', REJECT: 'Предложение отклонено' }[resolution] || resolution || 'Решение'; } function decisionStatusLabel(status) { return status === 'APPLIED' ? 'Применено' : status === 'REJECTED' ? 'Отклонено' : status || '—'; } function decisionCountLabel(count) { const value = Number(count) || 0; const tail = value % 100; if (tail >= 11 && tail <= 14) return `${value} решений`; if (value % 10 === 1) return `${value} решение`; if ([2, 3, 4].includes(value % 10)) return `${value} решения`; return `${value} решений`; }