Files
magistr/frontend/react/admin/tabs/ScheduleViewTab.jsx
dipatrik10 97bf3cc50a feat: завершить миграцию фронтенда на React (этапы 1-5)
Админка, настройки, логин, кабинеты преподавателя и студента
полностью переведены на React 19 (модель strangler, docs/REACT_MIGRATION.md).

- 15 вкладок админки: lazy-загрузка, общий error boundary, сервисный слой
- Удалены legacy-views и переходный слой LegacyTabView (45 файлов)
- Сборка esbuild (frontend/scripts/build-react.mjs) с бюджетами gzip на чанки
- CSP-хэши, кэширование: chunk'и immutable, CSS/HTML no-cache (react-cache.conf)
- Фиксы: lazy-импорт именованных экспортов (краш после авторизации),
  растяжение #admin-root/#settings-root на всю ширину,
  возврат subjects/lessonTypes из справочников (краш просмотра расписаний),
  отказ от заведомо широкого поиска расписания без выбранной цели,
  центрирование карточки логина
- Тесты: 237/237 (npm run check), регрессионные на lazy-вкладки и ширину
- Документация: REACT_MIGRATION.md, REACT_MIGRATION_RESULT.md, FRONTEND.md
2026-09-28 23:15:04 +03:00

1772 lines
80 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 => (
<option key={String(department.id)} value={String(department.id)}>
{department.departmentName}
</option>
));
if (isDepartment
&& userDepartmentId
&& !departments.some(department => String(department.id) === String(userDepartmentId))) {
options.unshift(<option key={userDepartmentId} value={userDepartmentId}>Своя кафедра</option>);
}
return options;
}, [departments, isDepartment, userDepartmentId]);
// --- Рендер сцены расписания ---
function renderStateRow(message, actionable = false) {
return (
<div className={`loading-row schedule-view-state${actionable ? ' schedule-view-state-actionable' : ''}`}>
<span>{message}</span>
{actionable ? (
<button
type="button"
className="btn btn-md btn-primary schedule-view-state-reload"
data-schedule-view-reload=""
onClick={() => {
void loadData();
}}
>
Показать расписание
</button>
) : null}
</div>
);
}
function renderStageContent() {
if (loading) {
return <div className="loading-row schedule-view-state"><span>Загрузка...</span></div>;
}
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 (
<MobileSemesterSchedule
section={section}
slots={slots}
semester={semester}
mobileDay={mobileDay}
canEditOverrides={canEditOverrides}
onEditLesson={openOverrideLesson}
/>
);
}
return (
<SemesterScheduleTable
section={section}
slots={slots}
semester={semester}
canEditOverrides={canEditOverrides}
onEditLesson={openOverrideLesson}
/>
);
}
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 => (
<MobileDayBlock
key={block.startDate}
block={block}
slots={slots}
lessonsByDateSlot={lessonsByDateSlot}
mobileDay={mobileDay}
canEditOverrides={canEditOverrides}
onEditLesson={openOverrideLesson}
/>
));
}
return blocks.map(block => (
<CombinedWeekTable
key={block.startDate}
block={block}
slots={slots}
lessonsByDateSlot={lessonsByDateSlot}
section={section}
canEditOverrides={canEditOverrides}
onEditLesson={openOverrideLesson}
/>
));
}
return (
<section className="schedule-view-shell">
<div className="card schedule-view-control-card">
<div className="schedule-view-control-header">
<div>
<h2>Расписания</h2>
<span className="muted" id="schedule-view-period-label">{periodLabelText}</span>
</div>
<button
type="button"
className="btn btn-md btn-secondary"
id="schedule-view-load"
onClick={() => {
void loadData();
}}
>
Показать
</button>
</div>
<div className="schedule-view-control-grid">
<div className="form-group">
<label htmlFor="schedule-view-target">Что смотреть</label>
<select
id="schedule-view-target"
value={activeTargetValue}
disabled={isDepartment}
onChange={handleTargetChange}
>
<option value={TARGET_GROUPS}>Группа</option>
<option value={TARGET_TEACHERS}>Преподаватель</option>
<option value={TARGET_CLASSROOMS}>Аудитория</option>
<option value={TARGET_DEPARTMENTS}>Кафедра</option>
</select>
</div>
<div
className="form-group schedule-view-entity-field"
data-schedule-view-target-field={TARGET_GROUPS}
hidden={activeTargetValue !== TARGET_GROUPS}
>
<label htmlFor="schedule-view-group">Группа</label>
<AsyncCombobox
ariaLabel="Группа"
placeholder="Найдите группу"
selectedValue={entitySelections[TARGET_GROUPS].id}
selectedLabel={entitySelections[TARGET_GROUPS].label}
optionLabel={groupOptionLabel}
optionDescription={group => [
group.specialtyCode,
group.specialtyProfileName,
group.educationFormName
].filter(Boolean).join(' · ')}
load={makeEntityLoader(TARGET_GROUPS)}
onSelect={item => handleEntitySelect(TARGET_GROUPS, item)}
/>
</div>
<div
className="form-group schedule-view-entity-field"
data-schedule-view-target-field={TARGET_TEACHERS}
hidden={activeTargetValue !== TARGET_TEACHERS}
>
<label htmlFor="schedule-view-teacher">Преподаватель</label>
<AsyncCombobox
ariaLabel="Преподаватель"
placeholder="Найдите преподавателя"
selectedValue={entitySelections[TARGET_TEACHERS].id}
selectedLabel={entitySelections[TARGET_TEACHERS].label}
optionLabel={teacherOptionLabel}
optionDescription={teacher => [
teacher.jobTitle,
teacher.departmentName
].filter(Boolean).join(' · ')}
load={makeEntityLoader(TARGET_TEACHERS)}
onSelect={item => handleEntitySelect(TARGET_TEACHERS, item)}
/>
</div>
<div
className="form-group schedule-view-entity-field"
data-schedule-view-target-field={TARGET_CLASSROOMS}
hidden={activeTargetValue !== TARGET_CLASSROOMS}
>
<label htmlFor="schedule-view-classroom">Аудитория</label>
<AsyncCombobox
ariaLabel="Аудитория"
placeholder="Найдите аудиторию"
selectedValue={entitySelections[TARGET_CLASSROOMS].id}
selectedLabel={entitySelections[TARGET_CLASSROOMS].label}
optionLabel={roomLabel}
optionDescription={classroom => [
classroom.capacity ? `${classroom.capacity} мест` : null,
classroom.building
].filter(Boolean).join(' · ')}
load={makeEntityLoader(TARGET_CLASSROOMS)}
onSelect={item => handleEntitySelect(TARGET_CLASSROOMS, item)}
/>
</div>
<div
className="form-group schedule-view-entity-field"
data-schedule-view-target-field={TARGET_DEPARTMENTS}
hidden={activeTargetValue !== TARGET_DEPARTMENTS}
>
<label htmlFor="schedule-view-department">Кафедра</label>
<select
id="schedule-view-department"
value={departmentId}
disabled={isDepartment}
onChange={handleDepartmentChange}
>
<option value="">Все кафедры</option>
{departmentOptions}
</select>
</div>
<input
type="date"
id="schedule-view-date"
hidden
value={dateValue}
onChange={handleDateChange}
/>
</div>
<details className="schedule-view-extra-filters">
<summary>Дополнительные фильтры</summary>
<div className="schedule-view-extra-grid">
<div className="form-group">
<label htmlFor="schedule-view-year">Учебный год</label>
<select id="schedule-view-year" value={yearId} onChange={handleYearChange}>
{!baseLoaded ? (
<option value="">Загрузка годов…</option>
) : (
years.length
? years.map(year => (
<option key={year.id} value={year.id}>{year.title}</option>
))
: <option value="">Учебные годы не настроены</option>
)}
</select>
</div>
<div className="form-group">
<label htmlFor="schedule-view-semester">Семестр</label>
<select id="schedule-view-semester" value={semesterId} onChange={handleSemesterChange}>
{!baseLoaded ? (
<option value="">Сначала выберите год</option>
) : (
yearSemesters.length
? yearSemesters.map(semester => (
<option key={String(semester.id)} value={String(semester.id)}>
{semesterOptionLabel(semester)}
</option>
))
: <option value="">Семестры не настроены</option>
)}
</select>
</div>
<div className="form-group">
<label htmlFor="schedule-view-subject">Дисциплина</label>
<select
id="schedule-view-subject"
value={subjectId}
onChange={handleSimpleFilterChange('subjectId', setSubjectId)}
>
<option value="">Все дисциплины</option>
{subjects.map(subject => (
<option key={String(subject.id)} value={String(subject.id)}>{subject.name}</option>
))}
</select>
</div>
<div className="form-group">
<label htmlFor="schedule-view-lesson-type">Тип занятия</label>
<select
id="schedule-view-lesson-type"
value={lessonTypeId}
onChange={handleSimpleFilterChange('lessonTypeId', setLessonTypeId)}
>
<option value="">Все типы</option>
{lessonTypes.map(lessonType => (
<option key={String(lessonType.id)} value={String(lessonType.id)}>
{lessonType.name}
</option>
))}
</select>
</div>
<div className="form-group">
<label htmlFor="schedule-view-parity">Неделя</label>
<select
id="schedule-view-parity"
value={parity}
onChange={handleSimpleFilterChange('parity', setParity)}
>
<option value="">Любая</option>
<option value="BOTH">Каждая</option>
<option value="ODD">Нечётная</option>
<option value="EVEN">Чётная</option>
</select>
</div>
</div>
</details>
<FormAlert id="schedule-view-alert" alert={alert} />
</div>
<div className="card schedule-view-results-card">
<div className="schedule-view-results-header">
<div>
<h2>Найденные расписания</h2>
<span className="muted" id="schedule-view-count">{countLabelText}</span>
</div>
<div className="schedule-view-result-actions">
<button
type="button"
className="btn btn-md btn-secondary"
id="schedule-view-overrides-open"
hidden={!canEditOverrides}
onClick={() => overrideDrawerRef.current?.openRegistry()}
>
Изменения за период
<span className="schedule-override-count" id="schedule-view-overrides-count">{overrides.length}</span>
</button>
<button
type="button"
className="btn btn-icon-md btn-secondary"
id="schedule-view-result-prev"
aria-label="Предыдущее расписание"
disabled={sections.length < 2}
onClick={() => moveResult(-1)}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<polyline points="15 18 9 12 15 6"></polyline>
</svg>
</button>
<span id="schedule-view-active-label">{activeLabelText}</span>
<button
type="button"
className="btn btn-icon-md btn-secondary"
id="schedule-view-result-next"
aria-label="Следующее расписание"
disabled={sections.length < 2}
onClick={() => moveResult(1)}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<polyline points="9 18 15 12 9 6"></polyline>
</svg>
</button>
</div>
</div>
<div className="schedule-view-result-tabs" id="schedule-view-result-tabs">
{!loading && (activeSection ? (
sections.map((item, index) => (
<button
key={item.key}
type="button"
className={`schedule-view-result-tab${index === activeSectionIndex ? ' active' : ''}`}
data-schedule-view-section={index}
onClick={() => selectSection(index)}
>
<span>{item.title}</span>
<small>{lessonCountLabel(item.lessons.length)}</small>
</button>
))
) : (
<span className="schedule-view-empty-result">Ничего не найдено</span>
))}
</div>
</div>
<div className="card schedule-view-table-card">
<div className="schedule-view-table-header">
<div>
<h2 id="schedule-view-table-title">{activeSection ? activeSection.title : 'Расписание'}</h2>
<span className="muted" id="schedule-view-table-caption">{tableCaptionText}</span>
</div>
<div className="schedule-view-day-tabs" id="schedule-view-day-tabs" hidden={!isMobile || !activeSection || loading}>
{isMobile && activeSection ? (
DAY_ORDER.map(day => (
<button
key={day}
type="button"
className={`schedule-view-day-tab${day === mobileDay ? ' active' : ''}`}
data-schedule-view-day={day}
onClick={() => setMobileDay(day)}
>
{dayShort(day)}
</button>
))
) : null}
</div>
</div>
<div className="schedule-view-table-stage" id="schedule-view-tables">
{renderStageContent()}
</div>
</div>
<OverrideDrawer
ref={overrideDrawerRef}
overrides={overrides}
registryRange={overrideRegistryRange}
dictionariesRef={dictionariesRef}
onChanged={() => {
void loadData();
}}
/>
</section>
);
}
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 (
<div className="lesson-card schedule-view-lesson-card">
<div className="lesson-subject schedule-view-lesson-title">
<span>{lesson.subjectName || 'Без дисциплины'}</span>
{weeksText ? <span className="lesson-weeks-badge">{weeksText}</span> : null}
</div>
{lesson.scheduleOverrideId ? <span className="schedule-override-badge">Разовая правка</span> : null}
<div className="lesson-group">{(lesson.groupNames || []).join(', ') || 'Группа не указана'}</div>
{subgroupText ? <div className="lesson-subgroup">{subgroupText}</div> : null}
<div className="lesson-teacher">{lesson.teacherName || 'Преподаватель не указан'}</div>
<div className="lesson-meta">{lesson.classroomName || 'Аудитория не указана'}</div>
{meta ? <div className="lesson-meta">{meta}</div> : null}
{lesson.scheduleRuleSlotId ? (
<div className="lesson-meta">ID слота: {lesson.scheduleRuleSlotId}</div>
) : null}
{canEditOverrides && lesson.scheduleRuleSlotId && sourceDate ? (
<button
type="button"
className="btn btn-sm btn-secondary schedule-override-edit"
data-schedule-override-edit=""
data-base-rule-slot-id={String(lesson.scheduleRuleSlotId)}
data-lesson-date={sourceDate}
onClick={() => onEditLesson(lesson)}
>
Изменить
</button>
) : null}
</div>
);
}
// --- Ячейки слотов ---
function DayHeaderCells() {
return DAY_ORDER.map(day => (
<th className="room-day-cell" key={day}>
<strong>{dayShort(day)}</strong>
<span>{DAY_FULL_LABELS[day] || ''}</span>
</th>
));
}
function SlotTimeCell({ slot }) {
return (
<td className="room-time-cell">
<strong>{slot.orderLabel}</strong>
<span>{slot.timeLabel}</span>
{slot.id ? <small>ID {slot.id}</small> : null}
</td>
);
}
function SlotContent({ states, canEditOverrides, onEditLesson }) {
if (slotStatesEqual(states[0].state, states[1].state)) {
const state = states[0].state;
if (state.kind === 'busy') {
return (
<div className="room-week-unified room-week-unified-busy">
{state.lessons.map((lesson, index) => (
<LessonCard
key={`${index}:${lesson.subjectName || ''}`}
lesson={lesson}
canEditOverrides={canEditOverrides}
onEditLesson={onEditLesson}
/>
))}
</div>
);
}
if (state.kind === 'empty') {
return (
<div className="room-week-unified room-week-unified-empty">
<span className="slot-unavailable">Нет даты</span>
</div>
);
}
return (
<div className="room-week-unified room-week-unified-free">
<span className="free-slot">Нет занятий</span>
</div>
);
}
return states.map(({ parity, label, state }) => (
<div
key={parity}
className={`room-week-half room-week-half-${parity.toLowerCase()} room-week-half-${state.kind}`}
>
<span className="room-half-marker">{label}</span>
{state.kind === 'busy' ? (
state.lessons.map((lesson, index) => (
<LessonCard
key={`${index}:${lesson.subjectName || ''}`}
lesson={lesson}
canEditOverrides={canEditOverrides}
onEditLesson={onEditLesson}
/>
))
) : (
<span className={state.kind === 'empty' ? 'slot-unavailable' : 'free-slot'}>
{state.kind === 'empty' ? 'Нет даты' : 'Нет занятий'}
</span>
)}
</div>
));
}
function CombinedSlotCell({ states, canEditOverrides, onEditLesson }) {
const single = slotStatesEqual(states[0].state, states[1].state);
return (
<td className={`room-combined-cell${single ? ' room-single-cell' : ''}`}>
<SlotContent states={states} canEditOverrides={canEditOverrides} onEditLesson={onEditLesson} />
</td>
);
}
// --- Таблицы ---
function SemesterScheduleTable({ section, slots, semester, canEditOverrides, onEditLesson }) {
const lessonMatrix = useMemo(() => buildSemesterLessonMatrix(section.lessons), [section]);
return (
<section className="schedule-view-week-panel schedule-view-semester-panel">
<div className="room-week-heading">
<h3>{section.title}</h3>
<span>{semesterScheduleCaption(semester)}</span>
</div>
<div className="workload-grid-container schedule-view-grid-container">
<table className="workload-table room-week-table schedule-view-matrix-table">
<thead>
<tr>
<th className="room-time-heading">Время</th>
<DayHeaderCells />
</tr>
</thead>
<tbody>
{slots.map(slot => (
<tr key={slot.key}>
<SlotTimeCell slot={slot} />
{DAY_ORDER.map(day => (
<CombinedSlotCell
key={day}
states={slotStatesForSemester(day, slot, lessonMatrix)}
canEditOverrides={canEditOverrides}
onEditLesson={onEditLesson}
/>
))}
</tr>
))}
</tbody>
</table>
</div>
</section>
);
}
function CombinedWeekTable({ block, slots, lessonsByDateSlot, section, canEditOverrides, onEditLesson }) {
return (
<section className="schedule-view-week-panel">
<div className="room-week-heading">
<h3>{section.title}</h3>
<span>{combinedWeekCaption(block)}</span>
</div>
<div className="workload-grid-container schedule-view-grid-container">
<table className="workload-table room-week-table schedule-view-matrix-table">
<thead>
<tr>
<th className="room-time-heading">Время</th>
<DayHeaderCells />
</tr>
</thead>
<tbody>
{slots.map(slot => (
<tr key={slot.key}>
<SlotTimeCell slot={slot} />
{DAY_ORDER.map(day => (
<CombinedSlotCell
key={day}
states={slotStatesForDates(block.datesByDay[day] || {}, slot, lessonsByDateSlot)}
canEditOverrides={canEditOverrides}
onEditLesson={onEditLesson}
/>
))}
</tr>
))}
</tbody>
</table>
</div>
</section>
);
}
// --- Мобильная версия ---
function MobileSlotArticle({ slot, states, canEditOverrides, onEditLesson }) {
return (
<article className="schedule-view-mobile-slot">
<div className="schedule-view-mobile-slot-time">
<strong>{slot.orderLabel}</strong>
<span>{slot.timeLabel}</span>
</div>
<div className="schedule-view-mobile-slot-content">
<SlotContent states={states} canEditOverrides={canEditOverrides} onEditLesson={onEditLesson} />
</div>
</article>
);
}
function MobileSemesterSchedule({ section, slots, semester, mobileDay, canEditOverrides, onEditLesson }) {
const lessonMatrix = useMemo(() => buildSemesterLessonMatrix(section.lessons), [section]);
return (
<section className="schedule-view-mobile-day schedule-view-mobile-semester">
<div className="schedule-view-mobile-day-heading">
<strong>{DAY_FULL_LABELS[mobileDay] || dayShort(mobileDay)}</strong>
<span>{semesterScheduleCaption(semester)}</span>
</div>
{slots.map(slot => (
<MobileSlotArticle
key={slot.key}
slot={slot}
states={slotStatesForSemester(mobileDay, slot, lessonMatrix)}
canEditOverrides={canEditOverrides}
onEditLesson={onEditLesson}
/>
))}
</section>
);
}
function MobileDayBlock({ block, slots, lessonsByDateSlot, mobileDay, canEditOverrides, onEditLesson }) {
const dayDates = block.datesByDay[mobileDay] || {};
return (
<section className="schedule-view-mobile-day">
<div className="schedule-view-mobile-day-heading">
<strong>{DAY_FULL_LABELS[mobileDay] || dayShort(mobileDay)}</strong>
<span>{mobileDayCaption(dayDates)}</span>
</div>
{slots.map(slot => (
<MobileSlotArticle
key={slot.key}
slot={slot}
states={slotStatesForDates(dayDates, slot, lessonsByDateSlot)}
canEditOverrides={canEditOverrides}
onEditLesson={onEditLesson}
/>
))}
</section>
);
}
// --- Боковая панель разовых правок (переходный общий модуль, волна 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 (
<div className="schedule-override-drawer" id="schedule-override-drawer" hidden={!open} aria-hidden={!open}>
<button type="button" className="schedule-override-backdrop" data-override-close="" aria-label="Закрыть панель" onClick={closeDrawer}></button>
<aside className="schedule-override-panel" role="dialog" aria-modal="true" aria-labelledby="schedule-override-title">
<header className="schedule-override-header">
<div>
<span className="schedule-override-eyebrow">Точечное изменение</span>
<h2 id="schedule-override-title">Конкретное занятие</h2>
</div>
<button type="button" className="btn btn-icon-md btn-secondary" data-override-close="" aria-label="Закрыть" onClick={closeDrawer}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
<path d="M6 6l12 12M18 6L6 18"></path>
</svg>
</button>
</header>
<nav className="schedule-override-tabs" aria-label="Разовые изменения">
<button
type="button"
className={`schedule-override-tab${tab === 'edit' ? ' active' : ''}`}
data-override-tab="edit"
onClick={() => setTab('edit')}
>
Редактирование
</button>
<button
type="button"
className={`schedule-override-tab${tab === 'registry' ? ' active' : ''}`}
data-override-tab="registry"
onClick={() => setTab('registry')}
>
Изменения за период
</button>
</nav>
<div className="schedule-override-body">
<section data-override-section="edit" hidden={tab !== 'edit'}>
<div className="schedule-override-empty" id="schedule-override-empty" hidden={editorPhase === 'ready'}>
{editorMessage}
</div>
<form id="schedule-override-form" hidden={editorPhase !== 'ready'} onSubmit={save}>
<div className="schedule-override-comparison">
<article>
<span>Было по правилу</span>
<strong id="schedule-override-before-title">{comparisonTitle}</strong>
<p id="schedule-override-before-meta">{beforeMeta}</p>
</article>
<article className="schedule-override-after">
<span>Станет</span>
<strong id="schedule-override-after-title">{comparisonTitle}</strong>
<p id="schedule-override-after-meta">{afterMeta}</p>
</article>
</div>
<fieldset className="schedule-override-fieldset">
<legend>Дата занятия</legend>
<div className="schedule-override-week" id="schedule-override-week">
{weekDayList.map((date, index) => (
<button
key={date}
type="button"
className={`schedule-override-date${date === targetDate ? ' active' : ''}`}
data-override-date={date}
disabled={!availableDates.has(date)}
onClick={() => {
void selectDate(date);
}}
>
<span>{OVERRIDE_DAY_LABELS[index]}</span>
<strong>{formatDateShort(date)}</strong>
</button>
))}
</div>
<button
type="button"
className="btn btn-md btn-secondary schedule-override-calendar-toggle"
id="schedule-override-calendar-toggle"
onClick={() => setCalendarOpen(current => !current)}
>
{calendarOpen ? 'Скрыть календарь семестра' : 'Выбрать другую дату'}
</button>
<div className="schedule-override-calendar" id="schedule-override-calendar" hidden={!calendarOpen}>
{calendarMonths.map(month => (
<section key={month.title} className="schedule-override-month">
<h4>{month.title}</h4>
<div className="schedule-override-calendar-grid">
{OVERRIDE_DAY_LABELS.map(label => <strong key={label}>{label}</strong>)}
{month.blanks.map(blank => (
<span key={blank.key} className="schedule-override-calendar-blank"></span>
))}
{month.days.map(day => (
<button
key={day.key}
type="button"
className={`schedule-override-calendar-day${day.active ? ' active' : ''}`}
data-override-date={day.date}
disabled={!day.enabled}
onClick={() => {
void selectDate(day.date);
}}
>
{day.day}
</button>
))}
</div>
</section>
))}
</div>
</fieldset>
<div className="schedule-override-fields">
<div className="form-group">
<label htmlFor="schedule-override-time">Время</label>
<select
id="schedule-override-time"
required
disabled={timeSlotsLoading}
value={timeSlotId}
onChange={event => setTimeSlotId(event.target.value)}
>
{timeSlotsLoading ? (
<option value="">Загрузка...</option>
) : (
<>
<option value="">Выберите время</option>
{timeSlots.map(slot => (
<option key={slot.id} value={String(slot.id)}>{overrideTimeSlotLabel(slot)}</option>
))}
</>
)}
</select>
</div>
<div className="form-group">
<label htmlFor="schedule-override-teacher">Преподаватель</label>
<AsyncCombobox
ariaLabel="Преподаватель"
placeholder="Найдите преподавателя"
optionLabel={teacher => teacher.fullName || teacher.username}
optionDescription={teacher => [teacher.jobTitle, teacher.departmentName].filter(Boolean).join(' · ')}
load={loadTeacherOptions}
selectedValue={teacherId}
selectedLabel={teacherLabel}
onSelect={teacher => {
setTeacherId(teacher ? String(teacher.id) : '');
setTeacherLabel(teacher ? (teacher.fullName || teacher.username) : '');
}}
/>
</div>
<div className="form-group">
<label htmlFor="schedule-override-classroom">Аудитория</label>
<AsyncCombobox
ariaLabel="Аудитория"
placeholder="Найдите аудиторию"
optionLabel={classroomOptionLabel}
optionDescription={room => [
room.building,
room.floor ? `${room.floor} этаж` : null,
room.capacity ? `${room.capacity} мест` : null
].filter(Boolean).join(' · ')}
load={loadClassroomOptions}
selectedValue={classroomId}
selectedLabel={classroomLabel}
onSelect={room => {
setClassroomId(room ? String(room.id) : '');
setClassroomLabel(room ? classroomOptionLabel(room) : '');
}}
/>
</div>
<div className="form-group">
<label htmlFor="schedule-override-format">Формат</label>
<select
id="schedule-override-format"
value={lessonFormat}
onChange={event => setLessonFormat(event.target.value)}
>
<option value="Очно">Очно</option>
<option value="Онлайн">Онлайн</option>
</select>
</div>
</div>
<div className="form-group">
<label htmlFor="schedule-override-comment">Комментарий</label>
<textarea
id="schedule-override-comment"
rows="3"
maxLength="1000"
placeholder="Например, замена аудитории из-за мероприятия"
value={comment}
onChange={event => setComment(event.target.value)}
></textarea>
</div>
<FormAlert id="schedule-override-alert" alert={alert} />
<footer className="schedule-override-actions">
<button type="submit" className="btn btn-md btn-primary" id="schedule-override-save" disabled={busy}>
{busy ? 'Сохранение...' : 'Сохранить'}
</button>
<button type="button" className="btn btn-md btn-danger-subtle" id="schedule-override-cancel" disabled={busy} onClick={cancelLesson}>
Отменить занятие
</button>
<button type="button" className="btn btn-md btn-secondary" id="schedule-override-restore" hidden={!selectedOverride} disabled={busy} onClick={restoreRule}>
Вернуть по правилу
</button>
</footer>
</form>
</section>
<section data-override-section="registry" hidden={tab !== 'registry'}>
<div className="schedule-override-registry-heading">
<div>
<h3>Изменения за выбранный период</h3>
<span className="muted" id="schedule-override-registry-period">{registryPeriodText}</span>
</div>
</div>
<div className="schedule-override-registry" id="schedule-override-registry">
{!registryItems.length ? (
<div className="schedule-override-empty">За выбранный период разовых изменений нет.</div>
) : registryItems.map(item => (
<article key={item.id} className="schedule-override-registry-item">
<div className="schedule-override-registry-topline">
<span className={`schedule-override-action ${item.actionClass}`}>{item.actionLabel}</span>
<time>{item.timeText}</time>
</div>
<h4>{item.subject}</h4>
<p>{item.description}</p>
{item.comment ? <blockquote>{item.comment}</blockquote> : null}
<button
type="button"
className="btn btn-md btn-secondary"
data-override-registry-id={item.id}
onClick={() => {
void openOverrideById(item.id);
}}
>
Открыть
</button>
</article>
))}
</div>
</section>
</div>
</aside>
</div>
);
});