Files
magistr/frontend/react/admin/tabs/ScheduleTab.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

2369 lines
117 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 { useEffect, useMemo, useRef, useState } from 'react';
import {
academicYearsFromSemesters,
resolveAcademicPeriod,
semestersForAcademicYear
} from '../../../admin/js/academic-period.js';
import { confirmAction } from '../../../admin/js/dialog.js';
import { confirmAbandonDirtyChanges, createDirtyScope } from '../../../admin/js/dirty-state.js';
import { trackUxEvent } from '../../../telemetry.js';
import { AsyncCombobox } from '../../shared/ui/AsyncCombobox.jsx';
import { FormAlert } from '../../shared/ui/FormAlert.jsx';
import { Pagination } from '../../shared/ui/Pagination.jsx';
import {
SCHEDULE_OPEN_RULE_STORAGE_KEY,
SCHEDULE_OPEN_VERSION_STORAGE_KEY,
SCHEDULE_SEMESTER_STORAGE_KEY,
SCHEDULE_VERSION_STORAGE_KEY,
SCHEDULE_YEAR_STORAGE_KEY,
createScheduleDraftVersion,
fetchScheduleVersions
} from '../schedule-versions-service.js';
import { fetchClassroomOptions, fetchTeacherOptions } from '../schedule-view-service.js';
import {
CELL_PARITY_LAYOUT,
DAY_OPTIONS,
LESSON_TYPE_LIMITS,
PARITY_LABELS,
WHOLE_GROUP_SUBGROUP_VALUE,
buildRulePayloadFromForm,
buildRulePayloadFromRule,
buildVisualLessonsByCell,
buildVisualRows,
calculateVisualContextMenuPosition,
chooseScheduleVersion,
collectVisualGroups,
collectVisualSlotsFromRules,
conflictFieldClass,
conflictReasonLabels,
conflictSlotCurrentText,
fetchAcademicYears,
fetchApprovedTeacherPreferences,
fetchLessonTypes,
fetchRuleGroupOptions,
fetchScheduleRule,
fetchScheduleRules,
fetchScheduleRulesPage,
fetchSubgroupsForGroups,
fetchSubjects,
fetchTimeSlots,
formatLocalDate,
formatSemester,
formSlotsToPayloads,
groupCountLabel,
isLaboratorySlot,
isScheduleRuleConflictError,
matchingSubgroupsForGroups,
mergeEntitiesById,
orderedLessonTypes,
preferenceHintPills,
ruleCountLabel,
ruleDraftMeta,
ruleDraftTitle,
ruleMeta,
ruleSlotToPayload,
ruleTitle,
rowCountLabel,
saveScheduleRule,
deleteScheduleRule,
scheduleSlotActionLabel,
scheduleVersionOptionGroups,
semesterLabel,
shortSemesterLabel,
slotSubgroupIds,
slotSubgroupNames,
slotSubgroupText,
subgroupLabel,
toTimeSlotOption,
uniqueVisualLessons,
versionContextTexts,
visualSlotFromTimeSlot
} from '../schedule-service.js';
// Волна 6 (этап 5): вкладка «Конструктор правил», перенесённая с legacy
// admin/js/views/schedule.js + admin/views/schedule.html без изменения
// поведения: те же контракты API, идентификаторы DOM и русские тексты.
const PARITY_OPTIONS = Object.entries(PARITY_LABELS).map(([value, label]) => ({ value, label }));
const LESSON_FORMAT_OPTIONS = [
{ value: 'Очно', label: 'Очно' },
{ value: 'Онлайн', label: 'Онлайн' }
];
function clonePlain(value) {
return JSON.parse(JSON.stringify(value));
}
function emptySlot() {
return {
id: null,
dayOfWeek: '',
parity: 'BOTH',
timeSlotId: '',
teacherId: '',
teacherName: '',
classroomId: '',
classroomName: '',
lessonTypeId: '',
lessonFormat: 'Очно',
subgroupIds: []
};
}
function slotFromRule(slot = {}) {
return {
id: slot.id || null,
dayOfWeek: slot.dayOfWeek ?? '',
parity: slot.parity || 'BOTH',
timeSlotId: slot.timeSlotId ?? '',
teacherId: slot.teacherId != null ? String(slot.teacherId) : '',
teacherName: slot.teacherName || '',
classroomId: slot.classroomId != null ? String(slot.classroomId) : '',
classroomName: slot.classroomName || '',
lessonTypeId: slot.lessonTypeId ?? '',
lessonFormat: slot.lessonFormat || 'Очно',
subgroupIds: slotSubgroupIds(slot)
};
}
function emptyTypeLimitsState() {
return LESSON_TYPE_LIMITS.reduce((state, config) => {
state[config.key] = { hours: '0', startWeek: '1' };
return state;
}, {});
}
function typeLimitsStateFromRule(rule) {
return LESSON_TYPE_LIMITS.reduce((state, config) => {
state[config.key] = {
hours: String(rule?.[config.hoursField] ?? 0),
startWeek: String(rule?.[config.startWeekField] ?? 1)
};
return state;
}, {});
}
function typeLimitsPayload(state) {
return LESSON_TYPE_LIMITS.reduce((payload, config) => {
payload[config.hoursField] = Number(state[config.key].hours || 0);
payload[config.startWeekField] = Number(state[config.key].startWeek || 1);
return payload;
}, {});
}
function findRuleSlotIndex(rule, target) {
if (!rule?.slots?.length) return -1;
if (target.slotId) {
const byId = rule.slots.findIndex(slot => String(slot.id) === String(target.slotId));
if (byId >= 0) return byId;
}
const index = Number(target.slotIndex);
return Number.isInteger(index) && index >= 0 && index < rule.slots.length ? index : -1;
}
function effectiveVisualPeriodFrom({ academicYears, semesters, visualYearId, visualSemesterId }) {
const yearValue = academicYears.some(year => String(year.id) === String(visualYearId))
? String(visualYearId)
: (academicYears[0] ? String(academicYears[0].id) : '');
const yearSemesters = semesters.filter(semester => String(semester.academicYearId) === yearValue);
const semesterValue = yearSemesters.some(semester => String(semester.id) === String(visualSemesterId))
? String(visualSemesterId)
: (yearSemesters[0] ? String(yearSemesters[0].id) : '');
return { yearId: yearValue, semesterId: semesterValue, yearSemesters };
}
const teacherOptionLabel = teacher => teacher.fullName || teacher.username || `Преподаватель ${teacher.id}`;
const classroomOptionLabel = classroom => classroom.name || `Аудитория ${classroom.id}`;
const teacherOptionDescription = teacher => [teacher.jobTitle, teacher.departmentName].filter(Boolean).join(' · ');
const classroomOptionDescription = classroom => [
classroom.building,
classroom.floor != null ? `${classroom.floor} этаж` : '',
classroom.capacity ? `${classroom.capacity} мест` : ''
].filter(Boolean).join(' · ');
export function ScheduleTab() {
const [initialPhase, setInitialPhase] = useState('loading');
const [subjects, setSubjects] = useState([]);
const [lessonTypes, setLessonTypes] = useState([]);
const [timeSlots, setTimeSlots] = useState([]);
const [academicYears, setAcademicYears] = useState([]);
const [semesters, setSemesters] = useState([]);
const [approvedPreferences, setApprovedPreferences] = useState([]);
const [groups, setGroups] = useState([]);
const [groupsPhase, setGroupsPhase] = useState('loading');
const [groupsError, setGroupsError] = useState('');
const [groupSearchQuery, setGroupSearchQuery] = useState('');
const [groupsMenuOpen, setGroupsMenuOpen] = useState(false);
const [subgroups, setSubgroups] = useState([]);
const [subgroupsLoading, setSubgroupsLoading] = useState(false);
const [versionYearId, setVersionYearId] = useState('');
const [versionSemesterId, setVersionSemesterId] = useState('');
const [scheduleVersions, setScheduleVersions] = useState([]);
const [versionId, setVersionId] = useState('');
const [rules, setRules] = useState([]);
const [rulesPhase, setRulesPhase] = useState('loading');
const [rulesMessage, setRulesMessage] = useState('Загрузка...');
const [rulesPageData, setRulesPageData] = useState(null);
const [rulesPage, setRulesPage] = useState(0);
const [rulesSize, setRulesSize] = useState(25);
const [rulesSearch, setRulesSearch] = useState('');
const [ruleAlert, setRuleAlert] = useState(null);
const [ruleId, setRuleId] = useState('');
const [ruleSubjectId, setRuleSubjectId] = useState('');
const [typeLimits, setTypeLimits] = useState(emptyTypeLimitsState);
const [selectedGroupIds, setSelectedGroupIds] = useState([]);
const [slots, setSlots] = useState([emptySlot()]);
const [subgroupMenuOpenIndex, setSubgroupMenuOpenIndex] = useState(null);
const [draftOpen, setDraftOpen] = useState(false);
const [draftName, setDraftName] = useState('');
const [draftBusy, setDraftBusy] = useState(false);
const [draftError, setDraftError] = useState('');
const [conflict, setConflict] = useState(null);
const [conflictAlert, setConflictAlert] = useState(null);
const [conflictBusy, setConflictBusy] = useState(false);
const [visualOpen, setVisualOpen] = useState(false);
const [visualYearId, setVisualYearId] = useState('');
const [visualSemesterId, setVisualSemesterId] = useState('');
const [visualSelectedGroupIds, setVisualSelectedGroupIds] = useState([]);
const [visualGroupFilterTouched, setVisualGroupFilterTouched] = useState(false);
const [visualPeriodTouched, setVisualPeriodTouched] = useState(false);
const [visualRules, setVisualRules] = useState([]);
const [visualStageMessage, setVisualStageMessage] = useState('Загрузка...');
const [contextMenu, setContextMenu] = useState(null);
const [moveSlot, setMoveSlot] = useState(null);
const [moveSlotAlert, setMoveSlotAlert] = useState(null);
const [moveSlotBusy, setMoveSlotBusy] = useState(false);
// Отказ от смены версии при несохранённых изменениях: контролируемые
// селекты возвращаются к состоянию после принудительного ре-рендера.
const [, bumpSelectSync] = useState(0);
const ruleFormRef = useRef(null);
const groupsMenuRef = useRef(null);
const contextMenuRef = useRef(null);
const contextMenuAnchorRef = useRef(null);
const contextMenuAnimationRef = useRef(null);
const draftNameInputRef = useRef(null);
const rulesSearchTimerRef = useRef(null);
const groupSearchTimerRef = useRef(null);
const rulesRequestRef = useRef(0);
const visualRequestRef = useRef(0);
const groupSearchRequestRef = useRef(0);
const subgroupRequestRef = useRef(0);
const rulesVersionIdRef = useRef('');
const dirtyScopeRef = useRef(null);
if (!dirtyScopeRef.current) {
dirtyScopeRef.current = createDirtyScope(
'schedule-constructor',
'В конструкторе есть несохранённые изменения правила. Покинуть раздел?'
);
}
useEffect(() => () => {
dirtyScopeRef.current?.dispose();
window.clearTimeout(rulesSearchTimerRef.current);
window.clearTimeout(groupSearchTimerRef.current);
}, []);
const selectedVersion = useMemo(
() => scheduleVersions.find(version => String(version.id) === String(versionId)) || null,
[scheduleVersions, versionId]
);
const versionContext = useMemo(() => versionContextTexts(selectedVersion), [selectedVersion]);
const editorDisabled = !selectedVersion;
const ruleSemesterId = selectedVersion ? String(selectedVersion.semesterId) : '';
const years = useMemo(() => academicYearsFromSemesters(semesters), [semesters]);
const yearSemesters = useMemo(
() => semestersForAcademicYear(semesters, versionYearId),
[semesters, versionYearId]
);
const orderedLessonTypeOptions = useMemo(
() => orderedLessonTypes(lessonTypes).map(type => ({ value: type.id, label: type.name || type.lessonType || `ID ${type.id}` })),
[lessonTypes]
);
const timeSlotOptions = useMemo(() => timeSlots.map(toTimeSlotOption), [timeSlots]);
const effectiveVisualPeriod = useMemo(
() => effectiveVisualPeriodFrom({ academicYears, semesters, visualYearId, visualSemesterId }),
[academicYears, semesters, visualYearId, visualSemesterId]
);
const latestRef = useRef(null);
latestRef.current = {
subjects, lessonTypes, timeSlots, academicYears, semesters, approvedPreferences,
groups, subgroups, selectedGroupIds, slots, typeLimits, ruleId, ruleSubjectId,
scheduleVersions, versionId, versionYearId, versionSemesterId, selectedVersion,
rules, visualRules, rulesPage, rulesSize, rulesSearch,
visualOpen, visualPeriodTouched, visualYearId, visualSemesterId,
conflict, contextMenu, moveSlot, draftBusy
};
// --- Инициализация ---
useEffect(() => {
let cancelled = false;
async function initialize() {
try {
const [, , yearData] = await Promise.all([
loadBaseLists(),
loadTimeSlotsList(),
loadYears(),
loadPreferences()
]);
if (cancelled) return;
const versionState = await loadScheduleVersions({
semesterId: yearData.semesterId,
yearId: yearData.academicYearId
});
if (cancelled) return;
setSlots([emptySlot()]);
await loadRules({ version: versionState.selected, semesters: yearData.semesters });
dirtyScopeRef.current?.clear();
} catch (error) {
if (cancelled) return;
setRuleAlert({ message: error.message || 'Ошибка загрузки данных расписания', type: 'error' });
} finally {
if (!cancelled) setInitialPhase('ready');
}
}
void initialize();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
async function loadBaseLists() {
const [subjectList, lessonTypeList] = await Promise.all([fetchSubjects(), fetchLessonTypes()]);
setSubjects(subjectList);
setLessonTypes(lessonTypeList);
setSubgroups([]);
await searchRuleGroups('');
}
async function loadTimeSlotsList() {
const slotList = await fetchTimeSlots();
setTimeSlots(slotList);
}
async function loadPreferences() {
const preferences = await fetchApprovedTeacherPreferences();
setApprovedPreferences(preferences || []);
}
async function loadYears() {
const yearList = await fetchAcademicYears();
const flatSemesters = yearList.flatMap(year => year.semesters || []);
setAcademicYears(yearList);
setSemesters(flatSemesters);
const selected = resolveAcademicPeriod(flatSemesters, {
academicYearId: window.localStorage.getItem(SCHEDULE_YEAR_STORAGE_KEY),
semesterId: window.localStorage.getItem(SCHEDULE_SEMESTER_STORAGE_KEY),
date: formatLocalDate(new Date())
});
setVersionYearId(selected.academicYearId);
setVersionSemesterId(selected.semesterId);
selectDefaultVisualPeriod({ semesters: flatSemesters, formSemesterId: '', rules: [] });
return {
years: yearList,
semesters: flatSemesters,
academicYearId: selected.academicYearId,
semesterId: selected.semesterId
};
}
function defaultScheduleSemesterId(semesterList) {
return resolveAcademicPeriod(semesterList, {
academicYearId: window.localStorage.getItem(SCHEDULE_YEAR_STORAGE_KEY),
semesterId: window.localStorage.getItem(SCHEDULE_SEMESTER_STORAGE_KEY),
date: formatLocalDate(new Date())
}).semesterId;
}
async function loadScheduleVersions({ preferredVersionId = null, semesterId: semesterIdOverride, yearId: yearIdOverride } = {}) {
const latest = latestRef.current;
const semesterId = semesterIdOverride ?? latest.versionSemesterId ?? defaultScheduleSemesterId(latest.semesters);
if (!semesterId) {
setScheduleVersions([]);
setVersionId('');
return { versions: [], selected: null };
}
window.localStorage.setItem(SCHEDULE_YEAR_STORAGE_KEY, yearIdOverride ?? latest.versionYearId ?? '');
window.localStorage.setItem(SCHEDULE_SEMESTER_STORAGE_KEY, semesterId);
const loaded = (await fetchScheduleVersions(semesterId))
.filter(version => ['PUBLISHED', 'DRAFT'].includes(version.status));
const requestedVersionId = preferredVersionId
|| window.localStorage.getItem(SCHEDULE_OPEN_VERSION_STORAGE_KEY)
|| window.localStorage.getItem(SCHEDULE_VERSION_STORAGE_KEY)
|| '';
window.localStorage.removeItem(SCHEDULE_OPEN_VERSION_STORAGE_KEY);
const selected = chooseScheduleVersion(loaded, semesterId, requestedVersionId);
setScheduleVersions(loaded);
setVersionId(selected ? String(selected.id) : '');
if (selected) {
window.localStorage.setItem(SCHEDULE_VERSION_STORAGE_KEY, String(selected.id));
}
return { versions: loaded, selected };
}
async function loadRules(overrides = {}) {
const latest = { ...latestRef.current, ...overrides };
const request = ++rulesRequestRef.current;
visualRequestRef.current += 1;
setVisualRules([]);
setRules([]);
setRulesPageData(null);
setRulesPhase('loading');
setRulesMessage('Загрузка...');
const version = overrides.version !== undefined
? overrides.version
: (latest.scheduleVersions || []).find(item => String(item.id) === String(latest.versionId)) || null;
if (!version) {
setRulesPhase('empty');
setRulesMessage('Нет расписания для редактирования');
setVisualStageMessage('Нет выбранного расписания');
return;
}
let page = overrides.page !== undefined ? overrides.page : latest.rulesPage;
if (rulesVersionIdRef.current !== String(version.id)) {
rulesVersionIdRef.current = String(version.id);
page = 0;
}
const size = overrides.size !== undefined ? overrides.size : latest.rulesSize;
const query = overrides.query !== undefined ? overrides.query : latest.rulesSearch;
setRulesPage(page);
try {
const pageData = await fetchScheduleRulesPage({ versionId: version.id, page, size, query });
if (request !== rulesRequestRef.current) return;
if (page > 0 && page >= pageData.totalPages) {
const lastPage = Math.max(0, pageData.totalPages - 1);
setRulesPage(lastPage);
return loadRules({ ...overrides, page: lastPage });
}
setRules(pageData.items || []);
setRulesPageData(pageData);
setRulesPhase('ready');
if (!latest.visualPeriodTouched) {
selectDefaultVisualPeriod({
semesters: overrides.semesters ?? latest.semesters,
formSemesterId: version.semesterId,
rules: pageData.items || []
});
}
if (latest.visualOpen) await loadVisualRules(version);
await openRequestedRule(version);
} catch (error) {
if (request !== rulesRequestRef.current) return;
setRulesPhase('error');
setRulesMessage(`Ошибка загрузки: ${error.message}`);
setVisualStageMessage('Ошибка загрузки правил расписания');
}
}
async function openRequestedRule(version) {
const requestedRuleId = window.localStorage.getItem(SCHEDULE_OPEN_RULE_STORAGE_KEY);
if (!requestedRuleId) return;
window.localStorage.removeItem(SCHEDULE_OPEN_RULE_STORAGE_KEY);
const rule = await fetchScheduleRule(requestedRuleId);
if (!rule || String(rule.scheduleVersionId) !== String(version?.id)) {
setRuleAlert({ message: 'Не удалось найти выбранное правило в этом расписании.', type: 'error' });
return;
}
await fillRuleForm(rule);
}
// --- Версии расписания ---
async function handleVersionYearChange(event) {
const yearId = event.target.value || '';
if (dirtyScopeRef.current?.isDirty() && !(await confirmAbandonDirtyChanges())) {
bumpSelectSync(value => value + 1);
return;
}
const nextYearSemesters = semestersForAcademicYear(latestRef.current.semesters, yearId);
const selected = resolveAcademicPeriod(nextYearSemesters, { date: formatLocalDate(new Date()) });
await applyVersionPeriodChange(yearId, selected.semesterId);
}
async function handleVersionSemesterChange(event) {
if (dirtyScopeRef.current?.isDirty() && !(await confirmAbandonDirtyChanges())) {
bumpSelectSync(value => value + 1);
return;
}
await applyVersionPeriodChange(latestRef.current.versionYearId, event.target.value || '');
}
async function applyVersionPeriodChange(yearId, semesterId) {
setVersionYearId(yearId);
setVersionSemesterId(semesterId);
window.localStorage.setItem(SCHEDULE_YEAR_STORAGE_KEY, yearId);
window.localStorage.setItem(SCHEDULE_SEMESTER_STORAGE_KEY, semesterId);
try {
const versionState = await loadScheduleVersions({ semesterId, yearId });
resetRuleForm();
await loadRules({ version: versionState.selected });
} catch (error) {
setRuleAlert({ message: error.message || 'Не удалось сменить учебный период', type: 'error' });
}
}
async function handleVersionChange(event) {
const requestedValue = event.target.value || '';
if (dirtyScopeRef.current?.isDirty() && !(await confirmAbandonDirtyChanges())) {
bumpSelectSync(value => value + 1);
return;
}
if (requestedValue) {
window.localStorage.setItem(SCHEDULE_VERSION_STORAGE_KEY, requestedValue);
}
setVersionId(requestedValue);
resetRuleForm();
const selected = latestRef.current.scheduleVersions
.find(version => String(version.id) === String(requestedValue)) || null;
await loadRules({ version: selected });
}
function openDraftModal() {
const base = latestRef.current.selectedVersion;
if (!base || latestRef.current.draftBusy) return;
setDraftName('');
setDraftError('');
setDraftOpen(true);
}
useEffect(() => {
if (draftOpen) draftNameInputRef.current?.focus();
}, [draftOpen]);
function closeDraftModal() {
if (latestRef.current.draftBusy) return;
setDraftOpen(false);
}
async function createDraft(event) {
event.preventDefault();
const base = latestRef.current.selectedVersion;
const name = draftName.trim();
if (!base || !name || latestRef.current.draftBusy) return;
setDraftBusy(true);
try {
const created = await createScheduleDraftVersion({
semesterId: Number(base.semesterId),
name,
basedOnVersionId: Number(base.id)
});
setDraftOpen(false);
const versionState = await loadScheduleVersions({ preferredVersionId: created.id });
resetRuleForm();
await loadRules({ version: versionState.selected });
setRuleAlert({ message: `Черновик «${created.name}» создан и открыт.`, type: 'success' });
} catch (error) {
setDraftError(error.message || 'Не удалось создать черновик.');
} finally {
setDraftBusy(false);
}
}
// --- Справочники формы ---
async function searchRuleGroups(query = '', selectedIdsOverride = null) {
const request = ++groupSearchRequestRef.current;
const selectedBeforeSearch = selectedIdsOverride ?? latestRef.current.selectedGroupIds;
if (!latestRef.current.groups.length) {
setGroupsPhase('loading');
}
try {
const loaded = await fetchRuleGroupOptions(query);
if (request !== groupSearchRequestRef.current) return;
const currentSelected = selectedIdsOverride ?? latestRef.current.selectedGroupIds;
const selectedIds = currentSelected.length ? currentSelected : selectedBeforeSearch;
setGroups(current => {
const selectedGroups = current.filter(group => selectedIds.map(String).includes(String(group.id)));
return mergeEntitiesById([...selectedGroups, ...(loaded || [])]);
});
setGroupsPhase('ready');
} catch (error) {
if (request !== groupSearchRequestRef.current) return;
setGroupsPhase('error');
setGroupsError(error.message || 'Не удалось найти группы');
}
}
function handleGroupSearchInput(event) {
const value = event.target.value;
setGroupSearchQuery(value);
window.clearTimeout(groupSearchTimerRef.current);
groupSearchTimerRef.current = window.setTimeout(() => void searchRuleGroups(value), 320);
}
function toggleRuleGroupsMenu() {
setGroupsMenuOpen(current => !current);
setSubgroupMenuOpenIndex(null);
}
function handleRuleGroupsKeydown(event) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
toggleRuleGroupsMenu();
}
if (event.key === 'Escape') {
setGroupsMenuOpen(false);
}
}
async function handleGroupCheckboxChange(event) {
const value = Number(event.target.value);
const previous = latestRef.current.selectedGroupIds;
const next = event.target.checked
? [...previous, value]
: previous.filter(id => id !== value);
dirtyScopeRef.current?.mark();
setSelectedGroupIds(next);
await loadSubgroupsForGroups(next);
}
async function loadSubgroupsForGroups(groupIds) {
const request = ++subgroupRequestRef.current;
const normalizedIds = [...new Set((groupIds || []).map(Number).filter(Boolean))];
if (!normalizedIds.length) {
setSubgroups([]);
return true;
}
try {
const loaded = await fetchSubgroupsForGroups(normalizedIds);
if (request !== subgroupRequestRef.current) return false;
setSubgroups(loaded || []);
return true;
} catch (error) {
if (request !== subgroupRequestRef.current) return false;
setSubgroups([]);
setRuleAlert({ message: error.message || 'Не удалось загрузить подгруппы выбранных групп', type: 'error' });
return true;
}
}
function cacheRuleGroups(ids, names) {
const additions = (ids || []).map((id, index) => ({ id, name: names?.[index] || `Группа ${id}` }));
setGroups(current => mergeEntitiesById([...current, ...additions]));
}
// --- Форма правила ---
function resetRuleForm() {
setRuleId('');
setRuleSubjectId('');
setTypeLimits(emptyTypeLimitsState());
subgroupRequestRef.current += 1;
setSubgroups([]);
setSelectedGroupIds([]);
setSlots([emptySlot()]);
setGroupSearchQuery('');
setSubgroupMenuOpenIndex(null);
setRuleAlert(null);
dirtyScopeRef.current?.clear();
}
async function fillRuleForm(rule) {
setRuleId(String(rule.id));
setRuleSubjectId(rule.subjectId != null ? String(rule.subjectId) : '');
setTypeLimits(typeLimitsStateFromRule(rule));
cacheRuleGroups(rule.groupIds || [], rule.groupNames || []);
const ruleGroupIds = (rule.groupIds || []).map(Number);
setSelectedGroupIds(ruleGroupIds);
void searchRuleGroups(latestRef.current.groupSearchQuery, ruleGroupIds);
setSlots([]);
setSubgroupsLoading(true);
setSubgroupMenuOpenIndex(null);
if (!await loadSubgroupsForGroups(rule.groupIds || [])) return;
setSlots((rule.slots && rule.slots.length ? rule.slots : [{}]).map(slotFromRule));
setSubgroupsLoading(false);
setRuleAlert(null);
dirtyScopeRef.current?.clear();
ruleFormRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
function handleSubjectChange(event) {
dirtyScopeRef.current?.mark();
setRuleSubjectId(event.target.value);
}
function handleTypeLimitChange(key, field, value) {
dirtyScopeRef.current?.mark();
setTypeLimits(current => ({
...current,
[key]: { ...current[key], [field]: value }
}));
}
function handleSlotChange(index, patch) {
dirtyScopeRef.current?.mark();
setSlots(current => current.map((slot, slotIndex) => {
if (slotIndex !== index) return slot;
const next = { ...slot, ...patch };
if (patch.lessonTypeId !== undefined && !isLaboratorySlot(next, latestRef.current.lessonTypes)) {
next.subgroupIds = [];
}
return next;
}));
}
function handleSlotAdd() {
dirtyScopeRef.current?.mark();
setSlots(current => [...current, emptySlot()]);
}
function handleSlotRemove(index) {
dirtyScopeRef.current?.mark();
setSlots(current => {
const next = current.filter((_, slotIndex) => slotIndex !== index);
return next.length ? next : [emptySlot()];
});
}
function handleSlotSubgroupToggle(index) {
setSubgroupMenuOpenIndex(current => (current === index ? null : index));
setGroupsMenuOpen(false);
}
async function saveRule(event) {
event.preventDefault();
setRuleAlert(null);
const latest = latestRef.current;
let payload = null;
const id = latest.ruleId;
try {
const slotsPayload = formSlotsToPayloads(latest.slots, {
lessonTypes: latest.lessonTypes,
subgroups: latest.subgroups,
selectedGroupIds: latest.selectedGroupIds
});
payload = buildRulePayloadFromForm({
ruleId: latest.ruleId,
subjectId: latest.ruleSubjectId,
semesterId: latest.selectedVersion?.semesterId,
versionId: latest.versionId,
typeLimits: typeLimitsPayload(latest.typeLimits),
groupIds: latest.selectedGroupIds,
slots: slotsPayload,
lessonTypes: latest.lessonTypes
});
const saved = await saveScheduleRule(payload, id || null);
await finishSuccessfulRuleSave(saved, id);
} catch (error) {
if (payload && isScheduleRuleConflictError(error)) {
openConflictModal(error, payload, id);
return;
}
setRuleAlert({ message: error.message || 'Ошибка сохранения правила', type: 'error' });
}
}
async function finishSuccessfulRuleSave(saved, previousRuleId) {
const mode = previousRuleId ? 'update' : 'create';
const savedVersionId = latestRef.current.versionId;
dirtyScopeRef.current?.clear();
resetRuleForm();
trackUxEvent('правило_расписания_сохранено', { mode, versionId: savedVersionId });
setRuleAlert({ message: `Правило "${saved.subjectName || 'расписания'}" сохранено`, type: 'success' });
await loadRules();
}
// --- Конфликты правил ---
function openConflictModal(error, pendingPayload, pendingRuleId) {
const latest = latestRef.current;
const conflictRule = error.data.conflictRule;
const dictionaries = { groups: latest.groups, semesters: latest.semesters };
setConflict({
conflictRule: clonePlain(conflictRule),
conflictFields: Array.isArray(error.data.conflictFields) ? [...error.data.conflictFields] : [],
reasons: conflictReasonLabels(error.data.conflictFields, error.data.conflictReasons),
pendingPayload: clonePlain(pendingPayload),
pendingRuleId: pendingRuleId || '',
summary: error.message || 'Ранее созданное правило занимает выбранный слот.',
newTitle: ruleDraftTitle(pendingPayload, latest.subjects),
newMeta: ruleDraftMeta(pendingPayload, dictionaries),
existingTitle: ruleTitle(conflictRule, latest.subjects),
existingMeta: ruleMeta(conflictRule, dictionaries),
slots: (conflictRule.slots || []).map(slot => ({ ...slotFromRule(slot), original: slot }))
});
setConflictAlert(null);
}
function closeConflictModal() {
setConflict(null);
setConflictAlert(null);
}
function handleConflictSlotChange(index, patch) {
setConflict(current => (current ? {
...current,
slots: current.slots.map((slot, slotIndex) => (
slotIndex === index ? { ...slot, ...patch } : slot
))
} : current));
}
function readConflictSlots(validate) {
const resolution = latestRef.current.conflict;
return (resolution?.slots || []).map((slot, index) => {
const original = resolution.conflictRule?.slots?.[index] || {};
const payload = {
id: original.id || null,
dayOfWeek: Number(slot.dayOfWeek),
parity: slot.parity,
timeSlotId: Number(slot.timeSlotId),
subgroupId: original.subgroupId || null,
subgroupIds: slotSubgroupIds(original),
teacherId: Number(slot.teacherId),
classroomId: Number(slot.classroomId),
lessonTypeId: Number(original.lessonTypeId),
lessonFormat: original.lessonFormat || 'Очно'
};
if (validate && (!payload.dayOfWeek || !payload.parity || !payload.timeSlotId
|| !payload.teacherId || !payload.classroomId || !payload.lessonTypeId || !payload.lessonFormat)) {
throw new Error(`Заполните слот ${index + 1} конфликтующего правила`);
}
return payload;
});
}
async function resolveConflict(event) {
event.preventDefault();
const currentResolution = latestRef.current.conflict;
if (!currentResolution) return;
setConflictAlert(null);
setConflictBusy(true);
try {
const conflictPayload = buildRulePayloadFromRule(
currentResolution.conflictRule,
readConflictSlots(true),
latestRef.current.versionId
);
await saveScheduleRule(conflictPayload, currentResolution.conflictRule.id);
try {
const saved = await saveScheduleRule(
currentResolution.pendingPayload,
currentResolution.pendingRuleId || null
);
closeConflictModal();
await finishSuccessfulRuleSave(saved, currentResolution.pendingRuleId);
} catch (retryError) {
if (isScheduleRuleConflictError(retryError)) {
await loadRules();
openConflictModal(retryError, currentResolution.pendingPayload, currentResolution.pendingRuleId);
setConflictAlert({
message: 'Один конфликт устранён, но найдено ещё одно занятое место. Измените следующее правило и повторите сохранение.',
type: 'error'
});
return;
}
throw retryError;
}
} catch (error) {
if (isScheduleRuleConflictError(error)) {
setConflictAlert({
message: `Выбранное место для переносимого правила тоже занято: ${ruleTitle(error.data.conflictRule, latestRef.current.subjects)}. Выберите другой день, пару, преподавателя или аудиторию.`,
type: 'error'
});
return;
}
setConflictAlert({ message: error.message || 'Ошибка разрешения конфликта', type: 'error' });
} finally {
setConflictBusy(false);
}
}
// --- Реестр правил ---
function handleRulesSearchInput(event) {
const value = event.target.value;
setRulesSearch(value);
window.clearTimeout(rulesSearchTimerRef.current);
rulesSearchTimerRef.current = window.setTimeout(() => {
setRulesPage(0);
void loadRules({ page: 0, query: value });
}, 300);
}
async function handleEditRule(id) {
const rule = [...latestRef.current.rules, ...latestRef.current.visualRules]
.find(item => String(item.id) === String(id))
|| await fetchScheduleRule(id);
void fillRuleForm(rule);
}
async function handleDeleteRule(id) {
if (!await confirmAction({
title: 'Удалить правило расписания?',
message: 'Правило и связанные с ним слоты будут удалены из выбранной версии.',
confirmLabel: 'Удалить правило',
danger: true
})) return;
try {
await deleteScheduleRule(id);
setRuleAlert({ message: 'Правило расписания удалено', type: 'success' });
await loadRules();
} catch (error) {
setRuleAlert({ message: error.message || 'Ошибка удаления правила', type: 'error' });
}
}
// --- Визуальное расписание ---
function toggleVisualDrawer() {
if (latestRef.current.visualOpen) {
closeVisualDrawer();
} else {
openVisualDrawer();
}
}
function openVisualDrawer() {
setVisualOpen(true);
void loadVisualRules();
}
function closeVisualDrawer() {
closeVisualContextMenu();
setVisualOpen(false);
}
async function loadVisualRules(versionOverride) {
const version = versionOverride ?? latestRef.current.selectedVersion;
if (!version) return;
const request = ++visualRequestRef.current;
setVisualStageMessage('Загрузка визуального расписания...');
try {
const loaded = await fetchScheduleRules(version.id);
if (request !== visualRequestRef.current) return;
setVisualRules(loaded || []);
setVisualStageMessage(null);
} catch (error) {
if (request === visualRequestRef.current) {
setVisualStageMessage(error.message || 'Ошибка загрузки визуального расписания');
}
}
}
function selectDefaultVisualPeriod({ semesters: semesterList, formSemesterId, rules: ruleList } = {}) {
const semList = semesterList ?? latestRef.current.semesters;
const listValue = ruleList ?? latestRef.current.rules;
const formSemester = semList.find(item => String(item.id) === String(formSemesterId));
const ruleSemester = semList.find(item =>
(listValue || []).some(rule => String(rule.semesterId) === String(item.id)));
const semester = formSemester || ruleSemester || semList[0];
setVisualPeriodBySemesterId(semester?.id || '', semList);
}
function setVisualPeriodBySemesterId(semesterId, semesterListOverride) {
const semesterList = semesterListOverride ?? latestRef.current.semesters;
const semester = semesterList.find(item => String(item.id) === String(semesterId));
setVisualSemesterId(semester ? String(semester.id) : '');
setVisualYearId(semester ? String(semester.academicYearId) : '');
}
function handleVisualYearChange(event) {
const yearId = event.target.value || '';
const nextYearSemesters = latestRef.current.semesters
.filter(semester => String(semester.academicYearId) === String(yearId));
setVisualYearId(yearId);
setVisualSemesterId(nextYearSemesters[0] ? String(nextYearSemesters[0].id) : '');
setVisualPeriodTouched(true);
setVisualGroupFilterTouched(false);
}
function handleVisualSemesterChange(event) {
setVisualSemesterId(event.target.value || '');
setVisualPeriodTouched(true);
setVisualGroupFilterTouched(false);
}
function handleVisualUseForm() {
const formSemesterId = latestRef.current.selectedVersion?.semesterId;
if (formSemesterId) {
setVisualPeriodTouched(true);
setVisualPeriodBySemesterId(formSemesterId);
}
setVisualGroupFilterTouched(false);
setVisualSelectedGroupIds([]);
}
function handleVisualSelectAll() {
const latest = latestRef.current;
const period = effectiveVisualPeriodFrom(latest);
const periodRules = latest.visualRules
.filter(rule => String(rule.semesterId) === period.semesterId);
setVisualSelectedGroupIds(collectVisualGroups(periodRules, latest.groups).map(group => String(group.id)));
setVisualGroupFilterTouched(true);
}
function handleVisualGroupFilterChange(event) {
const value = String(event.target.value);
setVisualSelectedGroupIds(current => (
event.target.checked
? [...current, value]
: current.filter(id => id !== value)
));
setVisualGroupFilterTouched(true);
}
function openVisualContextMenu(button) {
const rule = [...latestRef.current.rules, ...latestRef.current.visualRules]
.find(item => String(item.id) === String(button.dataset.ruleId));
if (!rule) {
setRuleAlert({ message: 'Правило не найдено. Обновите список правил.', type: 'error' });
return;
}
contextMenuAnchorRef.current = button;
setContextMenu({
target: {
ruleId: button.dataset.ruleId,
slotId: button.dataset.slotId || '',
slotIndex: button.dataset.slotIndex || ''
},
position: null
});
}
useEffect(() => {
if (!contextMenu || contextMenu.position) return undefined;
const menu = contextMenuRef.current;
const anchor = contextMenuAnchorRef.current;
if (!menu || !anchor) {
setContextMenu(null);
return undefined;
}
const position = calculateVisualContextMenuPosition({
anchorRect: anchor.getBoundingClientRect(),
menuWidth: menu.offsetWidth,
menuHeight: menu.offsetHeight,
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight
});
setContextMenu(current => (current ? { ...current, position } : null));
contextMenuAnimationRef.current?.cancel();
const positionedTransform = `translate3d(${position.left}px, ${position.top}px, 0)`;
contextMenuAnimationRef.current = menu.animate(
[
{ transform: positionedTransform },
{ transform: positionedTransform }
],
{ duration: 1, fill: 'forwards' }
);
menu.querySelector('button')?.focus();
return undefined;
}, [contextMenu]);
function closeVisualContextMenu() {
contextMenuAnimationRef.current?.cancel();
contextMenuAnimationRef.current = null;
setContextMenu(null);
}
useEffect(() => {
if (!contextMenu) return undefined;
function handleDocumentClick(event) {
if (event.target.closest('#schedule-visual-context-menu')) return;
if (event.target.closest('.schedule-visual-edit-rule')) return;
closeVisualContextMenu();
}
document.addEventListener('click', handleDocumentClick);
return () => document.removeEventListener('click', handleDocumentClick);
}, [contextMenu]);
async function handleVisualContextMenuClick(event) {
const actionButton = event.target.closest('button[data-action]');
const current = latestRef.current.contextMenu;
if (!actionButton || !current?.target) return;
const action = actionButton.dataset.action;
const target = { ...current.target };
const rule = [...latestRef.current.rules, ...latestRef.current.visualRules]
.find(item => String(item.id) === String(target.ruleId));
if (!rule) {
closeVisualContextMenu();
setRuleAlert({ message: 'Правило не найдено. Обновите список правил.', type: 'error' });
return;
}
if (action === 'edit-rule') {
closeVisualContextMenu();
void fillRuleForm(rule);
closeVisualDrawer();
setRuleAlert({ message: 'Правило открыто для редактирования', type: 'success' });
return;
}
if (action === 'move-slot') {
openMoveSlotModal(target);
return;
}
if (action === 'delete-rule') {
await deleteRuleFromVisual(rule);
}
}
async function deleteRuleFromVisual(rule) {
closeVisualContextMenu();
if (!await confirmAction({
title: 'Удалить правило расписания?',
message: `Будет удалено правило «${rule.subjectName || 'Без дисциплины'}» и его слоты.`,
confirmLabel: 'Удалить правило',
danger: true
})) return;
try {
await deleteScheduleRule(rule.id);
setRuleAlert({ message: 'Правило расписания удалено', type: 'success' });
await loadRules();
} catch (error) {
setRuleAlert({ message: error.message || 'Ошибка удаления правила', type: 'error' });
}
}
function openMoveSlotModal(target) {
const rule = [...latestRef.current.rules, ...latestRef.current.visualRules]
.find(item => String(item.id) === String(target.ruleId));
const slotIndex = findRuleSlotIndex(rule, target);
const slot = slotIndex >= 0 ? rule.slots[slotIndex] : null;
if (!rule || !slot) {
closeVisualContextMenu();
setRuleAlert({ message: 'Слот правила не найден. Обновите список правил.', type: 'error' });
return;
}
setMoveSlot({
ruleId: String(rule.id),
slotIndex,
dayOfWeek: slot.dayOfWeek ?? '',
timeSlotId: slot.timeSlotId ?? '',
title: rule.subjectName || 'Изменить день и пару',
summary: `Текущий слот: ${conflictSlotCurrentText(slot) || 'не указан'}`
});
setMoveSlotAlert(null);
closeVisualContextMenu();
}
function closeMoveSlotModal() {
setMoveSlot(null);
setMoveSlotAlert(null);
}
async function saveVisualSlotMove(event) {
event.preventDefault();
const pending = latestRef.current.moveSlot;
if (!pending) return;
setMoveSlotAlert(null);
const rule = [...latestRef.current.rules, ...latestRef.current.visualRules]
.find(item => String(item.id) === pending.ruleId);
const slot = rule?.slots?.[pending.slotIndex];
if (!rule || !slot) {
setMoveSlotAlert({ message: 'Слот правила не найден. Обновите список правил.', type: 'error' });
return;
}
const dayOfWeek = Number(pending.dayOfWeek);
const timeSlotId = Number(pending.timeSlotId);
if (!dayOfWeek || !timeSlotId) {
setMoveSlotAlert({ message: 'Выберите день и пару', type: 'error' });
return;
}
setMoveSlotBusy(true);
try {
const slotsPayload = (rule.slots || []).map(ruleSlotToPayload);
slotsPayload[pending.slotIndex] = {
...slotsPayload[pending.slotIndex],
dayOfWeek,
timeSlotId
};
const payload = buildRulePayloadFromRule(rule, slotsPayload, latestRef.current.versionId);
const saved = await saveScheduleRule(payload, rule.id);
closeMoveSlotModal();
setRuleAlert({ message: `Слот правила "${saved.subjectName || 'расписания'}" перенесён`, type: 'success' });
await loadRules();
} catch (error) {
if (isScheduleRuleConflictError(error)) {
setMoveSlotAlert({
message: `Новое место занято правилом "${ruleTitle(error.data.conflictRule, latestRef.current.subjects)}". Выберите другой день или пару.`,
type: 'error'
});
return;
}
setMoveSlotAlert({ message: error.message || 'Ошибка переноса слота', type: 'error' });
} finally {
setMoveSlotBusy(false);
}
}
// --- Глобальные обработчики ---
useEffect(() => {
function handleKeydown(event) {
if (event.key !== 'Escape') return;
if (draftOpen) {
closeDraftModal();
return;
}
if (latestRef.current.moveSlot) {
closeMoveSlotModal();
return;
}
if (latestRef.current.conflict) {
closeConflictModal();
return;
}
if (latestRef.current.contextMenu) {
closeVisualContextMenu();
return;
}
closeVisualDrawer();
}
document.addEventListener('keydown', handleKeydown);
return () => document.removeEventListener('keydown', handleKeydown);
}, [draftOpen]);
useEffect(() => {
if (!groupsMenuOpen && subgroupMenuOpenIndex === null) return undefined;
function handleDocumentClick(event) {
if (groupsMenuOpen && !groupsMenuRef.current?.contains(event.target)) {
setGroupsMenuOpen(false);
}
if (subgroupMenuOpenIndex !== null && !event.target.closest('.slot-subgroup-multi')) {
setSubgroupMenuOpenIndex(null);
}
}
document.addEventListener('click', handleDocumentClick);
return () => document.removeEventListener('click', handleDocumentClick);
}, [groupsMenuOpen, subgroupMenuOpenIndex]);
// --- Производные данные визуального расписания ---
const visualView = useMemo(() => {
if (!visualOpen) return null;
if (!effectiveVisualPeriod.semesterId) {
return {
summary: 'Выберите учебный год и семестр',
state: 'Выберите период, чтобы увидеть правила конкретного семестра',
groups: []
};
}
if (!visualRules.length) {
return {
summary: 'Нет сохранённых правил',
state: 'Сохраните правила расписания, чтобы увидеть матрицу по группам',
groups: []
};
}
const periodRules = visualRules.filter(rule => String(rule.semesterId) === effectiveVisualPeriod.semesterId);
const periodSemester = semesters.find(semester => String(semester.id) === effectiveVisualPeriod.semesterId);
const periodLabel = periodSemester ? shortSemesterLabel(periodSemester) : 'Семестр не выбран';
if (!periodRules.length) {
return {
summary: `${periodLabel} · правил нет`,
state: 'В выбранном семестре нет сохранённых правил расписания',
groups: []
};
}
const allGroups = collectVisualGroups(periodRules, groups);
const availableIds = new Set(allGroups.map(group => String(group.id)));
let selected = visualSelectedGroupIds.filter(id => availableIds.has(id));
if (!visualGroupFilterTouched) {
const formIds = selectedGroupIds.map(String).filter(id => availableIds.has(id));
if (formIds.length) {
selected = formIds;
} else if (!selected.length && allGroups.length) {
selected = [String(allGroups[0].id)];
}
}
const slotList = timeSlots.length
? timeSlots.map(visualSlotFromTimeSlot)
: collectVisualSlotsFromRules(periodRules);
if (!allGroups.length || !slotList.length) {
return {
summary: 'Недостаточно данных для построения',
state: 'У правил должны быть группы и слоты занятий',
groups: allGroups,
selected
};
}
if (!selected.length) {
return {
summary: 'Выберите группы для просмотра',
state: 'Отметьте одну или несколько групп в фильтре',
groups: allGroups,
selected
};
}
const lessonsByCell = buildVisualLessonsByCell(new Set(selected), periodRules, {
lessonTypes,
semesters,
groups
});
const columns = allGroups.filter(group => selected.includes(String(group.id)));
const rows = buildVisualRows(columns, slotList, lessonsByCell);
if (!rows.length) {
return {
summary: `${periodLabel} · ${columns.length} ${groupCountLabel(columns.length)} · пар не найдено`,
state: 'Для выбранных групп нет активных пар в правилах выбранного семестра',
groups: allGroups,
selected
};
}
return {
summary: `${periodLabel} · ${periodRules.length} ${ruleCountLabel(periodRules.length)} · ${columns.length} ${groupCountLabel(columns.length)} · ${rows.length} ${rowCountLabel(rows.length)}`,
state: null,
groups: allGroups,
selected,
columns,
rows
};
}, [visualOpen, effectiveVisualPeriod.semesterId, visualRules, visualGroupFilterTouched,
visualSelectedGroupIds, selectedGroupIds, timeSlots, lessonTypes, semesters, groups]);
const groupsText = !groups.length
? 'Нет групп'
: !selectedGroupIds.length
? 'Выберите группы...'
: selectedGroupIds.length === 1
? (groups.find(group => String(group.id) === String(selectedGroupIds[0]))?.name || 'Выбрана 1 группа')
: `Выбрано групп: ${selectedGroupIds.length}`;
const draftCopyText = selectedVersion
? `За основу будет взято «${selectedVersion.name}» — все его правила и слоты будут скопированы.`
: 'Все правила выбранного расписания будут скопированы.';
const draftSummaryText = selectedVersion
? `${selectedVersion.semesterLabel} · версия ${selectedVersion.versionNumber}`
: '';
return (
<>
<div className="schedule-initial-state" id="schedule-initial-state" role="status" aria-live="polite" hidden={initialPhase !== 'loading'}>
<span className="state-spinner" aria-hidden="true" />
<span>Загружаем конструктор правил…</span>
</div>
<div
className={`schedule-initial-content${initialPhase === 'loading' ? ' is-loading' : ''}`}
id="schedule-initial-content"
aria-busy={initialPhase === 'loading'}
>
<section className="version-constructor-bar" id="schedule-version-context" data-status={versionContext.status}>
<div>
<span className="version-eyebrow" id="schedule-version-eyebrow">{versionContext.eyebrow}</span>
<h2 id="schedule-version-title">{versionContext.title}</h2>
<p id="schedule-version-note">{versionContext.note}</p>
</div>
<div className="version-constructor-controls">
<div className="version-constructor-field">
<label htmlFor="schedule-version-year">Учебный год</label>
<select id="schedule-version-year" value={versionYearId} onChange={handleVersionYearChange}>
{initialPhase === 'loading' ? (
<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="version-constructor-field">
<label htmlFor="schedule-version-semester">Семестр</label>
<select id="schedule-version-semester" value={versionSemesterId} onChange={handleVersionSemesterChange}>
{initialPhase === 'loading' ? (
<option value="">Сначала выберите год</option>
) : yearSemesters.length ? (
yearSemesters.map(semester => (
<option key={semester.id} value={semester.id}>{shortSemesterLabel(semester)}</option>
))
) : (
<option value="">Семестры не настроены</option>
)}
</select>
</div>
<div className="version-constructor-field version-constructor-field-wide">
<label htmlFor="schedule-version-select">Версия расписания</label>
<select id="schedule-version-select" value={versionId} onChange={handleVersionChange}>
{initialPhase === 'loading' || !versionSemesterId ? (
<option value="">Сначала выберите семестр</option>
) : scheduleVersions.length ? (
scheduleVersionOptionGroups(scheduleVersions).map(group => (
<optgroup key={group.label} label={group.label}>
{group.items.map(version => (
<option key={version.id} value={String(version.id)}>
{version.semesterLabel} · v{version.versionNumber} · {version.name}
</option>
))}
</optgroup>
))
) : (
<option value="">Расписания не созданы</option>
)}
</select>
</div>
<div className="version-constructor-actions">
<button type="button" className="btn btn-sm btn-primary" id="schedule-version-create-draft" onClick={openDraftModal} disabled={!selectedVersion}>Создать черновик</button>
<button type="button" className="btn btn-sm btn-secondary" id="schedule-version-manage" onClick={() => { window.location.hash = 'schedule-versions'; }}>Все версии</button>
</div>
</div>
</section>
{draftOpen && (
<div
className="version-modal schedule-draft-modal"
id="schedule-draft-modal"
onClick={event => {
if (event.target === event.currentTarget) closeDraftModal();
}}
>
<div className="version-modal-card" role="dialog" aria-modal="true" aria-labelledby="schedule-draft-title">
<button type="button" className="btn btn-icon-md btn-ghost version-modal-close" id="schedule-draft-close" aria-label="Закрыть окно" onClick={closeDraftModal}>&times;</button>
<span className="version-eyebrow">Новая рабочая копия</span>
<h3 id="schedule-draft-title">Создать черновик</h3>
<p id="schedule-draft-copy">{draftCopyText}</p>
<form id="schedule-draft-form" onSubmit={createDraft}>
<label htmlFor="schedule-draft-name">Название черновика</label>
<input
id="schedule-draft-name"
ref={draftNameInputRef}
type="text"
maxLength="160"
required
placeholder="Например, расписание после распределения аудиторий"
value={draftName}
onChange={event => setDraftName(event.target.value)}
/>
<div className="version-modal-summary" id="schedule-draft-summary">{draftError || draftSummaryText}</div>
<div className="version-modal-actions">
<button type="button" className="btn btn-md btn-ghost" id="schedule-draft-cancel" onClick={closeDraftModal}>Отмена</button>
<button type="submit" className="btn btn-md btn-primary" id="schedule-draft-submit" disabled={draftBusy}>
{draftBusy ? 'Создаём…' : 'Создать и открыть'}
</button>
</div>
</form>
</div>
</div>
)}
<div className={`card create-card${editorDisabled ? ' is-disabled' : ''}`} id="schedule-rule-editor">
<div className="card-header-row">
<h2 id="schedule-rule-form-title">{ruleId ? 'Редактирование правила расписания' : 'Новое правило расписания'}</h2>
<button type="button" className="btn btn-sm btn-ghost" id="schedule-rule-reset" onClick={resetRuleForm} disabled={editorDisabled}>Очистить форму</button>
</div>
<form id="schedule-rule-form" noValidate onSubmit={saveRule} ref={ruleFormRef}>
<input type="hidden" id="schedule-rule-id" value={ruleId} />
<div className="form-row">
<div className="form-group">
<label htmlFor="schedule-rule-subject">Дисциплина</label>
<select id="schedule-rule-subject" required disabled={editorDisabled} value={ruleSubjectId} onChange={handleSubjectChange}>
{!subjects.length ? (
<option value="">Загрузка...</option>
) : (
<option value="">Выберите дисциплину</option>
)}
{subjects.map(subject => (
<option key={subject.id} value={subject.id}>{subject.name}</option>
))}
</select>
</div>
<div className="form-group">
<label htmlFor="schedule-rule-semester">Семестр</label>
<select id="schedule-rule-semester" required disabled value={ruleSemesterId} onChange={() => {}}>
{!semesters.length ? (
<option value="">Загрузка...</option>
) : (
<option value="">Выберите семестр</option>
)}
{semesters.map(semester => (
<option key={semester.id} value={semester.id}>{semesterLabel(semester)}</option>
))}
</select>
</div>
</div>
<div className="schedule-hours-panel">
<label className="schedule-section-label">Часы и недели начала</label>
<div className="schedule-hours-grid">
{LESSON_TYPE_LIMITS.map(config => (
<div className="schedule-hours-row" key={config.key}>
<div className="schedule-hours-title">{config.label}</div>
<div className="form-group">
<label htmlFor={`schedule-rule-${config.key}-hours`}>Часы</label>
<input
type="number"
id={`schedule-rule-${config.key}-hours`}
min="0"
step="1"
required
disabled={editorDisabled}
value={typeLimits[config.key].hours}
onChange={event => handleTypeLimitChange(config.key, 'hours', event.target.value)}
/>
</div>
<div className="form-group">
<label htmlFor={`schedule-rule-${config.key}-start-week`}>Неделя начала</label>
<input
type="number"
id={`schedule-rule-${config.key}-start-week`}
min="1"
step="1"
required
disabled={editorDisabled}
value={typeLimits[config.key].startWeek}
onChange={event => handleTypeLimitChange(config.key, 'startWeek', event.target.value)}
/>
</div>
</div>
))}
</div>
</div>
<div className="schedule-groups-panel">
<label className="schedule-section-label">Группы</label>
<div id="schedule-rule-groups" className="custom-multi-select schedule-group-select" ref={groupsMenuRef}>
<div
className={`select-box${groupsMenuOpen ? ' active' : ''}`}
id="schedule-rule-groups-box"
role="button"
tabIndex={editorDisabled ? -1 : 0}
aria-expanded={groupsMenuOpen}
onClick={event => {
if (editorDisabled) return;
event.stopPropagation();
toggleRuleGroupsMenu();
}}
onKeyDown={handleRuleGroupsKeydown}
>
<span className="select-text" id="schedule-rule-groups-text">{groupsText}</span>
<svg className="dropdown-icon" width="12" height="8" viewBox="0 0 12 8" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 1.5L6 6.5L11 1.5" stroke="#9ca3af" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
{groupsMenuOpen && (
<div className="dropdown-menu open" id="schedule-rule-groups-menu" onClick={event => event.stopPropagation()}>
<input
type="search"
id="schedule-rule-group-search"
className="schedule-group-search"
placeholder="Найти группу"
aria-label="Поиск групп"
autoComplete="off"
value={groupSearchQuery}
onChange={handleGroupSearchInput}
/>
<div id="schedule-rule-group-checkboxes" className="checkbox-group-vertical schedule-group-checkboxes">
{groupsPhase === 'loading' ? (
<div className="loading-row"><span className="state-spinner" aria-hidden="true" />Поиск групп...</div>
) : groupsPhase === 'error' ? (
<div className="loading-row view-state-error">{groupsError}</div>
) : !groups.length ? (
<div className="loading-row">Нет групп</div>
) : groups.map(group => (
<label key={group.id} className="checkbox-item">
<input
type="checkbox"
value={group.id}
checked={selectedGroupIds.map(String).includes(String(group.id))}
onChange={handleGroupCheckboxChange}
/>
<span className="checkmark" />
<span>{group.name}</span>
</label>
))}
</div>
</div>
)}
</div>
</div>
<div className="schedule-slots-panel">
<div className="card-header-row">
<h2>Слоты правила</h2>
<button type="button" className="btn btn-sm btn-secondary" id="schedule-slot-add" onClick={handleSlotAdd} disabled={editorDisabled}>Добавить слот</button>
</div>
<div id="schedule-rule-slots" className="schedule-slots-list">
{subgroupsLoading ? (
<div className="loading-row">
<span className="state-spinner" aria-hidden="true" />Загрузка подгрупп выбранных групп...
</div>
) : slots.map((slot, index) => (
<RuleSlotRow
key={`${index}-${slot.id || 'new'}`}
slot={slot}
index={index}
slotCount={slots.length}
disabled={editorDisabled}
timeSlotOptions={timeSlotOptions}
lessonTypeOptions={orderedLessonTypeOptions}
lessonTypes={lessonTypes}
subgroups={subgroups}
selectedGroupIds={selectedGroupIds}
semesterId={ruleSemesterId}
preferences={approvedPreferences}
subgroupMenuOpen={subgroupMenuOpenIndex === index}
onToggleSubgroupMenu={handleSlotSubgroupToggle}
onChange={handleSlotChange}
onRemove={handleSlotRemove}
/>
))}
</div>
</div>
<div className="form-row schedule-rule-actions">
<button type="submit" className="btn btn-md btn-primary" disabled={editorDisabled}>Сохранить правило</button>
</div>
<FormAlert id="schedule-rule-alert" alert={ruleAlert} />
</form>
</div>
{conflict && (
<div
className="modal-overlay schedule-conflict-modal open"
id="schedule-conflict-modal"
role="dialog"
aria-modal="true"
aria-hidden="false"
onClick={event => {
if (event.target === event.currentTarget) closeConflictModal();
}}
>
<div className="modal-content schedule-conflict-card">
<button type="button" className="btn btn-icon-md btn-ghost modal-close" id="schedule-conflict-close" aria-label="Закрыть окно" onClick={closeConflictModal}>&times;</button>
<div className="schedule-conflict-head">
<span className="schedule-conflict-kicker">Конфликт слота</span>
<h2>Освободите место для нового правила</h2>
<p id="schedule-conflict-summary">{conflict.summary}</p>
<div className="schedule-conflict-reasons" id="schedule-conflict-reasons" hidden={!conflict.reasons.length}>
<span>Причина:</span>
{conflict.reasons.map(reason => <strong key={reason}>{reason}</strong>)}
</div>
</div>
<div className="schedule-conflict-rules">
<section className="schedule-conflict-rule-box">
<span>Новое правило</span>
<strong id="schedule-conflict-new-rule">{conflict.newTitle}</strong>
<small id="schedule-conflict-new-rule-meta">{conflict.newMeta}</small>
</section>
<section className="schedule-conflict-rule-box schedule-conflict-rule-box-active">
<span>Нужно изменить</span>
<strong id="schedule-conflict-existing-rule">{conflict.existingTitle}</strong>
<small id="schedule-conflict-existing-rule-meta">{conflict.existingMeta}</small>
</section>
</div>
<form id="schedule-conflict-form" className="schedule-conflict-form" noValidate onSubmit={resolveConflict}>
<div className="schedule-conflict-slots" id="schedule-conflict-slots">
{!conflict.slots.length ? (
<div className="loading-row">У конфликтующего правила нет слотов</div>
) : conflict.slots.map((slot, index) => (
<ConflictSlotRow
key={index}
slot={slot}
index={index}
conflictFields={conflict.conflictFields}
timeSlotOptions={timeSlotOptions}
onSlotChange={handleConflictSlotChange}
/>
))}
</div>
<FormAlert id="schedule-conflict-alert" alert={conflictAlert} />
<div className="schedule-conflict-actions">
<button type="button" className="btn btn-md btn-ghost" id="schedule-conflict-cancel" onClick={closeConflictModal}>Отмена</button>
<button type="submit" className="btn btn-md btn-primary" id="schedule-conflict-submit" disabled={conflictBusy}>
{conflictBusy ? 'Сохраняем...' : 'Сохранить и повторить'}
</button>
</div>
</form>
</div>
</div>
)}
<div
className="schedule-visual-context-menu"
id="schedule-visual-context-menu"
role="menu"
hidden={!contextMenu}
ref={contextMenuRef}
onClick={handleVisualContextMenuClick}
>
<button type="button" role="menuitem" data-action="edit-rule">Открыть правило</button>
<button type="button" role="menuitem" data-action="move-slot">Изменить день и пару</button>
<button type="button" role="menuitem" data-action="delete-rule" className="danger">Удалить правило</button>
</div>
{moveSlot && (
<div
className="modal-overlay schedule-move-slot-modal open"
id="schedule-move-slot-modal"
role="dialog"
aria-modal="true"
aria-hidden="false"
onClick={event => {
if (event.target === event.currentTarget) closeMoveSlotModal();
}}
>
<div className="modal-content schedule-move-slot-card">
<button type="button" className="btn btn-icon-md btn-ghost modal-close" id="schedule-move-slot-close" aria-label="Закрыть окно" onClick={closeMoveSlotModal}>&times;</button>
<div className="schedule-conflict-head">
<span className="schedule-conflict-kicker">Перенос слота</span>
<h2 id="schedule-move-slot-title">{moveSlot.title}</h2>
<p id="schedule-move-slot-summary">{moveSlot.summary}</p>
</div>
<form id="schedule-move-slot-form" className="schedule-move-slot-form" noValidate onSubmit={saveVisualSlotMove}>
<div className="form-row">
<div className="form-group">
<label htmlFor="schedule-move-slot-day">День</label>
<select
id="schedule-move-slot-day"
required
value={moveSlot.dayOfWeek}
onChange={event => setMoveSlot(current => (
current ? { ...current, dayOfWeek: Number(event.target.value) } : current
))}
>
<option value="">Выберите</option>
{DAY_OPTIONS.map(day => <option key={day.value} value={day.value}>{day.label}</option>)}
</select>
</div>
<div className="form-group">
<label htmlFor="schedule-move-slot-time">Пара</label>
<select
id="schedule-move-slot-time"
required
value={moveSlot.timeSlotId}
onChange={event => setMoveSlot(current => (
current ? { ...current, timeSlotId: Number(event.target.value) } : current
))}
>
<option value="">Выберите</option>
{timeSlotOptions.map(option => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</div>
</div>
<FormAlert id="schedule-move-slot-alert" alert={moveSlotAlert} />
<div className="schedule-conflict-actions">
<button type="button" className="btn btn-md btn-ghost" id="schedule-move-slot-cancel" onClick={closeMoveSlotModal}>Отмена</button>
<button type="submit" className="btn btn-md btn-primary" id="schedule-move-slot-submit" disabled={moveSlotBusy}>
{moveSlotBusy ? 'Сохраняем...' : 'Сохранить перенос'}
</button>
</div>
</form>
</div>
</div>
)}
<div className="card">
<div className="card-header-row">
<h2>Правила динамического расписания</h2>
<button className="btn btn-md btn-secondary" id="schedule-refresh" onClick={() => void loadRules()}>Обновить</button>
</div>
<div className="form-group">
<label htmlFor="schedule-rules-search">Поиск правил</label>
<input
type="search"
id="schedule-rules-search"
placeholder="Дисциплина, группа или ID правила"
value={rulesSearch}
onChange={handleRulesSearchInput}
/>
</div>
<div className="table-wrap">
<table id="schedule-table">
<colgroup>
<col className="schedule-rule-col-id" />
<col className="schedule-rule-col-subject" />
<col className="schedule-rule-col-semester" />
<col className="schedule-rule-col-groups" />
<col className="schedule-rule-col-load" />
<col className="schedule-rule-col-slots" />
<col className="schedule-rule-col-actions" />
</colgroup>
<thead>
<tr>
<th>ID</th>
<th>Дисциплина</th>
<th>Семестр</th>
<th>Группы</th>
<th>Нагрузка</th>
<th>Слоты</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="schedule-tbody">
{rulesPhase === 'loading' ? (
<tr><td colSpan="7" className="loading-row">Загрузка...</td></tr>
) : rulesPhase === 'error' || rulesPhase === 'empty' ? (
<tr><td colSpan="7" className="loading-row">{rulesMessage}</td></tr>
) : !rules.length ? (
<tr><td colSpan="7" className="loading-row">Правила не найдены</td></tr>
) : rules.map(rule => (
<RuleRow key={rule.id} rule={rule} onEdit={handleEditRule} onDelete={handleDeleteRule} />
))}
</tbody>
</table>
</div>
{rulesPhase === 'ready' && rulesPageData ? (
<Pagination
id="schedule-rules-pagination"
page={rulesPageData.page}
size={rulesPageData.size}
totalItems={rulesPageData.totalItems}
totalPages={rulesPageData.totalPages}
label="правил"
onPage={page => void loadRules({ page })}
onSize={size => void loadRules({ size, page: 0 })}
/>
) : (
<div className="pagination" id="schedule-rules-pagination" />
)}
</div>
<button
type="button"
className={`btn btn-icon-md btn-secondary schedule-visual-handle${visualOpen ? ' open' : ''}`}
id="schedule-visual-toggle"
aria-label={visualOpen ? 'Скрыть визуальное расписание' : 'Показать визуальное расписание'}
aria-expanded={visualOpen}
onClick={toggleVisualDrawer}
>
<svg className="schedule-visual-handle-icon" 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" />
</svg>
</button>
<div className="schedule-visual-backdrop" id="schedule-visual-backdrop" hidden={!visualOpen} onClick={closeVisualDrawer} />
<aside
className={`schedule-visual-drawer${visualOpen ? ' open' : ''}`}
id="schedule-visual-drawer"
aria-hidden={!visualOpen}
onScrollCapture={closeVisualContextMenu}
>
<div className="schedule-visual-header">
<div className="schedule-visual-header-main">
<div>
<h2>Визуальное расписание групп</h2>
<span className="muted" id="schedule-visual-summary">{visualView?.summary || 'Правила ещё не загружены'}</span>
</div>
<button type="button" className="btn btn-sm btn-ghost" id="schedule-visual-close" onClick={closeVisualDrawer}>Свернуть</button>
</div>
<div className="schedule-visual-toolbar">
<div className="schedule-visual-period-grid">
<div className="form-group">
<label htmlFor="schedule-visual-year">Учебный год</label>
<select id="schedule-visual-year" value={effectiveVisualPeriod.yearId} onChange={handleVisualYearChange}>
<option value="">Выберите учебный год</option>
{academicYears.map(year => <option key={year.id} value={year.id}>{year.title}</option>)}
</select>
</div>
<div className="form-group">
<label htmlFor="schedule-visual-semester">Семестр</label>
<select id="schedule-visual-semester" value={effectiveVisualPeriod.semesterId} onChange={handleVisualSemesterChange}>
{effectiveVisualPeriod.yearSemesters.length ? (
<>
<option value="">Выберите семестр</option>
{effectiveVisualPeriod.yearSemesters.map(semester => (
<option key={semester.id} value={semester.id}>{shortSemesterLabel(semester)}</option>
))}
</>
) : (
<option value="">Нет семестров</option>
)}
</select>
</div>
</div>
<div className="schedule-visual-filter-head">
<span>Группы</span>
<div>
<button type="button" className="btn btn-sm btn-ghost" id="schedule-visual-use-form" onClick={handleVisualUseForm}>Из формы</button>
<button type="button" className="btn btn-sm btn-ghost" id="schedule-visual-select-all" onClick={handleVisualSelectAll}>Все</button>
</div>
</div>
<div className="schedule-visual-group-filter" id="schedule-visual-group-filter">
{!visualView || !visualView.groups.length ? (
<span className="muted">{visualView ? 'Нет групп в правилах' : 'Группы появятся после загрузки правил'}</span>
) : visualView.groups.map(group => (
<label key={group.id} className="schedule-visual-group-chip">
<input
type="checkbox"
value={String(group.id)}
checked={visualView.selected.includes(String(group.id))}
onChange={handleVisualGroupFilterChange}
/>
<span>{group.name}</span>
</label>
))}
</div>
</div>
</div>
<div className="schedule-visual-stage" id="schedule-visual-stage">
{visualStageMessage ? (
<div className="loading-row schedule-visual-state">{visualStageMessage}</div>
) : visualView?.rows ? (
<VisualTable rows={visualView.rows} columns={visualView.columns} onEditLesson={openVisualContextMenu} />
) : (
<div className="loading-row schedule-visual-state">{visualView?.state || 'Загрузка...'}</div>
)}
</div>
</aside>
</div>
</>
);
}
// --- Строка слота формы ---
function RuleSlotRow({
slot, index, slotCount, disabled,
timeSlotOptions, lessonTypeOptions, lessonTypes, subgroups, selectedGroupIds,
semesterId, preferences,
subgroupMenuOpen, onToggleSubgroupMenu, onChange, onRemove
}) {
const isSingle = slotCount === 1;
const actionLabel = scheduleSlotActionLabel(slotCount);
const actionClass = isSingle ? 'btn-ghost' : 'btn-danger';
const laboratory = isLaboratorySlot(slot, lessonTypes);
const pills = preferenceHintPills({
semesterId,
teacherId: slot.teacherId,
dayOfWeek: slot.dayOfWeek,
timeSlotId: slot.timeSlotId,
preferences
});
const matchingSubgroups = laboratory
? matchingSubgroupsForGroups(subgroups, selectedGroupIds)
: [];
return (
<div className={`schedule-slot-row${laboratory ? ' has-subgroup' : ''}`} data-index={index} data-slot-id={slot.id || ''}>
<div className="form-group">
<label>День</label>
<select className="slot-day" required disabled={disabled} value={slot.dayOfWeek} onChange={event => onChange(index, { dayOfWeek: Number(event.target.value) })}>
<option value="">Выберите</option>
{DAY_OPTIONS.map(day => <option key={day.value} value={day.value}>{day.label}</option>)}
</select>
</div>
<div className="form-group">
<label>Чётность</label>
<select className="slot-parity" required disabled={disabled} value={slot.parity} onChange={event => onChange(index, { parity: event.target.value })}>
<option value="">Выберите</option>
{PARITY_OPTIONS.map(option => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</div>
<div className="form-group">
<label>Пара</label>
<select className="slot-time" required disabled={disabled} value={slot.timeSlotId} onChange={event => onChange(index, { timeSlotId: Number(event.target.value) })}>
<option value="">Выберите</option>
{timeSlotOptions.map(option => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</div>
<div className="form-group">
<label>Преподаватель</label>
<AsyncCombobox
ariaLabel={`Преподаватель слота ${index + 1}`}
allowClear={false}
placeholder="Найдите преподавателя"
emptyLabel="Преподаватели не найдены"
optionLabel={teacherOptionLabel}
optionDescription={teacherOptionDescription}
load={(query, selectedId) => fetchTeacherOptions(query, selectedId)}
selectedValue={slot.teacherId}
selectedLabel={slot.teacherName}
onSelect={teacher => onChange(index, teacher
? { teacherId: String(teacher.id), teacherName: teacherOptionLabel(teacher) }
: { teacherId: '', teacherName: '' })}
/>
</div>
<div className="form-group">
<label>Аудитория</label>
<AsyncCombobox
ariaLabel={`Аудитория слота ${index + 1}`}
allowClear={false}
placeholder="Найдите аудиторию"
emptyLabel="Аудитории не найдены"
optionLabel={classroomOptionLabel}
optionDescription={classroomOptionDescription}
load={(query, selectedId) => fetchClassroomOptions(query, selectedId)}
selectedValue={slot.classroomId}
selectedLabel={slot.classroomName}
onSelect={classroom => onChange(index, classroom
? { classroomId: String(classroom.id), classroomName: classroomOptionLabel(classroom) }
: { classroomId: '', classroomName: '' })}
/>
</div>
<div className="form-group">
<label>Тип</label>
<select className="slot-lesson-type" required disabled={disabled} value={slot.lessonTypeId} onChange={event => onChange(index, { lessonTypeId: Number(event.target.value) })}>
<option value="">Выберите</option>
{lessonTypeOptions.map(option => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</div>
{laboratory && (selectedGroupIds.length > 1 ? (
<div className="form-group slot-subgroup-multi">
<label>Подгруппы</label>
<div className="custom-multi-select">
<div
className={`select-box slot-subgroup-box${subgroupMenuOpen ? ' active' : ''}`}
role="button"
tabIndex={disabled ? -1 : 0}
aria-expanded={subgroupMenuOpen}
onClick={event => {
if (disabled) return;
event.stopPropagation();
onToggleSubgroupMenu(index);
}}
onKeyDown={event => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onToggleSubgroupMenu(index);
}
if (event.key === 'Escape') {
onToggleSubgroupMenu(null);
}
}}
>
<span className="select-text">{slotSubgroupText(slot, subgroups)}</span>
<svg className="dropdown-icon" width="12" height="8" viewBox="0 0 12 8" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 1.5L6 6.5L11 1.5" stroke="#9ca3af" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
{subgroupMenuOpen && (
<div className="dropdown-menu slot-subgroup-menu open" onClick={event => event.stopPropagation()}>
<div className="checkbox-group-vertical slot-subgroup-checkboxes" data-index={index}>
{matchingSubgroups.length ? matchingSubgroups.map(subgroup => (
<label key={subgroup.id} className="checkbox-item">
<input
type="checkbox"
value={subgroup.id}
checked={slot.subgroupIds.map(String).includes(String(subgroup.id))}
onChange={event => onChange(index, {
subgroupIds: event.target.checked
? [...slot.subgroupIds, Number(subgroup.id)]
: slot.subgroupIds.filter(id => id !== subgroup.id)
})}
/>
<span className="checkmark" />
<span>{subgroupLabel(subgroup)}</span>
</label>
)) : (
<div className="loading-row">Нет подгрупп выбранных групп</div>
)}
</div>
</div>
)}
</div>
</div>
) : (
<div className="form-group">
<label>Подгруппа</label>
<select
className="slot-subgroup"
disabled={disabled}
value={slot.subgroupIds[0] == null ? WHOLE_GROUP_SUBGROUP_VALUE : String(slot.subgroupIds[0])}
onChange={event => onChange(index, {
subgroupIds: event.target.value === WHOLE_GROUP_SUBGROUP_VALUE
? []
: [Number(event.target.value)]
})}
>
{selectedGroupIds.length !== 1 ? (
<option value="">Выберите группу правила</option>
) : (
<>
<option value={WHOLE_GROUP_SUBGROUP_VALUE}>Вся группа</option>
{matchingSubgroups.map(subgroup => (
<option key={subgroup.id} value={subgroup.id}>{subgroupLabel(subgroup)}</option>
))}
</>
)}
</select>
</div>
))}
<div className="form-group">
<label>Формат</label>
<select className="slot-format" required disabled={disabled} value={slot.lessonFormat} onChange={event => onChange(index, { lessonFormat: event.target.value })}>
<option value="">Выберите</option>
{LESSON_FORMAT_OPTIONS.map(option => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</div>
<button
type="button"
className={`btn btn-md ${actionClass} schedule-slot-remove`}
onClick={() => onRemove(index)}
>
{actionLabel}
</button>
<div className="schedule-preference-hint" data-preference-hint="">
{pills ? (
pills.length ? (
<>
<span className="schedule-preference-caption">Пожелания преподавателя</span>
{pills.map(pill => (
<span key={pill.text} className={`schedule-preference-pill ${pill.kind}`}>{pill.text}</span>
))}
</>
) : (
<span className="schedule-preference-caption muted">Согласованных пожеланий для слота нет</span>
)
) : null}
</div>
</div>
);
}
// --- Строка слота конфликтующего правила ---
function ConflictSlotRow({ slot, index, conflictFields, timeSlotOptions, onSlotChange }) {
const original = slot.original || {};
const fixedParts = [
original.lessonTypeName,
original.lessonFormat,
slotSubgroupNames(original).join(', ')
].filter(Boolean);
return (
<section className="schedule-conflict-slot-row" data-index={index}>
<div className={`schedule-conflict-slot-fixed ${conflictFieldClass('group', conflictFields)}`}>
<span>Слот {index + 1}</span>
<strong>{fixedParts.join(' · ') || 'Занятие'}</strong>
<small>{conflictSlotCurrentText(original)}</small>
</div>
<div className="form-group">
<label>День</label>
<select className="conflict-slot-day" required value={slot.dayOfWeek} onChange={event => onSlotChange(index, { dayOfWeek: Number(event.target.value) })}>
<option value="">Выберите</option>
{DAY_OPTIONS.map(day => <option key={day.value} value={day.value}>{day.label}</option>)}
</select>
</div>
<div className="form-group">
<label>Чётность</label>
<select className="conflict-slot-parity" required value={slot.parity} onChange={event => onSlotChange(index, { parity: event.target.value })}>
<option value="">Выберите</option>
{PARITY_OPTIONS.map(option => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</div>
<div className="form-group">
<label>Пара</label>
<select className="conflict-slot-time" required value={slot.timeSlotId} onChange={event => onSlotChange(index, { timeSlotId: Number(event.target.value) })}>
<option value="">Выберите</option>
{timeSlotOptions.map(option => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</div>
<div className={`form-group ${conflictFieldClass('teacher', conflictFields)}`}>
<label>Преподаватель</label>
<AsyncCombobox
ariaLabel={`Преподаватель конфликтующего слота ${index + 1}`}
allowClear={false}
placeholder="Найдите преподавателя"
emptyLabel="Преподаватели не найдены"
optionLabel={teacherOptionLabel}
optionDescription={teacherOptionDescription}
load={(query, selectedId) => fetchTeacherOptions(query, selectedId)}
selectedValue={slot.teacherId}
selectedLabel={slot.teacherName}
onSelect={teacher => onSlotChange(index, teacher
? { teacherId: String(teacher.id), teacherName: teacherOptionLabel(teacher) }
: { teacherId: '', teacherName: '' })}
/>
</div>
<div className={`form-group ${conflictFieldClass('classroom', conflictFields)}`}>
<label>Аудитория</label>
<AsyncCombobox
ariaLabel={`Аудитория конфликтующего слота ${index + 1}`}
allowClear={false}
placeholder="Найдите аудиторию"
emptyLabel="Аудитории не найдены"
optionLabel={classroomOptionLabel}
optionDescription={classroomOptionDescription}
load={(query, selectedId) => fetchClassroomOptions(query, selectedId)}
selectedValue={slot.classroomId}
selectedLabel={slot.classroomName}
onSelect={classroom => onSlotChange(index, classroom
? { classroomId: String(classroom.id), classroomName: classroomOptionLabel(classroom) }
: { classroomId: '', classroomName: '' })}
/>
</div>
</section>
);
}
// --- Строка реестра правил ---
function RuleRow({ rule, onEdit, onDelete }) {
return (
<tr>
<td>{rule.id}</td>
<td>{rule.subjectName || '-'}</td>
<td>{formatSemester(rule)}</td>
<td>{(rule.groupNames || []).join(', ') || '-'}</td>
<td><RuleLoadSummary rule={rule} /></td>
<td><RuleSlotsSummary slots={rule.slots} /></td>
<td>
<div className="schedule-rule-table-actions">
<button type="button" className="btn btn-sm btn-secondary btn-edit-rule" data-id={rule.id} onClick={() => void onEdit(rule.id)}>Изменить</button>
<button type="button" className="btn btn-sm btn-danger btn-delete-rule" data-id={rule.id} onClick={() => void onDelete(rule.id)}>Удалить</button>
</div>
</td>
</tr>
);
}
function RuleLoadSummary({ rule }) {
const rows = LESSON_TYPE_LIMITS
.map(config => ({
label: config.label,
hours: Number(rule[config.hoursField] || 0),
startWeek: Number(rule[config.startWeekField] || 1)
}))
.filter(item => item.hours > 0);
if (!rows.length) {
return <span className="muted">Часы не указаны</span>;
}
return (
<>
{rows.map(item => (
<div key={item.label} className="schedule-slot-summary">
{`${item.label}: ${item.hours} ч, с ${item.startWeek} недели`}
</div>
))}
</>
);
}
function RuleSlotsSummary({ slots }) {
if (!slots || !slots.length) {
return <span className="muted">Нет слотов</span>;
}
return (
<>
{slots.map((slot, index) => {
const parity = PARITY_LABELS[slot.parity] || slot.parity || '-';
const parts = [
slot.dayName,
slot.timeSlotLabel,
parity,
slotSubgroupNames(slot).join(', '),
slot.teacherName,
slot.classroomName,
slot.lessonTypeName,
slot.lessonFormat
].filter(Boolean);
return <div key={index} className="schedule-slot-summary">{parts.join(' · ')}</div>;
})}
</>
);
}
// --- Визуальная матрица ---
function VisualTable({ rows, columns, onEditLesson }) {
return (
<div className="schedule-visual-table-wrap">
<table className="schedule-visual-table">
<thead>
<tr>
<th className="schedule-visual-day-head">День</th>
<th className="schedule-visual-time-head">Время</th>
{columns.map(group => <th key={group.id}>{group.name}</th>)}
</tr>
</thead>
<tbody>
{rows.map((row, index) => (
<tr key={`${row.day.value}-${row.slot.key}-${index}`}>
{row.dayRowspan ? (
<td className="schedule-visual-day-cell" rowSpan={row.dayRowspan}>{row.day.label}</td>
) : null}
<td className="schedule-visual-time-cell">
<strong>{row.slot.orderLabel}</strong>
<span>{row.slot.timeLabel}</span>
</td>
{row.cells.map(cell => (
<td
key={cell.group.id}
className={`schedule-visual-lesson-cell${cell.lessons.length ? '' : ' schedule-visual-lesson-cell-empty'}`}
>
<VisualCellContent lessons={cell.lessons} onEditLesson={onEditLesson} />
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
function VisualCellContent({ lessons, onEditLesson }) {
if (!lessons.length) return null;
const oddLessons = lessons.filter(lesson => lesson.parity === 'BOTH' || lesson.parity === 'ODD');
const evenLessons = lessons.filter(lesson => lesson.parity === 'BOTH' || lesson.parity === 'EVEN');
const shouldSplit = lessons.some(lesson => lesson.parity !== 'BOTH');
if (!shouldSplit) {
return (
<div className="schedule-visual-unified">
{uniqueVisualLessons(lessons).map(lesson => (
<VisualLesson key={lesson.id} lesson={lesson} onEditLesson={onEditLesson} />
))}
</div>
);
}
return CELL_PARITY_LAYOUT.map(({ parity, label }) => {
const parityLessons = parity === 'ODD' ? oddLessons : evenLessons;
return (
<div key={parity} className={`schedule-visual-half schedule-visual-half-${parity.toLowerCase()}`}>
<span className="schedule-visual-half-label">{label}</span>
{parityLessons.map(lesson => (
<VisualLesson key={lesson.id} lesson={lesson} onEditLesson={onEditLesson} />
))}
</div>
);
});
}
function VisualLesson({ lesson, onEditLesson }) {
const meta = [
lesson.lessonTypeName,
lesson.lessonFormat,
lesson.teacherName,
lesson.classroomName
].filter(Boolean).join(' · ');
const subgroupText = lesson.subgroupNames.join(', ');
return (
<article className="schedule-visual-lesson">
<strong>
{lesson.subjectName}
{lesson.weekText ? <> <span>{lesson.weekText}</span></> : null}
</strong>
{subgroupText ? <small>{subgroupText}</small> : null}
{meta ? <small>{meta}</small> : null}
<button
type="button"
className="btn btn-icon-sm btn-ghost schedule-visual-edit-rule"
data-rule-id={String(lesson.ruleId)}
data-slot-id={String(lesson.slotId || '')}
data-slot-index={String(lesson.slotIndex ?? '')}
title="Действия со слотом"
aria-label={`Действия со слотом: ${lesson.subjectName}`}
onClick={event => onEditLesson(event.currentTarget)}
>
&#8942;
</button>
</article>
);
}