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}
{isMobile && activeSection ? (
DAY_ORDER.map(day => (
))
) : null}
{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 (
);
});