import { api, getSession } from '../api.js'; import { escapeHtml, hideAlert, showAlert } from '../utils.js'; import { alertAction } from '../dialog.js'; import { AsyncCombobox } from '../async-combobox.js'; const MANAGER_ROLES = new Set(['ADMIN', 'EDUCATION_OFFICE', 'DEPARTMENT']); export function teacherAbsenceTeacherOptionsUrl({ query = '', selectedId = '', role = '', departmentId = '' } = {}) { const params = new URLSearchParams({ query, limit: '30' }); if (role === 'DEPARTMENT' && departmentId) params.set('departmentId', departmentId); if (selectedId) params.set('selectedId', selectedId); return `/api/users/teachers/options?${params}`; } 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'); const preferenceSemesterFilter = document.getElementById('teacher-preference-semester-filter'); const preferenceStatusFilter = document.getElementById('teacher-preference-status-filter'); const preferenceReviewList = document.getElementById('teacher-preference-review-list'); const changeStatusFilter = document.getElementById('teacher-change-status-filter'); const changeReviewList = document.getElementById('teacher-change-review-list'); let absences = []; let currentWizard = null; let preferenceItems = []; let changeItems = []; new AsyncCombobox(teacherSelect, { placeholder: 'Найдите преподавателя по ФИО', optionLabel: teacher => teacher.fullName || teacher.username || `ID ${teacher.id}`, optionDescription: teacher => [teacher.jobTitle, teacher.departmentName].filter(Boolean).join(' · '), load: async (query, selectedId) => { return api.get(teacherAbsenceTeacherOptionsUrl({ query, selectedId, role, departmentId: session?.departmentId })); } }); 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); preferenceSemesterFilter?.addEventListener('change', loadPreferenceReviews); preferenceStatusFilter?.addEventListener('change', loadPreferenceReviews); document.getElementById('teacher-preference-refresh')?.addEventListener('click', loadPreferenceReviews); preferenceReviewList?.addEventListener('click', handlePreferenceReview); changeStatusFilter?.addEventListener('change', loadChangeReviews); document.getElementById('teacher-change-refresh')?.addEventListener('click', loadChangeReviews); changeReviewList?.addEventListener('click', handleChangeReview); await Promise.all([loadAbsences(), loadPreferenceMeta(), loadChangeReviews()]); 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) { await alertAction({ title: 'Действие не выполнено', message: 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; } } async function loadPreferenceMeta() { preferenceReviewList.innerHTML = '
Загрузка семестров...
'; try { const meta = await api.get('/api/teacher-preferences/meta'); preferenceSemesterFilter.innerHTML = '' + (meta.semesters || []).map(item => ` `).join(''); const today = isoDate(new Date()); const current = (meta.semesters || []).find(item => item.startDate <= today && item.endDate >= today); if (current) preferenceSemesterFilter.value = String(current.id); await loadPreferenceReviews(); } catch (error) { preferenceReviewList.innerHTML = `
${escapeHtml(error.message || 'Не удалось загрузить семестры')}
`; } } async function loadPreferenceReviews() { preferenceReviewList.innerHTML = '
Загрузка пожеланий...
'; const params = new URLSearchParams(); if (preferenceSemesterFilter.value) params.set('semesterId', preferenceSemesterFilter.value); if (preferenceStatusFilter.value) params.set('status', preferenceStatusFilter.value); try { preferenceItems = await api.get(`/api/teacher-preferences${params.size ? `?${params}` : ''}`); document.getElementById('teacher-preference-pending-count').textContent = String( preferenceItems.filter(item => item.status === 'PENDING').length ); renderPreferenceReviews(); } catch (error) { preferenceReviewList.innerHTML = `
${escapeHtml(error.message || 'Не удалось загрузить пожелания')}
`; } } function renderPreferenceReviews() { preferenceReviewList.innerHTML = preferenceItems.length ? preferenceItems.map(item => `
${escapeHtml(item.academicYearTitle)} · ${escapeHtml(item.semesterType === 'AUTUMN' ? 'осенний семестр' : 'весенний семестр')}

${escapeHtml(item.teacherName)}

${escapeHtml(preferenceDescription(item))}

