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' ?
Загрузка...
: absencesPhase === 'error' ?
{absencesError}
: (absences.length ? absences.map(item => (
{dayOfMonth(item.startDate)} {monthShort(item.startDate)} {dayOfMonth(item.endDate)} {monthShort(item.endDate)}

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

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

{statusLabel(item.status)}
{formatAbsenceDate(item.startDate)} — {formatAbsenceDate(item.endDate)} {decisionCountLabel(item.decisionCount || 0)} {item.reviewComment ? {item.reviewComment} : null}
{item.status === 'PENDING' ? ( <> ) : null} {['APPROVED', 'RESOLVED'].includes(item.status) ? ( ) : null} {['PENDING', 'APPROVED'].includes(item.status) ? ( ) : null}
)) : (
Инцидентов не найдено Измените фильтр или зарегистрируйте отсутствие.
)); const preferenceContent = preferencePhase === 'idle' ? null : preferencePhase === 'meta-loading' ?
Загрузка семестров...
: preferencePhase === 'loading' ?
Загрузка пожеланий...
: preferencePhase === 'error' ?
{preferenceError}
: (preferenceItems.length ? preferenceItems.map(item => (
{item.academicYearTitle} · {item.semesterType === 'AUTUMN' ? 'осенний семестр' : 'весенний семестр'}

{item.teacherName}

{preferenceDescription(item)}

{item.comment ?
{item.comment}
: null} {item.reviewComment ? Решение: {item.reviewComment} : null}
{genericStatusLabel(item.status)} {item.status === 'PENDING' ? ( <> setPreferenceComments(previous => ({ ...previous, [item.id]: event.target.value }))} />
) : null}
)) : (
Пожеланий не найдено Измените фильтры или дождитесь новых отметок преподавателей.
)); const changeContent = changePhase === 'idle' ? null : changePhase === 'loading' ?
Загрузка заявок...
: changePhase === 'error' ?
{changeError}
: (changeItems.length ? changeItems.map(item => (
{formatAbsenceDate(item.lessonDate)} · {trimTime(item.sourceStartTime)}–{trimTime(item.sourceEndTime)} · {item.sourceClassroomName}

{item.teacherName} · {item.subjectName}

{changeTypeLabel(item.requestType)} → {changeReviewTarget(item)}

{item.reason}
{item.reviewComment ? Решение: {item.reviewComment} : null}
История · {(item.history || []).length}
    {(item.history || []).map((entry, index) => (
  1. {genericStatusLabel(entry.status)} · {entry.comment || 'Без комментария'}
  2. ))}
{genericStatusLabel(item.status)} {item.status === 'PENDING' && canApplyChanges ? ( <> setChangeComments(previous => ({ ...previous, [item.id]: event.target.value }))} />
) : item.status === 'PENDING' ? Применение доступно учебному отделу : null}
)) : (
Заявок не найдено Очередь изменений сейчас пуста.
)); return ( <>
Оперативный контур расписания

От регистрации инцидента до готовой замены

Зафиксируйте период отсутствия, проверьте затронутые пары и примените только выбранные решения.

{stats.active}требуют внимания
{stats.pending}ждут подтверждения
{stats.resolved}разрешены
01

Зарегистрировать отсутствие

Ответственный сотрудник создаёт согласованную запись сразу
[ teacher.jobTitle, teacher.departmentName ].filter(Boolean).join(' · ')} load={(query, selectedId) => fetchAbsenceTeacherOptions({ query, selectedId, role, departmentId: session?.departmentId })} onSelect={item => setTeacherSelection(item ? { id: String(item.id), label: teacherOptionLabel(item) } : { id: '', label: '' })} />
setStartDate(event.target.value)} />
setEndDate(event.target.value)} />
setReason(event.target.value)} />
02

Реестр инцидентов

{registryContent}
03

Пожелания на семестр

Строгие ограничения после согласования участвуют в проверке правил. Мягкие отметки остаются видимой подсказкой.

{preferenceItems.filter(item => item.status === 'PENDING').length} ожидают
{preferenceContent}
04

Заявки на изменение

Каждое одобрение проходит повторную проверку конфликтов и применяется через точечное изменение расписания.

{changeItems.filter(item => item.status === 'PENDING').length} в очереди
{changeContent}
{wizardOpen ? (
{ if (event.target === event.currentTarget) closeWizard(); }} >
Мастер замены

{wizardData ? (wizardData.absence.teacherName || 'Мастер замены') : 'Затронутые занятия'}

{wizardData ? `${formatAbsenceDate(wizardData.absence.startDate)} — ${formatAbsenceDate(wizardData.absence.endDate)} · ${wizardData.absence.reason}` : 'Загрузка...'}

{wizardLoading ?
Подбираем свободные ресурсы...
: (wizardData ? (wizardData.lessons.length ? wizardData.lessons.map((lesson, index) => ( setDecisions(previous => ({ ...previous, [index]: value }))} /> )) : (
Затронутых занятий нет Инцидент можно завершить без точечных изменений.
)) : null)}

Журнал решений

{wizardData ? decisionCountLabel((wizardData.journal || []).length) : '0 записей'}
{wizardData ? ( (wizardData.journal || []).length ? (wizardData.journal || []).map((item, index) => (
{resolutionLabel(item.resolution)} {formatAbsenceDate(item.lessonDate)}
{decisionStatusLabel(item.status)}{item.scheduleOverrideId ? ` · override №${item.scheduleOverrideId}` : ''}
)) :
Решений пока нет
) : null}
) : null} {alert ? (
{ if (event.target === event.currentTarget) setAlert(null); }} >

{alert.title}

{alert.message}

) : null} ); } function LessonDecision({ lesson, index, locked, value, onChange }) { const options = decisionOptions(lesson); return (
{trimTime(lesson.startTime)} {trimTime(lesson.endTime)} {formatAbsenceDate(lesson.lessonDate)}
{lesson.lessonTypeName || 'Занятие'}

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

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

{lesson.existingOverrideId ? У пары уже есть точечное изменение — доступна только запись отклонения. : null}
{lesson.decisionStatus ? {decisionStatusLabel(lesson.decisionStatus)} : null}
); }