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