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 ( <>
{versionContext.note}
{draftCopyText}
{conflict.summary}
{moveSlot.summary}
| ID | Дисциплина | Семестр | Группы | Нагрузка | Слоты | Действия |
|---|---|---|---|---|---|---|
| Загрузка... | ||||||
| {rulesMessage} | ||||||
| Правила не найдены | ||||||
| День | Время | {columns.map(group =>{group.name} | )}
|---|---|---|
| {row.day.label} | ) : null}{row.slot.orderLabel} {row.slot.timeLabel} | {row.cells.map(cell => (
|
))}