${item.comment ? `
${escapeHtml(item.comment)}
` : ''} ${item.reviewComment ? `Решение: ${escapeHtml(item.reviewComment)}` : ''}
${escapeHtml(genericStatusLabel(item.status))} ${item.status === 'PENDING' ? `
` : ''}
`).join('') : '
Пожеланий не найденоИзмените фильтры или дождитесь новых отметок преподавателей.
'; } async function handlePreferenceReview(event) { const button = event.target.closest('[data-preference-review][data-id]'); if (!button) return; const card = button.closest('.teacher-dialogue-item'); const approved = button.dataset.preferenceReview === 'approve'; const comment = card.querySelector('.teacher-dialogue-comment')?.value.trim() || ''; if (!approved && !comment) { await alertAction({ title: 'Нужен комментарий', message: 'При отклонении укажите комментарий.' }); return; } button.disabled = true; try { await api.post(`/api/teacher-preferences/${button.dataset.id}/review`, { approved, comment: comment || 'Согласовано ответственным сотрудником' }); await loadPreferenceReviews(); } catch (error) { await alertAction({ title: 'Ошибка рассмотрения', message: error.message || 'Не удалось рассмотреть пожелание' }); button.disabled = false; } } async function loadChangeReviews() { changeReviewList.innerHTML = '
Загрузка заявок...
'; const params = changeStatusFilter.value ? `?status=${encodeURIComponent(changeStatusFilter.value)}` : ''; try { changeItems = await api.get(`/api/teacher-change-requests${params}`); document.getElementById('teacher-change-pending-count').textContent = String( changeItems.filter(item => item.status === 'PENDING').length ); renderChangeReviews(); } catch (error) { changeReviewList.innerHTML = `
${escapeHtml(error.message || 'Не удалось загрузить заявки')}
`; } } function renderChangeReviews() { const canApply = role === 'ADMIN' || role === 'EDUCATION_OFFICE'; changeReviewList.innerHTML = changeItems.length ? changeItems.map(item => { const target = item.requestType === 'MOVE' ? `${formatDate(item.targetLessonDate || item.lessonDate)} · ${trimTime(item.requestedStartTime)}–${trimTime(item.requestedEndTime)}` : item.requestType === 'CHANGE_CLASSROOM' ? item.requestedClassroomName : 'Отмена занятия'; return `
${formatDate(item.lessonDate)} · ${trimTime(item.sourceStartTime)}–${trimTime(item.sourceEndTime)} · ${escapeHtml(item.sourceClassroomName)}

${escapeHtml(item.teacherName)} · ${escapeHtml(item.subjectName)}

${escapeHtml(changeTypeLabel(item.requestType))} → ${escapeHtml(target)}

${escapeHtml(item.reason)}
${item.reviewComment ? `Решение: ${escapeHtml(item.reviewComment)}` : ''}
История · ${(item.history || []).length}
    ${(item.history || []).map(entry => `
  1. ${genericStatusLabel(entry.status)} · ${escapeHtml(entry.comment || 'Без комментария')}
  2. `).join('')}
${escapeHtml(genericStatusLabel(item.status))} ${item.status === 'PENDING' && canApply ? `
` : item.status === 'PENDING' ? 'Применение доступно учебному отделу' : ''}
`; }).join('') : '
Заявок не найденоОчередь изменений сейчас пуста.
'; } async function handleChangeReview(event) { const button = event.target.closest('[data-change-review][data-id]'); if (!button) return; const card = button.closest('.teacher-dialogue-item'); const approved = button.dataset.changeReview === 'approve'; const comment = card.querySelector('.teacher-dialogue-comment')?.value.trim() || ''; if (!approved && !comment) { await alertAction({ title: 'Нужен комментарий', message: 'При отклонении укажите комментарий.' }); return; } button.disabled = true; try { await api.post(`/api/teacher-change-requests/${button.dataset.id}/review`, { approved, comment }); await Promise.all([loadChangeReviews(), loadAbsences()]); } catch (error) { await alertAction({ title: 'Решение не применено', message: error.message || 'Не удалось применить решение' }); 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 genericStatusLabel(status) { return { PENDING: 'На рассмотрении', APPROVED: 'Согласовано', REJECTED: 'Отклонено', CANCELLED: 'Отменено' }[status] || status || '—'; } function semesterLabel(semester) { const type = semester.semesterType === 'AUTUMN' ? 'осенний' : semester.semesterType === 'SPRING' ? 'весенний' : semester.semesterType; return `${semester.academicYearTitle} · ${type} · ${formatDate(semester.startDate)}–${formatDate(semester.endDate)}`; } function preferenceDescription(item) { const type = { HARD_UNAVAILABLE: 'Строгая недоступность', SOFT_PREFERRED: 'Предпочтительное время', SOFT_UNWANTED: 'Нежелательное время', CONSECUTIVE: 'Пожелание «пары подряд»', NO_GAPS: 'Пожелание «без окон»' }[item.preferenceType] || item.preferenceType; if (item.preferenceDate) return `${type}: ${formatDate(item.preferenceDate)}`; if (item.dayName) return `${type}: ${item.dayName}, ${trimTime(item.startTime)}–${trimTime(item.endTime)}`; return type; } function changeTypeLabel(type) { return { MOVE: 'Перенос даты или времени', CHANGE_CLASSROOM: 'Смена аудитории', CANCEL: 'Отмена занятия' }[type] || type || 'Изменение'; } 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} решений`; }