{item.teacherName || 'Преподаватель не указан'}
{item.reason || 'Причина не указана'}
import { useEffect, useRef, useState } from 'react'; import { getSession } from '../../../admin/js/api.js'; import { AsyncCombobox } from '../../shared/ui/AsyncCombobox.jsx'; import { FormAlert } from '../../shared/ui/FormAlert.jsx'; import { absenceSemesterLabel, absenceStats, changeReviewTarget, changeTypeLabel, createTeacherAbsence, decisionCountLabel, decisionOptions, decisionStatusLabel, deleteTeacherAbsence, fetchAbsenceTeacherOptions, fetchTeacherAbsenceWizard, fetchTeacherAbsences, fetchTeacherChangeRequests, fetchTeacherPreferenceMeta, fetchTeacherPreferences, formatAbsenceDate, formatLocalDate, dayOfMonth, genericStatusLabel, monthShort, parseDecisionOption, preferenceDescription, resolutionLabel, resolveTeacherAbsence, reviewTeacherAbsence, reviewTeacherChangeRequest, reviewTeacherPreference, statusClass, statusLabel, trimTime } from '../teacher-absences-service.js'; function teacherOptionLabel(teacher) { return teacher.fullName || teacher.username || `ID ${teacher.id}`; } export function TeacherAbsencesTab() { const session = getSession(); const role = session?.role; const [teacherSelection, setTeacherSelection] = useState({ id: '', label: '' }); const [startDate, setStartDate] = useState(() => formatLocalDate(new Date())); const [endDate, setEndDate] = useState(() => formatLocalDate(new Date())); const [reason, setReason] = useState(''); const [formAlert, setFormAlert] = useState(null); const [absences, setAbsences] = useState([]); const [absencesPhase, setAbsencesPhase] = useState('loading'); const [absencesError, setAbsencesError] = useState(''); const [statusFilter, setStatusFilter] = useState(''); const [registryBusy, setRegistryBusy] = useState(null); const [wizardOpen, setWizardOpen] = useState(false); const [wizardData, setWizardData] = useState(null); const [wizardLoading, setWizardLoading] = useState(false); const [wizardAlert, setWizardAlert] = useState(null); const [wizardApplying, setWizardApplying] = useState(false); const [decisions, setDecisions] = useState({}); const [preferenceSemesters, setPreferenceSemesters] = useState([]); const [preferenceSemesterId, setPreferenceSemesterId] = useState(''); const [preferenceStatusFilter, setPreferenceStatusFilter] = useState('PENDING'); const [preferenceItems, setPreferenceItems] = useState([]); const [preferencePhase, setPreferencePhase] = useState('idle'); const [preferenceError, setPreferenceError] = useState(''); const [preferenceComments, setPreferenceComments] = useState({}); const [preferenceBusy, setPreferenceBusy] = useState(null); const [changeStatusFilter, setChangeStatusFilter] = useState('PENDING'); const [changeItems, setChangeItems] = useState([]); const [changePhase, setChangePhase] = useState('idle'); const [changeError, setChangeError] = useState(''); const [changeComments, setChangeComments] = useState({}); const [changeBusy, setChangeBusy] = useState(null); const [alert, setAlert] = useState(null); const latestRef = useRef(null); latestRef.current = { statusFilter, preferenceSemesterId, preferenceStatusFilter, changeStatusFilter }; const stats = absenceStats(absences); const canApplyChanges = role === 'ADMIN' || role === 'EDUCATION_OFFICE'; useEffect(() => { void loadAbsences(); void loadPreferenceMeta(); void loadChangeReviews(); }, []); // Мастер замены блокирует прокрутку страницы (в legacy — класс на body) useEffect(() => { if (!wizardOpen) return; document.body.classList.add('absence-wizard-open'); return () => document.body.classList.remove('absence-wizard-open'); }, [wizardOpen]); // Escape закрывает системное окно сообщения (в legacy — диалог из dialog.js) useEffect(() => { if (!alert) return; function handleKeydown(event) { if (event.key === 'Escape') setAlert(null); } document.addEventListener('keydown', handleKeydown); return () => document.removeEventListener('keydown', handleKeydown); }, [alert]); async function loadAbsences(overrideStatus) { const nextStatus = overrideStatus ?? latestRef.current.statusFilter; setAbsencesPhase('loading'); try { const items = await fetchTeacherAbsences(nextStatus); setAbsences(items); setAbsencesPhase('ready'); } catch (error) { setAbsencesError(error.message || 'Не удалось загрузить отсутствия'); setAbsencesPhase('error'); } } async function handleSubmit(event) { event.preventDefault(); setFormAlert(null); if (!teacherSelection.id || !startDate || !endDate || !reason.trim()) { setFormAlert({ type: 'error', message: 'Заполните преподавателя, период и причину' }); return; } if (endDate < startDate) { setFormAlert({ type: 'error', message: 'Дата окончания не может быть раньше даты начала' }); return; } try { await createTeacherAbsence({ teacherId: Number(teacherSelection.id), startDate, endDate, reason: reason.trim() }); setFormAlert({ type: 'success', message: 'Отсутствие зарегистрировано' }); setReason(''); await loadAbsences(); } catch (error) { setFormAlert({ type: 'error', message: error.message || 'Не удалось зарегистрировать отсутствие' }); } } async function handleRegistryAction(item, action) { setRegistryBusy({ id: item.id, action }); try { if (action === 'approve' || action === 'reject') { await reviewTeacherAbsence(item.id, { approved: action === 'approve', comment: action === 'approve' ? 'Подтверждено кафедрой или ответственным сотрудником' : 'Заявка отклонена ответственным сотрудником' }); await loadAbsences(); } else if (action === 'cancel') { await deleteTeacherAbsence(item.id); await loadAbsences(); } else if (action === 'wizard') { await openWizard(item.id); } } catch (error) { setAlert({ title: 'Действие не выполнено', message: error.message || 'Не удалось выполнить действие' }); } finally { setRegistryBusy(null); } } async function openWizard(id) { setWizardOpen(true); setWizardLoading(true); setWizardData(null); setWizardAlert(null); setDecisions({}); try { const data = await fetchTeacherAbsenceWizard(id); setWizardData(data); setWizardLoading(false); } catch (error) { setWizardAlert({ type: 'error', message: error.message || 'Не удалось открыть мастер замены' }); } } function closeWizard() { setWizardOpen(false); setWizardData(null); setWizardLoading(false); setWizardAlert(null); setWizardApplying(false); setDecisions({}); } async function applyWizard() { if (!wizardData) return; const nextDecisions = (wizardData.lessons || []).map((lesson, index) => { const value = decisions[index] || ''; if (!value) return null; return { baseRuleSlotId: Number(lesson.baseRuleSlotId), lessonDate: lesson.lessonDate, ...parseDecisionOption(value) }; }).filter(Boolean); if (!nextDecisions.length && (wizardData.lessons || []).length) { setWizardAlert({ type: 'error', message: 'Выберите хотя бы одно решение' }); return; } setWizardApplying(true); setWizardAlert(null); try { const data = await resolveTeacherAbsence(wizardData.absence.id, nextDecisions); setWizardData(data); setDecisions({}); setWizardAlert({ type: 'success', message: 'Выбранные решения применены к расписанию' }); await loadAbsences(); } catch (error) { setWizardAlert({ type: 'error', message: error.message || 'Не удалось применить решения' }); } finally { setWizardApplying(false); } } async function loadPreferenceMeta() { setPreferencePhase('meta-loading'); try { const meta = await fetchTeacherPreferenceMeta(); setPreferenceSemesters(meta.semesters || []); const today = formatLocalDate(new Date()); const current = (meta.semesters || []).find(item => item.startDate <= today && item.endDate >= today); const nextSemesterId = current ? String(current.id) : ''; setPreferenceSemesterId(nextSemesterId); await loadPreferenceReviews({ semesterId: nextSemesterId }); } catch (error) { setPreferenceError(error.message || 'Не удалось загрузить семестры'); setPreferencePhase('error'); } } async function loadPreferenceReviews(overrides = {}) { const semesterId = overrides.semesterId ?? latestRef.current.preferenceSemesterId; const status = overrides.status ?? latestRef.current.preferenceStatusFilter; setPreferencePhase('loading'); try { const items = await fetchTeacherPreferences({ semesterId, status }); setPreferenceItems(items); setPreferencePhase('ready'); } catch (error) { setPreferenceError(error.message || 'Не удалось загрузить пожелания'); setPreferencePhase('error'); } } async function handlePreferenceReview(item, approved) { const comment = (preferenceComments[item.id] || '').trim(); if (!approved && !comment) { setAlert({ title: 'Нужен комментарий', message: 'При отклонении укажите комментарий.' }); return; } setPreferenceBusy({ id: item.id, action: approved ? 'approve' : 'reject' }); try { await reviewTeacherPreference(item.id, { approved, comment: comment || 'Согласовано ответственным сотрудником' }); await loadPreferenceReviews(); } catch (error) { setAlert({ title: 'Ошибка рассмотрения', message: error.message || 'Не удалось рассмотреть пожелание' }); } finally { setPreferenceBusy(null); } } async function loadChangeReviews(overrideStatus) { const nextStatus = overrideStatus ?? latestRef.current.changeStatusFilter; setChangePhase('loading'); try { const items = await fetchTeacherChangeRequests(nextStatus); setChangeItems(items); setChangePhase('ready'); } catch (error) { setChangeError(error.message || 'Не удалось загрузить заявки'); setChangePhase('error'); } } async function handleChangeReview(item, approved) { const comment = (changeComments[item.id] || '').trim(); if (!approved && !comment) { setAlert({ title: 'Нужен комментарий', message: 'При отклонении укажите комментарий.' }); return; } setChangeBusy({ id: item.id, action: approved ? 'approve' : 'reject' }); try { await reviewTeacherChangeRequest(item.id, { approved, comment }); await Promise.all([loadChangeReviews(), loadAbsences()]); } catch (error) { setAlert({ title: 'Решение не применено', message: error.message || 'Не удалось применить решение' }); } finally { setChangeBusy(null); } } const registryContent = absencesPhase === 'loading' ?
{item.reason || 'Причина не указана'}
{preferenceDescription(item)}
{item.comment ?{item.comment}: null} {item.reviewComment ? Решение: {item.reviewComment} : null}
{changeTypeLabel(item.requestType)} → {changeReviewTarget(item)}
{item.reason}{item.reviewComment ? Решение: {item.reviewComment} : null}
Зафиксируйте период отсутствия, проверьте затронутые пары и примените только выбранные решения.
Строгие ограничения после согласования участвуют в проверке правил. Мягкие отметки остаются видимой подсказкой.
Каждое одобрение проходит повторную проверку конфликтов и применяется через точечное изменение расписания.
{wizardData ? `${formatAbsenceDate(wizardData.absence.startDate)} — ${formatAbsenceDate(wizardData.absence.endDate)} · ${wizardData.absence.reason}` : 'Загрузка...'}
{alert.message}
{(lesson.groupNames || []).join(', ') || 'Группы не указаны'} · {lesson.classroomName || 'Без аудитории'}
{lesson.existingOverrideId ? У пары уже есть точечное изменение — доступна только запись отклонения. : null}