import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'; import { getSession } from '../../../admin/js/api.js'; import { academicYearsFromSemesters, resolveAcademicPeriod, semestersForAcademicYear } from '../../../admin/js/academic-period.js'; import { readUrlState, writeUrlState } from '../../../admin/js/url-state.js'; import { OVERRIDE_DAY_LABELS, buildCancelOverridePayload, buildOverridePayload, canEditScheduleOverrides, classroomOptionLabel, deleteScheduleOverride, fetchEffectiveTimeSlots, fetchOverrideAvailability, initialTimePreference, lessonFromOverride, overrideAfterMeta, overrideBeforeMeta, overrideClassroomName, overrideRegistryItem, overrideSourceFrom, overrideTeacherName, overrideTimeSlotLabel, pickMatchingTimeSlot, saveScheduleOverride, semesterCalendarMonths, weekDates } from '../schedule-override-service.js'; import { trackUxEvent } from '../../../telemetry.js'; import { AsyncCombobox } from '../../shared/ui/AsyncCombobox.jsx'; import { FormAlert } from '../../shared/ui/FormAlert.jsx'; import { DAY_FULL_LABELS, DAY_ORDER, MOBILE_QUERY, PARITY_LABELS, ROLE_DEPARTMENT, SCHEDULE_REFRESH_PROMPT, TARGET_CLASSROOMS, TARGET_DEPARTMENTS, TARGET_GROUPS, TARGET_TEACHERS, buildCombinedWeekBlocks, buildScheduleSections, buildSectionContext, buildSemesterLessonMatrix, collectSlots, combinedWeekCaption, dateWithinSemester, dayShort, entityValueForTarget, fetchClassroomOptions, fetchGroupOptions, fetchScheduleLessons, fetchScheduleOverrides, fetchScheduleViewDictionaries, fetchTeacherOptions, formatDate, formatDateShort, getLessonWeeksText, groupLessonsByDateSlot, groupOptionLabel, hasScheduleSearchTarget, initialScheduleViewState, isoDay, lessonCountLabel, mergeDictionaryItems, mobileDayCaption, periodCaption, roomLabel, scheduleRangeForSemester, scheduleViewUrlState, sectionCountLabel, semesterOptionLabel, semesterScheduleCaption, slotStatesEqual, slotStatesForDates, slotStatesForSemester, splitTypeForTarget, teacherOptionLabel, toIsoDate } from '../schedule-view-service.js'; const ENTITY_KEYS = [TARGET_GROUPS, TARGET_TEACHERS, TARGET_CLASSROOMS]; const EMPTY_SELECTION = Object.freeze({ id: '', label: '' }); function restoredSelection(value) { return value ? { id: String(value), label: 'Загрузка...' } : EMPTY_SELECTION; } // Боковая панель разовых правок перенесена на React-состояние (волна 6): // вкладка владеет реестром правок, а панель открывает занятие или реестр // через императивный handle и работает со справочниками через общий ref. export function ScheduleViewTab() { const [session] = useState(() => getSession() || {}); const role = session?.role; const userDepartmentId = normalizeId(session?.departmentId); const isDepartment = role === ROLE_DEPARTMENT; const canEditOverrides = canEditScheduleOverrides(role); const restoredRef = useRef(null); if (!restoredRef.current) { restoredRef.current = initialScheduleViewState( typeof window === 'undefined' ? {} : readUrlState({ scheduleTarget: '', scheduleEntity: '', scheduleDate: '', scheduleYear: '', scheduleSemester: '', scheduleSubject: '', scheduleLessonType: '', scheduleParity: '' }) ); } const restored = restoredRef.current; // Фильтры const [target, setTarget] = useState(() => ( isDepartment ? TARGET_DEPARTMENTS : (restored.target || TARGET_GROUPS) )); const [entitySelections, setEntitySelections] = useState(() => ({ [TARGET_GROUPS]: restoredSelection(restored.target === TARGET_GROUPS ? restored.entity : ''), [TARGET_TEACHERS]: restoredSelection(restored.target === TARGET_TEACHERS ? restored.entity : ''), [TARGET_CLASSROOMS]: restoredSelection(restored.target === TARGET_CLASSROOMS ? restored.entity : '') })); const [departmentId, setDepartmentId] = useState(''); const [dateValue, setDateValue] = useState(() => restored.date || toIsoDate(new Date())); const [yearId, setYearId] = useState(''); const [semesterId, setSemesterId] = useState(''); const [subjectId, setSubjectId] = useState(''); const [lessonTypeId, setLessonTypeId] = useState(''); const [parity, setParity] = useState(''); // Справочники const dictionariesRef = useRef({ groups: [], teachers: [], classrooms: [], departments: [], semesters: [] }); const [departments, setDepartments] = useState([]); const [subjects, setSubjects] = useState([]); const [lessonTypes, setLessonTypes] = useState([]); const [semesters, setSemesters] = useState([]); const [baseLoaded, setBaseLoaded] = useState(false); // Результаты const [alert, setAlert] = useState(null); const [sections, setSections] = useState([]); const [activeSectionIndex, setActiveSectionIndex] = useState(0); const [mobileDay, setMobileDay] = useState(1); const [range, setRange] = useState(null); const [totalLessons, setTotalLessons] = useState(0); const [loading, setLoading] = useState(false); const [tableState, setTableState] = useState({ message: 'Загрузка...', actionable: false }); const [captionText, setCaptionText] = useState('Выберите параметры и нажмите «Показать»'); const [isMobile, setIsMobile] = useState(false); // Реестр разовых правок за показанный период const [overrides, setOverrides] = useState([]); const [overrideRegistryRange, setOverrideRegistryRange] = useState(null); const overrideDrawerRef = useRef(null); const rangeRef = useRef(null); // Загрузка и обработчики читают актуальные значения через этот ref: // боковая панель правок захватывает колбэки один раз при создании. const latestRef = useRef(null); latestRef.current = { target, entitySelections, departmentId, dateValue, yearId, semesterId, subjectId, lessonTypeId, parity, sections, activeSectionIndex, semesters }; const selectedSemester = useMemo( () => semesters.find(semester => String(semester.id) === String(semesterId)) || null, [semesters, semesterId] ); const activeTargetValue = isDepartment ? TARGET_DEPARTMENTS : (target || TARGET_GROUPS); const activeSection = sections[activeSectionIndex] || null; const canEditOverridesRef = useRef(canEditOverrides); canEditOverridesRef.current = canEditOverrides; useEffect(() => { if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return undefined; const media = window.matchMedia(MOBILE_QUERY); const apply = () => setIsMobile(media.matches); apply(); if (typeof media.addEventListener === 'function') { media.addEventListener('change', apply); return () => media.removeEventListener('change', apply); } if (typeof media.addListener === 'function') { media.addListener(apply); return () => media.removeListener(apply); } return undefined; }, []); // --- Загрузка данных --- useEffect(() => { let cancelled = false; async function initialize() { try { const dictionaries = await fetchScheduleViewDictionaries(); if (cancelled) return; dictionariesRef.current = { ...dictionariesRef.current, departments: dictionaries.departments, semesters: dictionaries.semesters }; setDepartments(dictionaries.departments); setSemesters(dictionaries.semesters); setSubjects(dictionaries.subjects); setLessonTypes(dictionaries.lessonTypes); const resolved = resolveAcademicPeriod(dictionaries.semesters, { academicYearId: restored.yearId, semesterId: restored.semesterId, date: latestRef.current.dateValue }); const nextSemesterId = resolved.semesterId; const nextDate = synchronizedDate(nextSemesterId, dictionaries.semesters, latestRef.current.dateValue); setYearId(resolved.academicYearId); setSemesterId(nextSemesterId); setDateValue(nextDate); if (isDepartment) { setDepartmentId(userDepartmentId || ''); } restoreSimpleFilter('subjectId', restored.subjectId, dictionaries.subjects, setSubjectId); restoreSimpleFilter('lessonTypeId', restored.lessonTypeId, dictionaries.lessonTypes, setLessonTypeId); if (restored.parity && ['BOTH', 'ODD', 'EVEN'].includes(restored.parity)) { setParity(restored.parity); } setBaseLoaded(true); // Аналог combo.preload('') из legacy: заранее наполняем справочники // сущностей и обновляем подпись восстановленного значения. await Promise.all(ENTITY_KEYS.map(key => loadEntityOptions(key, '', latestRef.current.entitySelections[key].id) .catch(() => []) )); await loadData(nextDate); } catch (error) { if (cancelled) return; const message = error.message || 'Ошибка загрузки справочников'; setAlert({ message, type: 'error' }); resetResults(message); } } void initialize(); return () => { cancelled = true; }; }, []); function restoreSimpleFilter(key, value, items, setter) { if (!value) return; if ((items || []).some(item => String(item.id) === String(value))) { setter(String(value)); } } function synchronizedDate(nextSemesterId, semestersList, currentDate) { const semester = (semestersList || []).find(item => String(item.id) === String(nextSemesterId)) || null; if (!semester) return toIsoDate(new Date()); if (!dateWithinSemester(currentDate, semester)) return semester.startDate; return currentDate; } async function loadData(overrideDate) { const current = latestRef.current; const effectiveDate = overrideDate || current.dateValue; setAlert(null); if (!effectiveDate) { const message = 'Выберите дату в периоде'; setAlert({ message, type: 'error' }); resetResults(message); return; } if (isDepartment && !userDepartmentId) { const message = 'Не удалось определить кафедру пользователя'; setAlert({ message, type: 'error' }); resetResults(message); return; } // Без выбранной цели (группа/преподаватель/аудитория/кафедра) backend // отвергает широкий поиск — показываем нейтральную подсказку вместо // заведомо неудачного запроса и красной ошибки. if (!hasScheduleSearchTarget({ role, userDepartmentId, target: current.target || TARGET_GROUPS, groupId: current.entitySelections[TARGET_GROUPS].id, teacherId: current.entitySelections[TARGET_TEACHERS].id, classroomId: current.entitySelections[TARGET_CLASSROOMS].id, departmentId: current.departmentId })) { resetResults('Выберите параметры и нажмите «Показать»'); return; } const loadTarget = isDepartment ? TARGET_DEPARTMENTS : (current.target || TARGET_GROUPS); const semester = (current.semesters || []).find(item => String(item.id) === String(current.semesterId)) || null; const nextRange = scheduleRangeForSemester(effectiveDate, semester); const previousKey = current.sections[current.activeSectionIndex]?.key; rangeRef.current = nextRange; setRange(nextRange); setLoading(true); setTableState({ message: 'Загрузка...', actionable: false }); try { const [lessons, overrideList] = await Promise.all([ fetchScheduleLessons({ startDate: nextRange.startDate, endDate: nextRange.endDate, target: loadTarget, role, userDepartmentId, groupId: current.entitySelections[TARGET_GROUPS].id, teacherId: current.entitySelections[TARGET_TEACHERS].id, classroomId: current.entitySelections[TARGET_CLASSROOMS].id, departmentId: current.departmentId, subjectId: current.subjectId, lessonTypeId: current.lessonTypeId, parity: current.parity }), canEditOverridesRef.current ? fetchScheduleOverrides(nextRange) : Promise.resolve([]) ]); setOverrides(overrideList || []); setOverrideRegistryRange(nextRange); renderLessons(lessons, nextRange, loadTarget, previousKey, effectiveDate); persistFilters({ date: effectiveDate }); trackUxEvent('расписание_найдено', { target: loadTarget, lessons: lessons.length }); } catch (error) { const message = error.data?.message || error.message || 'Ошибка загрузки расписания'; setAlert({ message, type: 'error' }); resetResults(message); } finally { setLoading(false); } } function renderLessons(lessons, nextRange, loadTarget, previousKey, effectiveDate) { setTotalLessons(lessons.length); if (!lessons.length) { setSections([]); setActiveSectionIndex(0); setCaptionText(periodCaption(nextRange)); setTableState({ message: 'Занятия не найдены', actionable: false }); return; } const selectedGroupId = loadTarget === TARGET_GROUPS ? latestRef.current.entitySelections[TARGET_GROUPS].id : ''; const context = buildSectionContext({ target: loadTarget, selectedGroupId, groups: dictionariesRef.current.groups }); const nextSections = buildScheduleSections(lessons, splitTypeForTarget(loadTarget), context); if (!nextSections.length) { setSections([]); setActiveSectionIndex(0); setCaptionText(periodCaption(nextRange)); setTableState({ message: 'Нет данных для выбранного режима просмотра', actionable: false }); return; } const previousIndex = nextSections.findIndex(section => section.key === previousKey); setSections(nextSections); setActiveSectionIndex(Math.max(0, previousIndex)); setMobileDay(isoDay(effectiveDate || nextRange.startDate)); } function resetResults(message) { setSections([]); setActiveSectionIndex(0); setMobileDay(1); setTotalLessons(0); setCaptionText(message); setTableState({ message, actionable: message === SCHEDULE_REFRESH_PROMPT }); } function persistFilters(overrides = {}) { if (typeof window === 'undefined') return; const current = { ...latestRef.current, ...overrides }; const persistTarget = isDepartment ? TARGET_DEPARTMENTS : (current.target || TARGET_GROUPS); writeUrlState(scheduleViewUrlState({ role, target: persistTarget, entityValue: entityValueForTarget(persistTarget, { groupId: current.entitySelections[TARGET_GROUPS].id, teacherId: current.entitySelections[TARGET_TEACHERS].id, classroomId: current.entitySelections[TARGET_CLASSROOMS].id, departmentId: current.departmentId }), date: current.dateValue, yearId: current.yearId, semesterId: current.semesterId, subjectId: current.subjectId, lessonTypeId: current.lessonTypeId, parity: current.parity })); } // --- Обработчики фильтров --- function handleTargetChange(event) { const nextTarget = event.target.value; setTarget(nextTarget); setEntitySelections(current => { const next = { ...current }; if (nextTarget !== TARGET_GROUPS) next[TARGET_GROUPS] = EMPTY_SELECTION; if (nextTarget !== TARGET_TEACHERS) next[TARGET_TEACHERS] = EMPTY_SELECTION; if (nextTarget !== TARGET_CLASSROOMS) next[TARGET_CLASSROOMS] = EMPTY_SELECTION; return next; }); if (!isDepartment && nextTarget !== TARGET_DEPARTMENTS) { setDepartmentId(''); } resetResults(SCHEDULE_REFRESH_PROMPT); const clearedDepartments = !isDepartment && nextTarget !== TARGET_DEPARTMENTS; persistFilters({ target: nextTarget, departmentId: clearedDepartments ? '' : latestRef.current.departmentId }); } function handleDateChange(event) { const nextDate = event.target.value; setDateValue(nextDate); resetResults(SCHEDULE_REFRESH_PROMPT); persistFilters({ date: nextDate }); } function handleYearChange(event) { const nextYearId = event.target.value; const current = latestRef.current; const yearSemesters = semestersForAcademicYear(current.semesters, nextYearId); const selected = resolveAcademicPeriod(yearSemesters, { date: current.dateValue }); const nextDate = synchronizedDate(selected.semesterId, current.semesters, current.dateValue); setYearId(nextYearId); setSemesterId(selected.semesterId); setDateValue(nextDate); resetResults(SCHEDULE_REFRESH_PROMPT); persistFilters({ yearId: nextYearId, semesterId: selected.semesterId, date: nextDate }); } function handleSemesterChange(event) { const nextSemesterId = event.target.value; const current = latestRef.current; const nextDate = synchronizedDate(nextSemesterId, current.semesters, current.dateValue); setSemesterId(nextSemesterId); setDateValue(nextDate); resetResults(SCHEDULE_REFRESH_PROMPT); persistFilters({ semesterId: nextSemesterId, date: nextDate }); } function handleDepartmentChange(event) { const nextDepartmentId = event.target.value; setDepartmentId(nextDepartmentId); persistFilters({ departmentId: nextDepartmentId }); } function handleSimpleFilterChange(key, setter) { return event => { const value = event.target.value; setter(value); persistFilters({ [key]: value }); }; } function makeEntityLoader(entityKey) { return (query, selectedId) => loadEntityOptions(entityKey, query, selectedId); } async function loadEntityOptions(entityKey, query, selectedId) { let items; if (entityKey === TARGET_GROUPS) { items = await fetchGroupOptions(query, selectedId, isDepartment ? userDepartmentId : ''); } else if (entityKey === TARGET_TEACHERS) { items = await fetchTeacherOptions(query, selectedId); } else { items = await fetchClassroomOptions(query, selectedId); } mergeDictionaryItems(dictionariesRef.current, entityKey, items || []); const selected = (items || []).find(item => String(item.id) === String(selectedId)); if (selected) { setEntitySelections(current => ( String(current[entityKey].id) === String(selectedId) ? { ...current, [entityKey]: { id: String(selectedId), label: entityLabel(entityKey, selected) } } : current )); } return items; } function entityLabel(entityKey, item) { if (entityKey === TARGET_GROUPS) return groupOptionLabel(item); if (entityKey === TARGET_TEACHERS) return teacherOptionLabel(item); return roomLabel(item); } function handleEntitySelect(entityKey, item) { const selection = item ? { id: String(item.id), label: entityLabel(entityKey, item) } : EMPTY_SELECTION; setEntitySelections(current => ({ ...current, [entityKey]: selection })); persistFilters({ entitySelections: { ...latestRef.current.entitySelections, [entityKey]: selection } }); } // --- Навигация по результатам --- function selectSection(index) { setActiveSectionIndex(index); setMobileDay(isoDay(latestRef.current.dateValue || (rangeRef.current?.startDate || '')) || 1); } function moveResult(direction) { const total = latestRef.current.sections.length; if (total < 2) return; setActiveSectionIndex(current => (current + direction + total) % total); setMobileDay(isoDay(latestRef.current.dateValue || (rangeRef.current?.startDate || '')) || 1); } function openOverrideLesson(lesson) { void overrideDrawerRef.current?.openLesson(lesson); } // --- Производные значения --- const years = useMemo(() => academicYearsFromSemesters(semesters), [semesters]); const yearSemesters = useMemo( () => semestersForAcademicYear(semesters, yearId), [semesters, yearId] ); const periodLabelText = !dateValue ? 'Период не выбран' : periodCaption(range || scheduleRangeForSemester(dateValue, selectedSemester), selectedSemester); const countLabelText = loading ? 'Загрузка...' : (!totalLessons ? '0 занятий' : (sections.length ? `${lessonCountLabel(totalLessons)}, ${sectionCountLabel(sections.length)}` : lessonCountLabel(totalLessons))); const activeLabelText = loading ? 'Загрузка расписаний...' : (activeSection ? `${activeSection.title} · ${lessonCountLabel(activeSection.lessons.length)} · ${activeSectionIndex + 1} из ${sections.length}` : 'Нет выбранного расписания'); const tableCaptionText = activeSection && range ? `${lessonCountLabel(activeSection.lessons.length)} · ${periodCaption(range)}` : captionText; const departmentOptions = useMemo(() => { const options = departments.map(department => ( )); if (isDepartment && userDepartmentId && !departments.some(department => String(department.id) === String(userDepartmentId))) { options.unshift(); } return options; }, [departments, isDepartment, userDepartmentId]); // --- Рендер сцены расписания --- function renderStateRow(message, actionable = false) { return (
{message} {actionable ? ( ) : null}
); } function renderStageContent() { if (loading) { return
Загрузка...
; } const section = activeSection; if (!section || !range) { return renderStateRow(tableState.message, tableState.actionable); } const slots = collectSlots(section.lessons); const semester = selectedSemester; if (semester) { if (!slots.length) { return renderStateRow('Занятия не найдены'); } if (isMobile) { return ( ); } return ( ); } const lessonsByDateSlot = groupLessonsByDateSlot(section.lessons); const blocks = buildCombinedWeekBlocks(range.startDate, range.endDate, section.lessons); if (!blocks.length || !slots.length) { return renderStateRow('Занятия не найдены'); } if (isMobile) { return blocks.map(block => ( )); } return blocks.map(block => ( )); } return (

Расписания

{periodLabelText}
Дополнительные фильтры

Найденные расписания

{countLabelText}
{activeLabelText}
{!loading && (activeSection ? ( sections.map((item, index) => ( )) ) : ( Ничего не найдено ))}

{activeSection ? activeSection.title : 'Расписание'}

{tableCaptionText}
{renderStageContent()}
{ void loadData(); }} />
); } function normalizeId(value) { if (!value || value === 'null' || value === 'undefined') return ''; return String(value); } // --- Карточка занятия --- function LessonCard({ lesson, canEditOverrides, onEditLesson }) { const subgroupText = (lesson.subgroupNames || []).join(', '); const meta = [ lesson.lessonTypeName, lesson.lessonFormat, PARITY_LABELS[lesson.parity] || lesson.parity ].filter(Boolean).join(' · '); const weeksText = getLessonWeeksText(lesson); const sourceDate = lesson.originalLessonDate || lesson.date; return (
{lesson.subjectName || 'Без дисциплины'} {weeksText ? {weeksText} : null}
{lesson.scheduleOverrideId ? Разовая правка : null}
{(lesson.groupNames || []).join(', ') || 'Группа не указана'}
{subgroupText ?
{subgroupText}
: null}
{lesson.teacherName || 'Преподаватель не указан'}
{lesson.classroomName || 'Аудитория не указана'}
{meta ?
{meta}
: null} {lesson.scheduleRuleSlotId ? (
ID слота: {lesson.scheduleRuleSlotId}
) : null} {canEditOverrides && lesson.scheduleRuleSlotId && sourceDate ? ( ) : null}
); } // --- Ячейки слотов --- function DayHeaderCells() { return DAY_ORDER.map(day => ( {dayShort(day)} {DAY_FULL_LABELS[day] || ''} )); } function SlotTimeCell({ slot }) { return ( {slot.orderLabel} {slot.timeLabel} {slot.id ? ID {slot.id} : null} ); } function SlotContent({ states, canEditOverrides, onEditLesson }) { if (slotStatesEqual(states[0].state, states[1].state)) { const state = states[0].state; if (state.kind === 'busy') { return (
{state.lessons.map((lesson, index) => ( ))}
); } if (state.kind === 'empty') { return (
Нет даты
); } return (
Нет занятий
); } return states.map(({ parity, label, state }) => (
{label} {state.kind === 'busy' ? ( state.lessons.map((lesson, index) => ( )) ) : ( {state.kind === 'empty' ? 'Нет даты' : 'Нет занятий'} )}
)); } function CombinedSlotCell({ states, canEditOverrides, onEditLesson }) { const single = slotStatesEqual(states[0].state, states[1].state); return ( ); } // --- Таблицы --- function SemesterScheduleTable({ section, slots, semester, canEditOverrides, onEditLesson }) { const lessonMatrix = useMemo(() => buildSemesterLessonMatrix(section.lessons), [section]); return (

