import { useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { confirmAbandonDirtyChanges, createDirtyScope } from '../../../admin/js/dirty-state.js'; import { dateInputValueToIso, formatDateInputValue, isoDateToInputValue } from '../../../admin/js/date-input.js'; import { debounce } from '../../../admin/js/url-state.js'; import { buildAcademicCalendarTitle } from '../../../admin/js/views/academic-calendar-title.js'; import { trackUxEvent } from '../../../telemetry.js'; import { AsyncCombobox } from '../../shared/ui/AsyncCombobox.jsx'; import { ConfirmDialog } from '../../shared/ui/ConfirmDialog.jsx'; import { FormAlert } from '../../shared/ui/FormAlert.jsx'; import { Pagination } from '../../shared/ui/Pagination.jsx'; import { DAY_OPTIONS, academicYearDefaults, activityById, activityColorClass, automaticTimeScopeLabel, buildDefaultGrid, calculateCalendarTooltipPosition, calendarOptionDescription, calendarOptionLabel, calendarRowKey, calendarSemesterCount, commonActivityValue, dayDialogSummaryParts, dayDialogTexts, dayTooltipParts, deleteAcademicYearRequest, deleteCalendarRequest, deleteTimeSlotDateAssignmentRequest, effectiveActivityTypeId, fetchAcademicYearsList, fetchActivityTypes, fetchCalendarGrid, fetchCalendarOptions, fetchCalendarSubjects, fetchCalendarsPage, fetchEducationForms, fetchSpecialties, fetchSpecialtyProfiles, fetchSubjectsList, fetchTimeSlotDateAssignments, fetchTimeSlotScopes, formatDisplayDateRange, formatShortDate, gridTotalsEntries, groupBy, readDisplayDateIso, saveAcademicYearRequest, saveCalendarGridRequest, saveCalendarRequest, saveCalendarSubjectsRequest, semesterBlocksForCourse, semesterOptionLabel, semesterTypeTitle, shortDayLabel, sortCalendarSubjectRows, specialityLabel, startYearInputResult, subjectLabel, subjectRowLabel, timeAssignmentForDate, uniqueWeekNumbers, validateAcademicYearSemesters } from '../academic-calendar-service.js'; const YEAR_SEMESTER_LABELS = { autumn: { start: 'Начало осеннего семестра', end: 'Окончание осеннего семестра' }, spring: { start: 'Начало весеннего семестра', end: 'Окончание весеннего семестра' } }; function initialYearForm() { return { id: '', title: 'Учебный год и семестры', startYear: '', endYear: '', autumn: { id: '', start: '', end: '' }, spring: { id: '', start: '', end: '' } }; } function initialCalendarForm() { return { id: '', title: 'Календарный учебный график', yearId: '', specialtyId: '', profileId: '', studyFormId: '', courseCount: '4' }; } // Поле даты с маской ДД.ММ.ГГГГ: реплика bindDateInputMask из legacy — // форматирование прямо во время ввода, нормализация и ошибка при потере фокуса. function MaskedDateInput({ id, value, onChange, inputRef, required = false, 'aria-describedby': describedBy }) { const [invalid, setInvalid] = useState(false); const innerRef = useRef(null); const ref = inputRef || innerRef; function clearError() { setInvalid(false); ref.current?.setCustomValidity?.(''); } return ( { clearError(); onChange(formatDateInputValue(event.target.value)); }} onBlur={() => { if (!value) { clearError(); return; } try { onChange(isoDateToInputValue(dateInputValueToIso(value))); clearError(); } catch (error) { setInvalid(true); ref.current?.setCustomValidity?.(error.message); } }} /> ); } // Диалог ячейки сетки: код активности выделения и временная сетка даты. // Как в legacy, кнопка «Применить» закрывает окно, а выбор временной сетки // носит информационный характер. function CalendarDayDialog({ rows, activityTypes, scopes, assignments, onActivityChange, onClearAssignment, onClose, alert }) { const isSingle = rows.length === 1; const texts = dayDialogTexts(rows); const summary = dayDialogSummaryParts(rows, activityTypes); const common = commonActivityValue(rows); const assignment = isSingle ? timeAssignmentForDate(assignments, rows[0].date) : null; const manualScopes = scopes.filter(scope => scope.applyMode === 'MANUAL'); const [scopeValue, setScopeValue] = useState(assignment?.scopeId || ''); useEffect(() => { setScopeValue(assignment?.scopeId || ''); }, [assignment?.scopeId]); const summaryActivity = summary.kind === 'single' || summary.kind === 'multi' ? summary.activity : null; return (
{ if (event.target === event.currentTarget) onClose(); }} >

Ячейка графика

