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} решений`;
}