{section.title}

{semesterScheduleCaption(semester)}
{slots.map(slot => ( {DAY_ORDER.map(day => ( ))} ))}
Время
); } function CombinedWeekTable({ block, slots, lessonsByDateSlot, section, canEditOverrides, onEditLesson }) { return (

{section.title}

{combinedWeekCaption(block)}
{slots.map(slot => ( {DAY_ORDER.map(day => ( ))} ))}
Время
); } // --- Мобильная версия --- function MobileSlotArticle({ slot, states, canEditOverrides, onEditLesson }) { return (
{slot.orderLabel} {slot.timeLabel}
); } function MobileSemesterSchedule({ section, slots, semester, mobileDay, canEditOverrides, onEditLesson }) { const lessonMatrix = useMemo(() => buildSemesterLessonMatrix(section.lessons), [section]); return (
{DAY_FULL_LABELS[mobileDay] || dayShort(mobileDay)} {semesterScheduleCaption(semester)}
{slots.map(slot => ( ))}
); } function MobileDayBlock({ block, slots, lessonsByDateSlot, mobileDay, canEditOverrides, onEditLesson }) { const dayDates = block.datesByDay[mobileDay] || {}; return (
{DAY_FULL_LABELS[mobileDay] || dayShort(mobileDay)} {mobileDayCaption(dayDates)}
{slots.map(slot => ( ))}
); } // --- Боковая панель разовых правок (переходный общий модуль, волна 6) --- // --- Боковая панель разовых правок --- const OVERRIDE_EMPTY_PROMPT = 'Выберите занятие в расписании или откройте его из реестра изменений.'; const OverrideDrawer = forwardRef(function OverrideDrawer({ overrides, registryRange, dictionariesRef, onChanged }, ref) { const [open, setOpen] = useState(false); const [tab, setTab] = useState('edit'); const [editorPhase, setEditorPhase] = useState('idle'); const [editorMessage, setEditorMessage] = useState(OVERRIDE_EMPTY_PROMPT); const [source, setSource] = useState(null); const [selectedOverride, setSelectedOverride] = useState(null); const [availability, setAvailability] = useState(null); const [targetDate, setTargetDate] = useState(null); const [timeSlots, setTimeSlots] = useState([]); const [timeSlotsLoading, setTimeSlotsLoading] = useState(false); const [timeSlotId, setTimeSlotId] = useState(''); const [teacherId, setTeacherId] = useState(''); const [teacherLabel, setTeacherLabel] = useState(''); const [classroomId, setClassroomId] = useState(''); const [classroomLabel, setClassroomLabel] = useState(''); const [lessonFormat, setLessonFormat] = useState('Очно'); const [comment, setComment] = useState(''); const [calendarOpen, setCalendarOpen] = useState(false); const [alert, setAlert] = useState(null); const [busy, setBusy] = useState(false); // Асинхронные обработчики читают актуальные значения через этот ref. const latestRef = useRef(null); latestRef.current = { busy, source, selectedOverride, targetDate, timeSlotId, teacherId, classroomId, lessonFormat, comment, overrides }; useImperativeHandle(ref, () => ({ openLesson, openRegistry })); useEffect(() => { if (!open) return undefined; document.body.classList.add('schedule-override-open'); function handleKeydown(event) { if (event.key === 'Escape') closeDrawer(); } document.addEventListener('keydown', handleKeydown); return () => { document.body.classList.remove('schedule-override-open'); document.removeEventListener('keydown', handleKeydown); }; }, [open]); function closeDrawer() { setOpen(false); } function openRegistry() { setOpen(true); setTab('registry'); } async function openLesson(lesson) { if (!lesson?.scheduleRuleSlotId) return; const originalDate = lesson.originalLessonDate || lesson.date; const existing = latestRef.current.overrides.find(override => String(override.baseRuleSlotId) === String(lesson.scheduleRuleSlotId) && override.lessonDate === originalDate ) || null; await prepareEditor(lesson, existing); } async function openOverrideById(overrideId) { const override = latestRef.current.overrides .find(item => String(item.id) === String(overrideId)); if (override) { await prepareEditor(lessonFromOverride(override, dictionariesRef.current), override); } } async function prepareEditor(lesson, existing) { setOpen(true); setTab('edit'); setEditorPhase('loading'); setEditorMessage('Загрузка доступных дат и времени...'); setAlert(null); const nextSource = overrideSourceFrom(lesson, existing); const nextTargetDate = existing?.targetLessonDate || nextSource.lessonDate; setSource(nextSource); setSelectedOverride(existing); setTargetDate(nextTargetDate); setAvailability(null); setTimeSlots([]); setTimeSlotId(''); try { const availabilityData = await fetchOverrideAvailability( nextSource.baseRuleSlotId, nextSource.lessonDate ); setAvailability(availabilityData); await loadTimeSlotsFor(nextTargetDate, initialTimePreference(existing, nextSource)); const dictionaries = dictionariesRef.current; const effectiveTeacherId = existing?.newTeacherId || nextSource.teacherId || ''; const effectiveClassroomId = existing?.newClassroomId || nextSource.classroomId || ''; setTeacherId(String(effectiveTeacherId)); setTeacherLabel( overrideTeacherName(dictionaries, effectiveTeacherId) || nextSource.teacherName || `Преподаватель ${effectiveTeacherId}` ); setClassroomId(String(effectiveClassroomId)); setClassroomLabel( overrideClassroomName(dictionaries, effectiveClassroomId) || nextSource.classroomName || `Аудитория ${effectiveClassroomId}` ); setLessonFormat(existing?.newLessonFormat || nextSource.lessonFormat || 'Очно'); setComment(existing?.comment || ''); setEditorPhase('ready'); } catch (error) { setEditorPhase('error'); setEditorMessage(error.message || 'Не удалось загрузить данные занятия'); } } async function loadTimeSlotsFor(date, preference) { setTimeSlotsLoading(true); setTimeSlotId(''); try { const slots = await fetchEffectiveTimeSlots(date); setTimeSlots(slots); const selected = pickMatchingTimeSlot(slots, preference?.id, preference?.startTime, preference?.endTime); setTimeSlotId(selected ? String(selected.id) : ''); } finally { setTimeSlotsLoading(false); } } async function selectDate(date) { const current = latestRef.current; if (!date || !current.source || date === current.targetDate) return; setTargetDate(date); try { await loadTimeSlotsFor(date, { id: current.source.timeSlotId, startTime: current.source.startTime, endTime: current.source.endTime }); } catch (error) { setAlert({ message: error.message || 'Не удалось загрузить время занятий', type: 'error' }); } } async function save(event) { event.preventDefault(); if (latestRef.current.busy) return; setAlert(null); const current = latestRef.current; const { error, payload } = buildOverridePayload({ source: current.source, selectedOverride: current.selectedOverride, targetDate: current.targetDate, timeSlotId: current.timeSlotId, teacherId: current.teacherId, classroomId: current.classroomId, lessonFormat: current.lessonFormat, comment: current.comment }); if (error) { setAlert({ message: error, type: 'error' }); return; } await mutate(() => saveScheduleOverride(payload, current.selectedOverride?.id || null)); } async function cancelLesson() { const current = latestRef.current; if (current.busy || !current.source) return; await mutate(() => saveScheduleOverride( buildCancelOverridePayload(current.source, current.comment), current.selectedOverride?.id || null )); } async function restoreRule() { const current = latestRef.current; if (current.busy || !current.selectedOverride) return; await mutate(() => deleteScheduleOverride(current.selectedOverride.id)); } async function mutate(operation) { setBusy(true); setAlert(null); try { await operation(); await onChanged?.(); setSource(null); setSelectedOverride(null); setEditorPhase('idle'); setEditorMessage(OVERRIDE_EMPTY_PROMPT); openRegistry(); } catch (error) { setAlert({ message: error.message || 'Не удалось сохранить разовое изменение', type: 'error' }); } finally { setBusy(false); } } async function loadTeacherOptions(query, selectedId) { const items = await fetchTeacherOptions(query, selectedId); mergeDictionaryItems(dictionariesRef.current, 'teachers', items || []); return items; } async function loadClassroomOptions(query, selectedId) { const items = await fetchClassroomOptions(query, selectedId); mergeDictionaryItems(dictionariesRef.current, 'classrooms', items || []); return items; } const availableDates = useMemo( () => new Set(availability?.availableDates || []), [availability] ); const weekDayList = source ? weekDates(source.lessonDate) : []; const calendarMonths = useMemo( () => (availability ? semesterCalendarMonths( availability.semesterStartDate, availability.semesterEndDate, availableDates, targetDate ) : []), [availability, availableDates, targetDate] ); const selectedTime = timeSlots.find(slot => String(slot.id) === String(timeSlotId)) || null; const registryItems = overrides.map(override => overrideRegistryItem(override, dictionariesRef.current)); const comparisonTitle = source ? `${source.subjectName} · ${source.lessonTypeName}` : '—'; const beforeMeta = source ? overrideBeforeMeta(source) : '—'; const afterMeta = source ? overrideAfterMeta({ source, targetDate, selectedTime, teacherName: overrideTeacherName(dictionariesRef.current, teacherId), classroomName: overrideClassroomName(dictionariesRef.current, classroomId), lessonFormat }) : '—'; const registryPeriodText = registryRange ? `${formatDate(registryRange.startDate)} — ${formatDate(registryRange.endDate)}` : '—'; return ( ); });