Дата {texts.date}
Курс {texts.course}
Неделя {texts.week}
День {texts.day}
{summaryActivity ? ( <> {summaryActivity.code}
{summary.kind === 'multi' ? `${summaryActivity.name} · ${summary.count} ячеек` : summaryActivity.name}

{summaryActivity.description || (summaryActivity.allowSchedule ? 'Обычные пары разрешены' : 'Обычные пары не генерируются')}

) : summary.kind === 'multi-empty' ? ( <> ...
Выбрано ячеек: {summary.count}

Выберите код активности, чтобы применить его ко всему выделению

) : null}
); } export function AcademicCalendarTab() { const [activeSection, setActiveSection] = useState('manage'); // Справочники const [academicYears, setAcademicYears] = useState([]); const [specialties, setSpecialties] = useState([]); const [profilesBySpecialty, setProfilesBySpecialty] = useState(new Map()); const [studyForms, setStudyForms] = useState([]); const [activityTypes, setActivityTypes] = useState([]); const [subjects, setSubjects] = useState([]); const [timeSlotScopes, setTimeSlotScopes] = useState([]); const [timeSlotDateAssignments, setTimeSlotDateAssignments] = useState([]); const [baseLoaded, setBaseLoaded] = useState(false); const activityTypesRef = useRef([]); // Учебные годы const [yearsState, setYearsState] = useState({ loading: true, error: '' }); const [yearForm, setYearForm] = useState(initialYearForm); const [yearAlert, setYearAlert] = useState(null); const [yearDeleteDialog, setYearDeleteDialog] = useState(null); // Форма календарного графика const [calendarForm, setCalendarForm] = useState(initialCalendarForm); const [calendarAlert, setCalendarAlert] = useState(null); const [calendarDeleteDialog, setCalendarDeleteDialog] = useState(null); // Реестр графиков const [calendarsState, setCalendarsState] = useState({ loading: true, error: '', items: [], page: 0, size: 25, totalItems: 0, totalPages: 0 }); const [calendarSearchValue, setCalendarSearchValue] = useState(''); const [calendarYearFilter, setCalendarYearFilter] = useState(''); const calendarsPageRef = useRef(0); const calendarsSizeRef = useRef(25); const calendarsQueryRef = useRef(''); const calendarsYearFilterRef = useRef(''); const calendarsRequestRef = useRef(0); // Сетка годового графика const [editorCalendar, setEditorCalendar] = useState(null); const editorCalendarRef = useRef(null); const [loadedGridCalendarId, setLoadedGridCalendarId] = useState(''); const loadedGridCalendarRef = useRef(null); const [gridRows, setGridRows] = useState(null); const gridRowsRef = useRef(null); const [gridDirty, setGridDirty] = useState(false); const [gridAlert, setGridAlert] = useState(null); const [fillForm, setFillForm] = useState({ course: '', start: '', end: '', activityId: '' }); const gridLoadRequestRef = useRef(0); // Дисциплины графика const [subjectCalendar, setSubjectCalendar] = useState(null); const subjectCalendarRef = useRef(null); const [loadedSubjectCalendarId, setLoadedSubjectCalendarId] = useState(''); const loadedSubjectCalendarRef = useRef(null); const [subjectRows, setSubjectRows] = useState([]); const [subjectsState, setSubjectsState] = useState('idle'); const [subjectsError, setSubjectsError] = useState(''); const [subjectDirty, setSubjectDirty] = useState(false); const [subjectAlert, setSubjectAlert] = useState(null); const [subjectSemester, setSubjectSemester] = useState(''); const [subjectIdValue, setSubjectIdValue] = useState(''); const subjectLoadRequestRef = useRef(0); // Диалог ячейки, подсказка и выделение const [dayDialogKeys, setDayDialogKeys] = useState(null); const [dayDialogTimeAlert, setDayDialogTimeAlert] = useState(null); const [tooltipState, setTooltipState] = useState(null); const [selectedDayKeys, setSelectedDayKeys] = useState([]); const selectedDayKeysRef = useRef([]); const dragStateRef = useRef({ dragging: false, didDrag: false }); const suppressClickRef = useRef(false); // Ссылки для прокрутки и фокуса const yearFormCardRef = useRef(null); const calendarFormRef = useRef(null); const calendarGridRef = useRef(null); const subjectSummaryRef = useRef(null); const semesterInputRefs = useRef({ autumn: {}, spring: {} }); const tooltipRef = useRef(null); const tooltipAnimationRef = useRef(null); const gridDirtyScope = useRef(null); const subjectDirtyScope = useRef(null); if (!gridDirtyScope.current) { gridDirtyScope.current = createDirtyScope( 'academic-calendar-grid', 'В календарной сетке есть несохранённые изменения. Загрузить другой график и потерять их?' ); subjectDirtyScope.current = createDirtyScope( 'academic-calendar-subjects', 'В привязках дисциплин есть несохранённые изменения. Продолжить без сохранения?' ); } useEffect(() => () => { gridDirtyScope.current?.dispose(); subjectDirtyScope.current?.dispose(); }, []); function setEditorCalendarBoth(calendar) { editorCalendarRef.current = calendar; setEditorCalendar(calendar); } function setSubjectCalendarBoth(calendar) { subjectCalendarRef.current = calendar; setSubjectCalendar(calendar); } function applyGridRows(rows) { gridRowsRef.current = rows; setGridRows(rows); } function setSelectedDayKeysBoth(keys) { selectedDayKeysRef.current = keys; setSelectedDayKeys(keys); } function markGridDirty() { gridDirtyScope.current.mark(); setGridDirty(true); } function clearGridDirty() { gridDirtyScope.current.clear(); setGridDirty(false); } function markSubjectDirty() { subjectDirtyScope.current.mark(); setSubjectDirty(true); } function clearSubjectDirty() { subjectDirtyScope.current.clear(); setSubjectDirty(false); } // --- Загрузка данных --- async function loadYears() { setYearsState({ loading: true, error: '' }); try { const years = await fetchAcademicYearsList(); setAcademicYears(years); setYearsState({ loading: false, error: '' }); const keptFilter = years.some(year => String(year.id) === calendarYearFilter) ? calendarYearFilter : ''; setCalendarYearFilter(keptFilter); calendarsYearFilterRef.current = keptFilter; } catch (error) { setYearsState({ loading: false, error: error.message || 'Ошибка загрузки учебных годов' }); throw error; } } async function loadCalendars() { const request = calendarsRequestRef.current + 1; calendarsRequestRef.current = request; setCalendarsState(current => ({ ...current, loading: true, error: '' })); try { const pageData = await fetchCalendarsPage({ page: calendarsPageRef.current, size: calendarsSizeRef.current, query: calendarsQueryRef.current, academicYearId: calendarsYearFilterRef.current }); if (request !== calendarsRequestRef.current) return; if (calendarsPageRef.current > 0 && calendarsPageRef.current >= (pageData.totalPages || 0)) { calendarsPageRef.current = Math.max(0, (pageData.totalPages || 0) - 1); return loadCalendars(); } setCalendarsState({ loading: false, error: '', items: pageData.items || [], page: calendarsPageRef.current, size: calendarsSizeRef.current, totalItems: pageData.totalItems || 0, totalPages: pageData.totalPages || 0 }); } catch (error) { if (request !== calendarsRequestRef.current) return; setCalendarsState(current => ({ ...current, loading: false, error: error.message || 'Ошибка загрузки календарных графиков' })); throw error; } } useEffect(() => { let active = true; let yearsLoadError = null; const yearsPromise = fetchAcademicYearsList().catch(error => { yearsLoadError = error; throw error; }); (async () => { try { const [base, years, subjectsList, forms, activities, scopes, assignments] = await Promise.all([ (async () => { const list = await fetchSpecialties(); const pairs = await Promise.all(list.map(async speciality => [ String(speciality.id), await fetchSpecialtyProfiles(speciality.id) ])); return { list, profilesBySpecialty: new Map(pairs) }; })(), yearsPromise, fetchSubjectsList(), fetchEducationForms(), fetchActivityTypes(), fetchTimeSlotScopes(), fetchTimeSlotDateAssignments() ]); if (!active) return; setSpecialties(base.list); setProfilesBySpecialty(base.profilesBySpecialty); setAcademicYears(years); setSubjects(subjectsList); setStudyForms(forms); setActivityTypes(activities); activityTypesRef.current = activities; setTimeSlotScopes(scopes); setTimeSlotDateAssignments(assignments); setBaseLoaded(true); setYearsState({ loading: false, error: '' }); await loadCalendars(); } catch (error) { if (!active) return; if (yearsLoadError) { setYearsState({ loading: false, error: yearsLoadError.message || 'Ошибка загрузки учебных годов' }); } setCalendarAlert({ message: error.message || 'Ошибка загрузки календарных графиков', type: 'error' }); } })(); return () => { active = false; }; }, []); const debouncedCalendarsSearch = useRef(debounce(() => { calendarsPageRef.current = 0; void loadCalendars().catch(() => {}); }, 300)).current; // --- Разделы вкладки --- async function switchSection(section) { if (!await confirmAbandonDirtyChanges()) return; setActiveSection(section || 'manage'); } // --- Учебные годы --- function resetYearForm() { setYearForm(initialYearForm()); setYearAlert(null); } function fillYearForm(year, focusedSemesterType = '') { const semesterFields = type => { const semester = (year.semesters || []) .find(item => String(item.semesterType || '').toLowerCase() === type); return { id: semester?.id ? String(semester.id) : '', start: semester?.startDate ? isoDateToInputValue(semester.startDate) : '', end: semester?.endDate ? isoDateToInputValue(semester.endDate) : '' }; }; setYearForm({ id: year.id ? String(year.id) : '', title: `Редактирование учебного года ${year.title || ''}`.trim(), startYear: String(year.startDate || '').slice(0, 4), endYear: String(year.endDate || '').slice(0, 4), autumn: semesterFields('autumn'), spring: semesterFields('spring') }); setYearAlert(null); yearFormCardRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); if (focusedSemesterType) { semesterInputRefs.current[focusedSemesterType].start?.focus({ preventScroll: true }); } } function handleStartYearInput(rawValue) { const result = startYearInputResult(rawValue); setYearForm(current => { if (!result.semesterDates) { return { ...current, startYear: result.startYear, endYear: result.endYear }; } return { ...current, startYear: result.startYear, endYear: result.endYear, autumn: { ...current.autumn, start: result.semesterDates.autumn.start, end: result.semesterDates.autumn.end }, spring: { ...current.spring, start: result.semesterDates.spring.start, end: result.semesterDates.spring.end } }; }); } function readYearSemester(type, fallback) { const fields = yearForm[type]; const labels = YEAR_SEMESTER_LABELS[type]; const startDisplay = fields.start || isoDateToInputValue(fallback.startDate); const endDisplay = fields.end || isoDateToInputValue(fallback.endDate); return { id: fields.id ? Number(fields.id) : null, semesterType: type, startDate: readDisplayDateIso(startDisplay, labels.start), endDate: readDisplayDateIso(endDisplay, labels.end) }; } async function submitYearForm(event) { event.preventDefault(); setYearAlert(null); try { const defaults = academicYearDefaults(yearForm.startYear); const payload = { startDate: defaults.startDate, endDate: defaults.endDate, semesters: [ readYearSemester('autumn', defaults.semesters[0]), readYearSemester('spring', defaults.semesters[1]) ] }; validateAcademicYearSemesters(payload.semesters, defaults.startDate, defaults.endDate); await saveAcademicYearRequest(payload, yearForm.id); setYearAlert({ message: 'Учебный год и семестры сохранены', type: 'success' }); resetYearForm(); await loadYears(); await loadCalendars(); } catch (error) { setYearAlert({ message: error.message || 'Ошибка сохранения учебного года', type: 'error' }); const failedLabel = Object.values(YEAR_SEMESTER_LABELS) .flatMap(labels => [labels.start, labels.end]) .find(label => String(error.message || '').startsWith(label)); if (failedLabel) { const entry = ['autumn', 'spring'].find(type => [YEAR_SEMESTER_LABELS[type].start, YEAR_SEMESTER_LABELS[type].end].includes(failedLabel)); const field = failedLabel.includes('Начало') ? 'start' : 'end'; semesterInputRefs.current[entry]?.[field]?.focus({ preventScroll: true }); } } } async function confirmDeleteYear(id) { setYearDeleteDialog(null); try { await deleteAcademicYearRequest(id); setYearAlert({ message: 'Учебный год удалён', type: 'success' }); await loadYears(); await loadCalendars(); } catch (error) { setYearAlert({ message: error.message || 'Ошибка удаления учебного года', type: 'error' }); } } // --- Форма календарного графика --- function resetCalendarForm() { setCalendarForm(initialCalendarForm()); setCalendarAlert(null); } async function submitCalendarForm(event) { event.preventDefault(); setCalendarAlert(null); const payload = { id: calendarForm.id ? Number(calendarForm.id) : null, academicYearId: Number(calendarForm.yearId), specialtyId: Number(calendarForm.specialtyId), specialtyProfileId: Number(calendarForm.profileId), studyFormId: Number(calendarForm.studyFormId), courseCount: Number(calendarForm.courseCount) }; try { if (!payload.academicYearId || !payload.specialtyId || !payload.specialtyProfileId || !payload.studyFormId || !payload.courseCount) { throw new Error('Заполните все поля календарного графика'); } const saved = await saveCalendarRequest(payload, calendarForm.id); if (editorCalendarRef.current && String(editorCalendarRef.current.id) === String(saved.id)) { setEditorCalendarBoth(saved); } if (subjectCalendarRef.current && String(subjectCalendarRef.current.id) === String(saved.id)) { setSubjectCalendarBoth(saved); } setCalendarAlert({ message: `График "${buildAcademicCalendarTitle(saved)}" сохранён`, type: 'success' }); resetCalendarForm(); await loadCalendars(); } catch (error) { setCalendarAlert({ message: error.message || 'Ошибка сохранения календарного графика', type: 'error' }); } } function editCalendar(calendar) { setActiveSection('manage'); const profiles = profilesBySpecialty.get(String(calendar.specialtyId || '')) || []; const yearId = calendar.academicYearId && academicYears.some(year => String(year.id) === String(calendar.academicYearId)) ? String(calendar.academicYearId) : ''; const specialtyId = calendar.specialtyId && specialties.some(item => String(item.id) === String(calendar.specialtyId)) ? String(calendar.specialtyId) : ''; const profileId = calendar.specialtyProfileId && profiles.some(profile => String(profile.id) === String(calendar.specialtyProfileId)) ? String(calendar.specialtyProfileId) : ''; const studyFormId = calendar.studyFormId && studyForms.some(form => String(form.id) === String(calendar.studyFormId)) ? String(calendar.studyFormId) : ''; setCalendarForm({ id: String(calendar.id ?? ''), title: 'Редактирование календарного графика', yearId, specialtyId, profileId, studyFormId, courseCount: String(calendar.courseCount || 4) }); setCalendarAlert(null); calendarFormRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); } function openCalendarGrid(calendar) { setActiveSection('grid'); setEditorCalendarBoth(calendar); void (async () => { await loadCalendarGrid(calendar); calendarGridRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); })(); } function openCalendarSubjects(calendar) { setActiveSection('subjects'); setSubjectCalendarBoth(calendar); void (async () => { await loadCalendarSubjects(calendar); subjectSummaryRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); })(); } async function confirmDeleteCalendar(deletedId) { setCalendarDeleteDialog(null); try { await deleteCalendarRequest(deletedId); if (String(editorCalendarRef.current?.id) === String(deletedId)) { setEditorCalendarBoth(null); loadedGridCalendarRef.current = null; setLoadedGridCalendarId(''); applyGridRows(null); setSelectedDayKeysBoth([]); setTooltipState(null); clearGridDirty(); } if (String(subjectCalendarRef.current?.id) === String(deletedId)) { setSubjectCalendarBoth(null); loadedSubjectCalendarRef.current = null; setLoadedSubjectCalendarId(''); void loadCalendarSubjects(null); } setCalendarAlert({ message: 'Календарный график удалён', type: 'success' }); await loadCalendars(); } catch (error) { setCalendarAlert({ message: error.message || 'Ошибка удаления календарного графика', type: 'error' }); } } // --- Сетка годового графика --- async function loadCalendarGrid(calendar = editorCalendarRef.current) { const request = gridLoadRequestRef.current + 1; gridLoadRequestRef.current = request; setGridAlert(null); if (gridDirtyScope.current.isDirty() && !await confirmAbandonDirtyChanges()) { setEditorCalendarBoth(loadedGridCalendarRef.current); return; } if (request !== gridLoadRequestRef.current) return; if (!calendar) { setGridAlert({ message: 'Выберите календарный график', type: 'error' }); return; } try { const assignments = await fetchTimeSlotDateAssignments(); const rows = await fetchCalendarGrid(calendar.id); if (request !== gridLoadRequestRef.current || String(calendar.id) !== String(editorCalendarRef.current?.id)) { return; } setTimeSlotDateAssignments(assignments); const nextRows = rows.length ? rows.map(row => ({ ...row, activityTypeId: effectiveActivityTypeId(row, activityTypesRef.current) })) : buildDefaultGrid(calendar, activityTypesRef.current); applyGridRows(nextRows); setSelectedDayKeysBoth([]); setTooltipState(null); setFillForm(current => ({ ...current, course: '' })); loadedGridCalendarRef.current = calendar; setLoadedGridCalendarId(String(calendar.id)); clearGridDirty(); trackUxEvent('calendar_grid_loaded', { calendarId: calendar.id, hasSavedRows: rows.length > 0 }); setGridAlert({ message: rows.length ? 'Сетка загружена' : 'Подготовлена новая сетка по датам учебного года', type: 'success' }); } catch (error) { if (request !== gridLoadRequestRef.current) return; setGridAlert({ message: error.message || 'Ошибка загрузки сетки', type: 'error' }); } } async function saveCalendarGrid() { setGridAlert(null); const calendar = editorCalendarRef.current; if (!calendar) { setGridAlert({ message: 'Выберите календарный график', type: 'error' }); return; } if (String(calendar.id) !== loadedGridCalendarId) { setGridAlert({ message: 'Сначала загрузите сетку выбранного графика', type: 'error' }); return; } const rows = (gridRows || []).map(row => ({ calendarId: calendar.id, courseNumber: Number(row.courseNumber), date: row.date, weekNumber: Number(row.weekNumber), dayOfWeek: Number(row.dayOfWeek), activityTypeId: Number(row.activityTypeId) })); if (!rows.length) { setGridAlert({ message: 'Сначала загрузите сетку графика', type: 'error' }); return; } try { await saveCalendarGridRequest(calendar.id, rows); clearGridDirty(); trackUxEvent('calendar_grid_saved', { calendarId: calendar.id, cells: rows.length }); setGridAlert({ message: 'Календарный учебный график сохранён', type: 'success' }); await loadCalendarGrid(calendar); } catch (error) { setGridAlert({ message: error.message || 'Ошибка сохранения сетки', type: 'error' }); } } function applyCalendarFill() { setGridAlert(null); const activityId = fillForm.activityId; if (!activityId || !fillForm.start || !fillForm.end) { setGridAlert({ message: 'Выберите даты и код активности для заполнения', type: 'error' }); return; } let start; let end; try { start = readDisplayDateIso(fillForm.start, 'Дата начала диапазона'); end = readDisplayDateIso(fillForm.end, 'Дата окончания диапазона'); } catch (error) { setGridAlert({ message: error.message, type: 'error' }); return; } if (end < start) { setGridAlert({ message: 'Дата окончания не может быть раньше даты начала', type: 'error' }); return; } const course = fillForm.course; let changed = 0; const nextRows = (gridRows || []).map(row => { const sameCourse = !course || String(row.courseNumber) === String(course); const inRange = row.date >= start && row.date <= end; if (sameCourse && inRange) { changed += 1; return { ...row, activityTypeId: activityId }; } return row; }); if (changed) { applyGridRows(nextRows); markGridDirty(); } setGridAlert({ message: `Обновлено ячеек: ${changed}`, type: 'success' }); } // --- Выделение ячеек и подсказка --- function hideDayTooltip() { setTooltipState(null); } function showDayTooltip(row, event = null) { if (dragStateRef.current.dragging) { hideDayTooltip(); return; } const rect = event?.currentTarget?.getBoundingClientRect?.(); const x = Number.isFinite(event?.clientX) ? event.clientX : (rect ? rect.right : 0); const y = Number.isFinite(event?.clientY) ? event.clientY : (rect ? rect.bottom : 0); setTooltipState({ row, x, y }); } useEffect(() => { if (!tooltipState || !tooltipRef.current) return; const tooltip = tooltipRef.current; const rect = tooltip.getBoundingClientRect(); const { left, top } = calculateCalendarTooltipPosition({ pointerX: tooltipState.x, pointerY: tooltipState.y, tooltipWidth: rect.width, tooltipHeight: rect.height, viewportWidth: window.innerWidth, viewportHeight: window.innerHeight }); tooltipAnimationRef.current?.cancel(); const positionedTransform = `translate3d(${left}px, ${top}px, 0) scale(1)`; tooltipAnimationRef.current = tooltip.animate( [ { transform: positionedTransform }, { transform: positionedTransform } ], { duration: 1, fill: 'forwards' } ); }, [tooltipState]); function startDayDragSelection(event, key) { if (event.button !== 0) return; hideDayTooltip(); dragStateRef.current = { dragging: true, didDrag: false }; suppressClickRef.current = false; setSelectedDayKeysBoth([key]); event.preventDefault(); } function addDayToDragSelection(key) { if (!dragStateRef.current.dragging || selectedDayKeysRef.current.includes(key)) return; dragStateRef.current.didDrag = true; setSelectedDayKeysBoth([...selectedDayKeysRef.current, key]); } function openDayDialogForKeys(keys) { const uniqueKeys = Array.from(new Set(keys)); const rows = uniqueKeys .map(key => (gridRowsRef.current || []).find(row => calendarRowKey(row) === key)) .filter(Boolean); if (!rows.length) return; setSelectedDayKeysBoth(uniqueKeys); setDayDialogKeys(uniqueKeys); } useEffect(() => { function finishDragSelection() { if (!dragStateRef.current.dragging) return; dragStateRef.current.dragging = false; if (dragStateRef.current.didDrag && selectedDayKeysRef.current.length > 1) { suppressClickRef.current = true; openDayDialogForKeys(selectedDayKeysRef.current); } } document.addEventListener('mouseup', finishDragSelection); return () => document.removeEventListener('mouseup', finishDragSelection); }, []); function handleDayClick(event, key) { if (suppressClickRef.current) { suppressClickRef.current = false; event.preventDefault(); return; } openDayDialogForKeys([key]); } function handleDayKeyDown(event, key) { if (event.key !== 'Enter' && event.key !== ' ') return; event.preventDefault(); openDayDialogForKeys([key]); } function closeDayDialog() { setDayDialogKeys(null); } function handleDialogActivityChange(event) { const value = event.target.value; if (!value) return; const keys = new Set(dayDialogKeys || []); applyGridRows((gridRowsRef.current || []).map(row => ( keys.has(calendarRowKey(row)) ? { ...row, activityTypeId: value } : row ))); markGridDirty(); } async function clearTimeSlotDateAssignment() { if (!dayDialogKeys || dayDialogKeys.length !== 1) return; setDayDialogTimeAlert(null); const date = (gridRowsRef.current || []) .find(row => calendarRowKey(row) === dayDialogKeys[0])?.date; const assignment = timeAssignmentForDate(timeSlotDateAssignments, date); if (!assignment) { setDayDialogTimeAlert({ message: 'Для даты нет ручной сетки', type: 'success' }); return; } try { await deleteTimeSlotDateAssignmentRequest(assignment.id); const assignments = await fetchTimeSlotDateAssignments(); setTimeSlotDateAssignments(assignments); setDayDialogTimeAlert({ message: 'Ручная сетка убрана', type: 'success' }); } catch (error) { setDayDialogTimeAlert({ message: error.message || 'Ошибка удаления ручной сетки', type: 'error' }); } } // --- Дисциплины графика --- async function loadCalendarSubjects(calendar = subjectCalendarRef.current) { const request = subjectLoadRequestRef.current + 1; subjectLoadRequestRef.current = request; setSubjectAlert(null); if (subjectDirtyScope.current.isDirty() && !await confirmAbandonDirtyChanges()) { setSubjectCalendarBoth(loadedSubjectCalendarRef.current); return; } if (request !== subjectLoadRequestRef.current) return; if (!calendar) { setSubjectSemester(''); } else { const count = calendarSemesterCount(calendar); setSubjectSemester(current => ( current && Number(current) >= 1 && Number(current) <= count ? current : '1' )); } setSubjectRows([]); setLoadedSubjectCalendarId(''); loadedSubjectCalendarRef.current = null; if (!calendar) { clearSubjectDirty(); setSubjectsState('idle'); return; } setSubjectsState('loading'); try { const rows = await fetchCalendarSubjects(calendar.id); if (request !== subjectLoadRequestRef.current || String(calendar.id) !== String(subjectCalendarRef.current?.id)) { return; } setSubjectRows(sortCalendarSubjectRows(rows || [])); setLoadedSubjectCalendarId(String(calendar.id)); loadedSubjectCalendarRef.current = calendar; clearSubjectDirty(); setSubjectsState('ready'); } catch (error) { if (request !== subjectLoadRequestRef.current) return; setSubjectsError(error.message || 'Ошибка загрузки дисциплин графика'); setSubjectsState('error'); setSubjectAlert({ message: error.message || 'Ошибка загрузки дисциплин графика', type: 'error' }); } } function addCalendarSubject() { setSubjectAlert(null); const calendar = subjectCalendarRef.current; if (calendar && String(calendar.id) !== loadedSubjectCalendarId) { setSubjectAlert({ message: 'Дождитесь загрузки дисциплин выбранного графика', type: 'error' }); return; } const semesterNumber = Number(subjectSemester); const subjectId = Number(subjectIdValue); if (!calendar || !semesterNumber || !subjectId) { setSubjectAlert({ message: 'Выберите график, семестр и дисциплину', type: 'error' }); return; } if (subjectRows.some(row => Number(row.semesterNumber) === semesterNumber && Number(row.subjectId) === subjectId )) { setSubjectAlert({ message: 'Дисциплина уже добавлена в этот семестр', type: 'error' }); return; } const subject = subjects.find(item => Number(item.id) === subjectId); if (!subject) { setSubjectAlert({ message: 'Дисциплина не найдена в справочнике', type: 'error' }); return; } setSubjectRows(sortCalendarSubjectRows([ ...subjectRows, { id: null, calendarId: calendar.id, semesterNumber, subjectId, subjectName: subject.name, subjectCode: subject.code, departmentId: subject.departmentId } ])); markSubjectDirty(); setSubjectAlert({ message: 'Дисциплина добавлена. Сохраните привязки.', type: 'success' }); } async function saveCalendarSubjects() { setSubjectAlert(null); const calendar = subjectCalendarRef.current; if (calendar && String(calendar.id) !== loadedSubjectCalendarId) { setSubjectAlert({ message: 'Дождитесь загрузки дисциплин выбранного графика', type: 'error' }); return; } if (!calendar) { setSubjectAlert({ message: 'Выберите календарный график', type: 'error' }); return; } const payload = subjectRows.map(row => ({ semesterNumber: Number(row.semesterNumber), subjectId: Number(row.subjectId) })); try { const saved = await saveCalendarSubjectsRequest(calendar.id, payload); const sorted = sortCalendarSubjectRows(saved || []); setSubjectRows(sorted); clearSubjectDirty(); trackUxEvent('calendar_subjects_saved', { calendarId: calendar.id, count: sorted.length }); setSubjectAlert({ message: 'Привязки дисциплин сохранены', type: 'success' }); } catch (error) { setSubjectAlert({ message: error.message || 'Ошибка сохранения дисциплин графика', type: 'error' }); } } function removeSubjectRow(row) { setSubjectRows(current => current.filter(item => !(Number(item.semesterNumber) === Number(row.semesterNumber) && Number(item.subjectId) === Number(row.subjectId)) )); markSubjectDirty(); setSubjectAlert({ message: 'Дисциплина убрана. Сохраните привязки.', type: 'success' }); } // --- Производные данные --- const sortedSubjects = useMemo( () => [...subjects].sort((a, b) => String(a.name || '').localeCompare(String(b.name || ''), 'ru')), [subjects] ); const profiles = profilesBySpecialty.get(String(calendarForm.specialtyId)) || []; const profileEmptyLabel = !calendarForm.specialtyId ? (specialties.length ? 'Нет профилей' : 'Выберите специальность') : (profiles.length ? 'Выберите профиль' : 'Нет профилей'); const fillCourseOptions = useMemo(() => { if (!gridRows) return []; return Array.from(new Set(gridRows.map(row => String(row.courseNumber)))) .sort((a, b) => Number(a) - Number(b)); }, [gridRows]); const courseBlocks = useMemo(() => { if (!gridRows) return []; const grouped = groupBy(gridRows, row => row.courseNumber); return Array.from(grouped.entries()).map(([course, courseRows]) => ({ course, courseRows, blocks: semesterBlocksForCourse(editorCalendar, Number(course), courseRows, academicYears) })); }, [gridRows, editorCalendar, academicYears]); const totals = useMemo( () => gridTotalsEntries(gridRows || [], activityTypes), [gridRows, activityTypes] ); const selectedCode = useMemo(() => { if (!selectedDayKeys.length || !gridRows) return null; const rows = selectedDayKeys .map(key => gridRows.find(row => calendarRowKey(row) === key)) .filter(Boolean); if (!rows.length) return null; const activity = activityById(commonActivityValue(rows), activityTypes); return activity?.code || null; }, [selectedDayKeys, gridRows, activityTypes]); const dayDialogRows = useMemo(() => { if (!dayDialogKeys) return []; const keys = new Set(dayDialogKeys); return (gridRows || []).filter(row => keys.has(calendarRowKey(row))); }, [dayDialogKeys, gridRows]); const subjectSemesterCount = subjectCalendar ? calendarSemesterCount(subjectCalendar) : 0; // --- Рендер сетки --- function renderCalendarDay(row) { const key = calendarRowKey(row); const activity = activityById(row.activityTypeId, activityTypes) || activityTypes[0] || {}; const code = activity.code || row.activityCode || 'Т'; const assignment = timeAssignmentForDate(timeSlotDateAssignments, row.date); const isSelected = selectedDayKeys.includes(key); return ( startDayDragSelection(event, key)} onMouseOver={() => addDayToDragSelection(key)} onMouseMove={event => showDayTooltip(row, event)} onClick={event => handleDayClick(event, key)} onKeyDown={event => handleDayKeyDown(event, key)} onFocus={() => showDayTooltip(row)} > {code} {assignment ? вр. : null} ); } function renderSemesterTable(rows, targetWeekColumns) { if (!rows.length) { return
Нет дат в этом семестре
; } const rowsByWeek = groupBy(rows, row => row.weekNumber); const weeks = Array.from(rowsByWeek.keys()).sort((a, b) => Number(a) - Number(b)); const weekSlots = [...weeks]; while (weekSlots.length < targetWeekColumns) { weekSlots.push(null); } const cellByWeekAndDay = new Map(rows.map(row => [`${row.weekNumber}:${row.dayOfWeek}`, row])); return (
{weekSlots.map((week, index) => ( ))} {weekSlots.map((week, index) => week === null ? )} {DAY_OPTIONS.map(day => ( {weekSlots.map((week, index) => { if (week === null) { return ( ))}
День{week}
{day.shortLabel}; })}
); } // --- Рендер --- const tooltipParts = tooltipState ? dayTooltipParts(tooltipState.row, activityTypes, timeSlotScopes, timeSlotDateAssignments) : null; return (
{dayDialogKeys && dayDialogRows.length > 0 && ( void clearTimeSlotDateAssignment()} onClose={closeDayDialog} alert={dayDialogTimeAlert} /> )} {yearDeleteDialog && ( void confirmDeleteYear(yearDeleteDialog.id)} onClose={() => setYearDeleteDialog(null)} /> )} {calendarDeleteDialog && ( void confirmDeleteCalendar(calendarDeleteDialog.id)} onClose={() => setCalendarDeleteDialog(null)} /> )} {tooltipState && tooltipParts && createPortal(
{tooltipParts.code}
{tooltipParts.longDate} {tooltipParts.shortDay}, {tooltipParts.week} неделя
Курс {tooltipParts.course}
Активность {tooltipParts.activityName}
Время {tooltipParts.timeLabel}
, document.body )}
); }