Админка, настройки, логин, кабинеты преподавателя и студента полностью переведены на 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
1772 lines
80 KiB
JavaScript
1772 lines
80 KiB
JavaScript
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>
|
||
);
|
||
});
|