Files
magistr/frontend/react/admin/tabs/AcademicCalendarTab.jsx
dipatrik10 97bf3cc50a feat: завершить миграцию фронтенда на React (этапы 1-5)
Админка, настройки, логин, кабинеты преподавателя и студента
полностью переведены на React 19 (модель strangler, docs/REACT_MIGRATION.md).

- 15 вкладок админки: lazy-загрузка, общий error boundary, сервисный слой
- Удалены legacy-views и переходный слой LegacyTabView (45 файлов)
- Сборка esbuild (frontend/scripts/build-react.mjs) с бюджетами gzip на чанки
- CSP-хэши, кэширование: chunk'и immutable, CSS/HTML no-cache (react-cache.conf)
- Фиксы: lazy-импорт именованных экспортов (краш после авторизации),
  растяжение #admin-root/#settings-root на всю ширину,
  возврат subjects/lessonTypes из справочников (краш просмотра расписаний),
  отказ от заведомо широкого поиска расписания без выбранной цели,
  центрирование карточки логина
- Тесты: 237/237 (npm run check), регрессионные на lazy-вкладки и ширину
- Документация: REACT_MIGRATION.md, REACT_MIGRATION_RESULT.md, FRONTEND.md
2026-09-28 23:15:04 +03:00

2245 lines
109 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useRef, useState } from 'react';
import { 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 (
<input
type="text"
id={id}
data-date-input=""
inputMode="numeric"
maxLength="10"
pattern="[0-9]{2}\.[0-9]{2}\.[0-9]{4}"
placeholder="ДД.ММ.ГГГГ"
required={required || undefined}
aria-invalid={invalid ? 'true' : undefined}
aria-describedby={describedBy}
value={value}
ref={ref}
onChange={event => {
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 (
<div
className="modal-overlay open"
id="calendar-day-dialog"
onClick={event => {
if (event.target === event.currentTarget) onClose();
}}
>
<div className="modal-content card calendar-day-dialog-card">
<h2>Ячейка графика</h2>
<button
type="button"
className="btn btn-icon-md btn-ghost modal-close"
id="calendar-day-dialog-close"
aria-label="Закрыть окно"
onClick={onClose}
>
&times;
</button>
<div className="calendar-day-dialog-grid">
<div>
<span>Дата</span>
<strong id="calendar-day-dialog-date">{texts.date}</strong>
</div>
<div>
<span>Курс</span>
<strong id="calendar-day-dialog-course">{texts.course}</strong>
</div>
<div>
<span>Неделя</span>
<strong id="calendar-day-dialog-week">{texts.week}</strong>
</div>
<div>
<span>День</span>
<strong id="calendar-day-dialog-day">{texts.day}</strong>
</div>
</div>
<div
className={`calendar-day-dialog-activity${summaryActivity ? ` ${activityColorClass(summaryActivity)}` : ''}`}
id="calendar-day-dialog-activity-summary"
>
{summaryActivity ? (
<>
<span className="calendar-day-dialog-code">{summaryActivity.code}</span>
<div>
<strong>{summary.kind === 'multi'
? `${summaryActivity.name} · ${summary.count} ячеек`
: summaryActivity.name}</strong>
<p>{summaryActivity.description
|| (summaryActivity.allowSchedule
? 'Обычные пары разрешены'
: 'Обычные пары не генерируются')}</p>
</div>
</>
) : summary.kind === 'multi-empty' ? (
<>
<span className="calendar-day-dialog-code">...</span>
<div>
<strong>Выбрано ячеек: {summary.count}</strong>
<p>Выберите код активности, чтобы применить его ко всему выделению</p>
</div>
</>
) : null}
</div>
<div className="form-group">
<label htmlFor="calendar-day-dialog-activity">Код активности</label>
<select id="calendar-day-dialog-activity" value={common} onChange={onActivityChange}>
<option value="">Выберите</option>
{activityTypes.map(type => (
<option key={type.id} value={type.id}>{`${type.code} - ${type.name}`}</option>
))}
</select>
</div>
<div className="calendar-day-dialog-time-grid">
<div className="form-group">
<label htmlFor="calendar-day-dialog-time-scope">Временная сетка даты</label>
<select
id="calendar-day-dialog-time-scope"
value={scopeValue}
disabled={!isSingle}
onChange={event => setScopeValue(event.target.value)}
>
{isSingle ? (
<>
<option value="">{automaticTimeScopeLabel(rows[0].date, scopes)}</option>
{manualScopes.map(scope => (
<option key={scope.id} value={scope.id}>{scope.name}</option>
))}
</>
) : (
<option value="">Для выделения доступен только код дня</option>
)}
</select>
</div>
<div className="calendar-day-dialog-actions">
<button
type="button"
className="btn btn-md btn-primary"
id="calendar-day-dialog-time-save"
onClick={onClose}
>
Применить
</button>
<button
type="button"
className="btn btn-md btn-danger-subtle"
id="calendar-day-dialog-time-clear"
disabled={!isSingle || !assignment}
onClick={onClearAssignment}
>
Убрать ручную сетку
</button>
</div>
<FormAlert id="calendar-day-dialog-time-alert" alert={alert} />
</div>
</div>
</div>
);
}
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 (
<td
key={row.date}
className={`calendar-day ${activityColorClass(activity)}${isSelected ? ' is-selected' : ''}`}
data-course={row.courseNumber}
data-date={row.date}
data-week={row.weekNumber}
data-day={row.dayOfWeek}
data-time-assignment-id={assignment?.id || ''}
data-time-scope-id={assignment?.scopeId || ''}
role="button"
tabIndex={0}
aria-label={`${formatShortDate(row.date)} ${shortDayLabel(row.dayOfWeek)}, ${row.weekNumber} неделя, код ${code}`}
onMouseDown={event => startDayDragSelection(event, key)}
onMouseOver={() => addDayToDragSelection(key)}
onMouseMove={event => showDayTooltip(row, event)}
onClick={event => handleDayClick(event, key)}
onKeyDown={event => handleDayKeyDown(event, key)}
onFocus={() => showDayTooltip(row)}
>
<strong className="calendar-day-code">{code}</strong>
{assignment ? <span className="calendar-day-time-marker">вр.</span> : null}
</td>
);
}
function renderSemesterTable(rows, targetWeekColumns) {
if (!rows.length) {
return <div className="calendar-empty-semester">Нет дат в этом семестре</div>;
}
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 (
<div className="calendar-semester-table-wrap">
<table className="calendar-semester-table">
<colgroup>
<col className="calendar-day-head-col" />
{weekSlots.map((week, index) => (
<col key={index} className="calendar-week-col" />
))}
</colgroup>
<thead>
<tr>
<th className="calendar-day-head">День</th>
{weekSlots.map((week, index) => week === null
? <th key={index} className="calendar-week-placeholder" aria-hidden="true" />
: <th key={index} title={`Неделя ${week} учебного года`}>{week}</th>)}
</tr>
</thead>
<tbody>
{DAY_OPTIONS.map(day => (
<tr key={day.value}>
<th className="calendar-day-head">{day.shortLabel}</th>
{weekSlots.map((week, index) => {
if (week === null) {
return (
<td
key={index}
className="calendar-empty-day calendar-week-placeholder"
aria-hidden="true"
/>
);
}
const row = cellByWeekAndDay.get(`${week}:${day.value}`);
return row
? renderCalendarDay(row)
: <td key={index} className="calendar-empty-day" />;
})}
</tr>
))}
</tbody>
</table>
</div>
);
}
// --- Рендер ---
const tooltipParts = tooltipState
? dayTooltipParts(tooltipState.row, activityTypes, timeSlotScopes, timeSlotDateAssignments)
: null;
return (
<div>
<div className="academic-calendar-tabs" role="tablist" aria-label="Разделы календарных учебных графиков">
<button
type="button"
className={`academic-calendar-tab${activeSection === 'manage' ? ' active' : ''}`}
data-calendar-tab="manage"
role="tab"
aria-selected={activeSection === 'manage' ? 'true' : 'false'}
aria-controls="academic-calendar-tab-manage"
onClick={() => void switchSection('manage')}
>
Графики
</button>
<button
type="button"
className={`academic-calendar-tab${activeSection === 'grid' ? ' active' : ''}`}
data-calendar-tab="grid"
role="tab"
aria-selected={activeSection === 'grid' ? 'true' : 'false'}
aria-controls="academic-calendar-tab-grid"
onClick={() => void switchSection('grid')}
>
Сетки
</button>
<button
type="button"
className={`academic-calendar-tab${activeSection === 'subjects' ? ' active' : ''}`}
data-calendar-tab="subjects"
role="tab"
aria-selected={activeSection === 'subjects' ? 'true' : 'false'}
aria-controls="academic-calendar-tab-subjects"
onClick={() => void switchSection('subjects')}
>
Дисциплины
</button>
</div>
<section
className={`academic-calendar-tab-panel${activeSection === 'manage' ? ' active' : ''}`}
id="academic-calendar-tab-manage"
data-calendar-tab-panel="manage"
role="tabpanel"
hidden={activeSection !== 'manage'}
>
<div className="card">
<div className="card-header-row">
<h2>Учебные годы</h2>
<button
type="button"
className="btn btn-md btn-secondary"
id="academic-years-refresh"
onClick={() => void loadYears().catch(() => {})}
>
Обновить
</button>
</div>
<div className="table-wrap">
<table>
<thead>
<tr>
<th>Название</th>
<th>Период</th>
<th>Семестры</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="academic-years-tbody">
{yearsState.loading ? (
<tr><td colSpan={4} className="loading-row">Загрузка...</td></tr>
) : yearsState.error ? (
<tr><td colSpan={4} className="loading-row">Ошибка загрузки: {yearsState.error}</td></tr>
) : !academicYears.length ? (
<tr><td colSpan={4} className="loading-row">Учебные годы не созданы</td></tr>
) : academicYears.map(year => (
<tr key={year.id}>
<td className="academic-year-center-cell academic-year-title-cell">{year.title}</td>
<td className="academic-year-center-cell academic-year-period-cell">
{formatDisplayDateRange(year.startDate, year.endDate)}
</td>
<td className="academic-year-semesters-cell">
{(year.semesters || []).length ? (
<div className="academic-year-semesters">
{(year.semesters || []).map(semester => (
<div key={semester.id} className="academic-year-semester-item">
<div className="academic-year-semester-meta">
<span className="academic-year-semester-type">
{semesterTypeTitle(semester)}
</span>
<span className="academic-year-semester-period">
{formatDisplayDateRange(semester.startDate, semester.endDate)}
</span>
</div>
<button
type="button"
className="btn btn-sm btn-secondary btn-edit-semester"
aria-label={`Изменить ${semesterTypeTitle(semester)} семестр ${formatDisplayDateRange(semester.startDate, semester.endDate)}`}
onClick={() => fillYearForm(year, String(semester.semesterType || '').toLowerCase())}
>
Изменить
</button>
</div>
))}
</div>
) : (
<span className="muted">Нет семестров</span>
)}
</td>
<td className="academic-year-actions-cell">
<div className="academic-year-actions">
<button
type="button"
className="btn btn-sm btn-secondary btn-edit-year"
onClick={() => fillYearForm(year)}
>
Изменить
</button>
<button
type="button"
className="btn btn-sm btn-danger btn-delete-year"
onClick={() => setYearDeleteDialog({ id: year.id })}
>
Удалить
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div className="card create-card" ref={calendarFormRef}>
<div className="card-header-row">
<h2 id="academic-calendar-form-title">{calendarForm.title}</h2>
<button
type="button"
className="btn btn-md btn-ghost"
id="academic-calendar-reset"
onClick={resetCalendarForm}
>
Очистить
</button>
</div>
<form id="academic-calendar-form" noValidate onSubmit={submitCalendarForm}>
<div className="form-row">
<div className="form-group">
<label htmlFor="academic-calendar-year">Учебный год</label>
<select
id="academic-calendar-year"
required
value={calendarForm.yearId}
onChange={event => setCalendarForm(current => ({
...current,
yearId: event.target.value
}))}
>
{baseLoaded ? (
<>
<option value="">Выберите учебный год</option>
{academicYears.map(year => (
<option key={year.id} value={year.id}>{year.title}</option>
))}
</>
) : (
<option value="">Загрузка...</option>
)}
</select>
</div>
<div className="form-group">
<label htmlFor="academic-calendar-specialty">Специальность</label>
<select
id="academic-calendar-specialty"
required
value={calendarForm.specialtyId}
onChange={event => setCalendarForm(current => ({
...current,
specialtyId: event.target.value,
profileId: ''
}))}
>
{baseLoaded ? (
<>
<option value="">Выберите специальность</option>
{specialties.map(speciality => (
<option key={speciality.id} value={speciality.id}>
{specialityLabel(speciality)}
</option>
))}
</>
) : (
<option value="">Загрузка...</option>
)}
</select>
</div>
<div className="form-group">
<label htmlFor="academic-calendar-profile">Профиль</label>
<select
id="academic-calendar-profile"
required
value={calendarForm.profileId}
onChange={event => setCalendarForm(current => ({
...current,
profileId: event.target.value
}))}
>
{profiles.length ? (
<>
<option value="">{profileEmptyLabel}</option>
{profiles.map(profile => (
<option key={profile.id} value={profile.id}>{profile.name}</option>
))}
</>
) : (
<option value="">{profileEmptyLabel}</option>
)}
</select>
</div>
<div className="form-group">
<label htmlFor="academic-calendar-study-form">Форма обучения</label>
<select
id="academic-calendar-study-form"
required
value={calendarForm.studyFormId}
onChange={event => setCalendarForm(current => ({
...current,
studyFormId: event.target.value
}))}
>
{baseLoaded ? (
<>
<option value="">Выберите форму</option>
{studyForms.map(form => (
<option key={form.id} value={form.id}>{form.name}</option>
))}
</>
) : (
<option value="">Загрузка...</option>
)}
</select>
</div>
<div className="form-group">
<label htmlFor="academic-calendar-course-count">Курсов</label>
<input
type="number"
id="academic-calendar-course-count"
min="1"
max="8"
required
value={calendarForm.courseCount}
onChange={event => setCalendarForm(current => ({
...current,
courseCount: event.target.value
}))}
/>
</div>
<button type="submit" className="btn btn-md btn-primary">Сохранить график</button>
</div>
<FormAlert id="academic-calendar-alert" alert={calendarAlert} />
</form>
</div>
<div className="card">
<div className="card-header-row">
<h2>Календарные графики</h2>
<button
type="button"
className="btn btn-md btn-secondary"
id="academic-calendars-refresh"
onClick={() => void loadCalendars().catch(() => {})}
>
Обновить
</button>
</div>
<div className="form-row">
<div className="form-group">
<label htmlFor="academic-calendars-search">Поиск графиков</label>
<input
type="search"
id="academic-calendars-search"
placeholder="Направление, профиль, форма или год"
value={calendarSearchValue}
onChange={event => {
setCalendarSearchValue(event.target.value);
calendarsQueryRef.current = event.target.value;
debouncedCalendarsSearch();
}}
/>
</div>
<div className="form-group">
<label htmlFor="academic-calendars-year-filter">Учебный год</label>
<select
id="academic-calendars-year-filter"
value={calendarYearFilter}
onChange={event => {
setCalendarYearFilter(event.target.value);
calendarsYearFilterRef.current = event.target.value;
calendarsPageRef.current = 0;
void loadCalendars().catch(() => {});
}}
>
<option value="">Все учебные годы</option>
{academicYears.map(year => (
<option key={year.id} value={year.id}>{year.title}</option>
))}
</select>
</div>
</div>
<div className="table-wrap">
<table>
<thead>
<tr>
<th>Учебный год</th>
<th>Специальность</th>
<th>Профиль</th>
<th>Форма</th>
<th>Курсов</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="academic-calendars-tbody">
{calendarsState.loading ? (
<tr><td colSpan={6} className="loading-row">Загрузка...</td></tr>
) : calendarsState.error ? (
<tr>
<td colSpan={6} className="loading-row">
Ошибка загрузки: {calendarsState.error}
</td>
</tr>
) : !calendarsState.items.length ? (
<tr><td colSpan={6} className="loading-row">Календарные графики не найдены</td></tr>
) : calendarsState.items.map(calendar => (
<tr key={calendar.id}>
<td>{calendar.academicYearTitle || '-'}</td>
<td>{`${calendar.specialtyCode || ''} ${calendar.specialtyName || ''}`.trim() || '-'}</td>
<td>{calendar.specialtyProfileName || '-'}</td>
<td>{calendar.studyFormName || '-'}</td>
<td>{calendar.courseCount || '-'}</td>
<td>
<button
type="button"
className="btn btn-sm btn-secondary btn-edit-calendar"
onClick={() => editCalendar(calendar)}
>
Изменить
</button>
<button
type="button"
className="btn btn-sm btn-secondary btn-open-calendar-grid"
onClick={() => openCalendarGrid(calendar)}
>
Сетка
</button>
<button
type="button"
className="btn btn-sm btn-secondary btn-open-calendar-subjects"
onClick={() => openCalendarSubjects(calendar)}
>
Дисциплины
</button>
<button
type="button"
className="btn btn-sm btn-danger btn-delete-calendar"
onClick={() => setCalendarDeleteDialog({ id: calendar.id })}
>
Удалить
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{!calendarsState.loading && !calendarsState.error && (
<div id="academic-calendars-pagination">
<Pagination
page={calendarsState.page}
size={calendarsState.size}
totalItems={calendarsState.totalItems}
totalPages={calendarsState.totalPages}
label="графиков"
onPage={value => {
calendarsPageRef.current = value;
void loadCalendars().catch(() => {});
}}
onSize={value => {
calendarsSizeRef.current = value;
calendarsPageRef.current = 0;
void loadCalendars().catch(() => {});
}}
/>
</div>
)}
</div>
<div className="card create-card academic-year-builder-card" ref={yearFormCardRef}>
<div className="card-header-row">
<div>
<h2 id="academic-year-form-title">{yearForm.title}</h2>
<p className="muted academic-year-builder-description">
Учебный год создаётся сразу с осенним и весенним семестрами.
</p>
</div>
<button
type="button"
className="btn btn-md btn-ghost"
id="academic-year-reset"
onClick={resetYearForm}
>
Очистить
</button>
</div>
<form id="academic-year-form" noValidate onSubmit={submitYearForm}>
<div className="academic-year-period-grid">
<div className="form-group">
<label htmlFor="academic-year-start">Год начала учебного года</label>
<input
type="text"
id="academic-year-start"
inputMode="numeric"
maxLength="4"
pattern="[0-9]{4}"
placeholder="2026"
autoComplete="off"
aria-describedby="academic-year-start-hint"
required
value={yearForm.startYear}
onChange={event => handleStartYearInput(event.target.value)}
/>
<small className="muted academic-year-field-hint" id="academic-year-start-hint">
Введите четыре цифры — дата начала будет 1 сентября.
</small>
</div>
<div className="form-group">
<label htmlFor="academic-year-end">Год окончания учебного года</label>
<input
type="text"
id="academic-year-end"
inputMode="numeric"
maxLength="4"
placeholder="2027"
readOnly
aria-readonly="true"
required
value={yearForm.endYear}
onChange={() => {}}
/>
<small className="muted academic-year-field-hint">
Подставляется автоматически и формирует название учебного года.
</small>
</div>
</div>
<section className="academic-year-semester-builder" aria-labelledby="academic-year-semesters-title">
<div className="academic-year-semester-builder-head">
<div>
<h3 id="academic-year-semesters-title">Семестры</h3>
<p className="muted">Проверьте даты: оба семестра будут сохранены вместе с учебным годом.</p>
</div>
<span className="academic-year-required-badge">Обязательно</span>
</div>
<div className="academic-year-semester-grid">
<article className="academic-year-semester-card" data-academic-year-semester="autumn">
<div className="academic-year-semester-card-title">
<span
className="academic-year-semester-dot academic-year-semester-dot-autumn"
aria-hidden="true"
/>
<strong>Осенний семестр</strong>
</div>
<div className="academic-year-semester-dates">
<div className="form-group">
<label htmlFor="academic-year-autumn-start">Начало</label>
<MaskedDateInput
id="academic-year-autumn-start"
required
value={yearForm.autumn.start}
inputRef={ref => {
semesterInputRefs.current.autumn.start = ref;
}}
onChange={value => setYearForm(current => ({
...current,
autumn: { ...current.autumn, start: value }
}))}
/>
</div>
<div className="form-group">
<label htmlFor="academic-year-autumn-end">Окончание</label>
<MaskedDateInput
id="academic-year-autumn-end"
required
value={yearForm.autumn.end}
inputRef={ref => {
semesterInputRefs.current.autumn.end = ref;
}}
onChange={value => setYearForm(current => ({
...current,
autumn: { ...current.autumn, end: value }
}))}
/>
</div>
</div>
</article>
<article className="academic-year-semester-card" data-academic-year-semester="spring">
<div className="academic-year-semester-card-title">
<span
className="academic-year-semester-dot academic-year-semester-dot-spring"
aria-hidden="true"
/>
<strong>Весенний семестр</strong>
</div>
<div className="academic-year-semester-dates">
<div className="form-group">
<label htmlFor="academic-year-spring-start">Начало</label>
<MaskedDateInput
id="academic-year-spring-start"
required
value={yearForm.spring.start}
inputRef={ref => {
semesterInputRefs.current.spring.start = ref;
}}
onChange={value => setYearForm(current => ({
...current,
spring: { ...current.spring, start: value }
}))}
/>
</div>
<div className="form-group">
<label htmlFor="academic-year-spring-end">Окончание</label>
<MaskedDateInput
id="academic-year-spring-end"
required
value={yearForm.spring.end}
inputRef={ref => {
semesterInputRefs.current.spring.end = ref;
}}
onChange={value => setYearForm(current => ({
...current,
spring: { ...current.spring, end: value }
}))}
/>
</div>
</div>
</article>
</div>
</section>
<div className="academic-year-form-actions">
<button type="submit" className="btn btn-md btn-primary">
Сохранить учебный год и семестры
</button>
</div>
<FormAlert id="academic-year-alert" alert={yearAlert} />
</form>
</div>
</section>
<section
className={`academic-calendar-tab-panel${activeSection === 'grid' ? ' active' : ''}`}
id="academic-calendar-tab-grid"
data-calendar-tab-panel="grid"
role="tabpanel"
hidden={activeSection !== 'grid'}
>
<div className="card create-card">
<h2>Редактор годового графика</h2>
<div className="form-row">
<div className="form-group">
<label htmlFor="calendar-editor-calendar">График</label>
<AsyncCombobox
ariaLabel="Поиск по направлению, профилю, форме или году"
placeholder="Поиск по направлению, профилю, форме или году"
load={(query, selectedId) => fetchCalendarOptions(query, selectedId)}
optionLabel={calendarOptionLabel}
optionDescription={calendarOptionDescription}
onSelect={calendar => setEditorCalendarBoth(calendar)}
selectedLabel={editorCalendar ? calendarOptionLabel(editorCalendar) : ''}
selectedValue={editorCalendar ? String(editorCalendar.id) : ''}
/>
</div>
<button
type="button"
className="btn btn-md btn-secondary"
id="calendar-editor-load"
onClick={() => void loadCalendarGrid()}
>
Загрузить сетку
</button>
<button
type="button"
className="btn btn-md btn-primary"
id="calendar-editor-save"
onClick={() => void saveCalendarGrid()}
>
{gridDirty ? 'Сохранить сетку •' : 'Сохранить сетку'}
</button>
</div>
<div className="calendar-fill-panel">
<div className="form-group">
<label htmlFor="calendar-fill-course">Курс</label>
<select
id="calendar-fill-course"
value={fillForm.course}
onChange={event => setFillForm(current => ({
...current,
course: event.target.value
}))}
>
<option value="">Все курсы</option>
{fillCourseOptions.map(course => (
<option key={course} value={course}>{course} курс</option>
))}
</select>
</div>
<div className="form-group">
<label htmlFor="calendar-fill-start">С даты</label>
<MaskedDateInput
id="calendar-fill-start"
value={fillForm.start}
onChange={value => setFillForm(current => ({ ...current, start: value }))}
/>
</div>
<div className="form-group">
<label htmlFor="calendar-fill-end">По дату</label>
<MaskedDateInput
id="calendar-fill-end"
value={fillForm.end}
onChange={value => setFillForm(current => ({ ...current, end: value }))}
/>
</div>
<div className="form-group">
<label htmlFor="calendar-fill-activity">Код</label>
<select
id="calendar-fill-activity"
value={fillForm.activityId}
onChange={event => setFillForm(current => ({
...current,
activityId: event.target.value
}))}
>
{baseLoaded ? (
<>
<option value="">Выберите код</option>
{activityTypes.map(type => (
<option key={type.id} value={type.id}>
{`${type.code} - ${type.name}`}
</option>
))}
</>
) : (
<option value="">Загрузка...</option>
)}
</select>
</div>
<button
type="button"
className="btn btn-md btn-secondary"
id="calendar-fill-apply"
onClick={applyCalendarFill}
>
Заполнить диапазон
</button>
</div>
<FormAlert id="calendar-editor-alert" alert={gridAlert} />
<div id="calendar-totals" className="calendar-totals">
{totals.map(entry => (
<span
key={entry.code}
className={`calendar-total-chip ${entry.colorClass}${selectedCode === entry.code ? ' is-selected' : ''}`}
>
{entry.code}: {entry.count}
{selectedCode === entry.code && (
<span className="calendar-total-selected">выбрано</span>
)}
</span>
))}
</div>
<div
id="calendar-grid"
className="academic-calendar-grid"
ref={calendarGridRef}
onMouseLeave={hideDayTooltip}
onBlur={hideDayTooltip}
>
{courseBlocks.map(({ course, courseRows, blocks }) => {
const maxWeekColumns = Math.max(
...blocks.map(block => uniqueWeekNumbers(block.rows).length),
1
);
return (
<details key={course} className="calendar-course-block">
<summary className="calendar-course-header">
<span className="calendar-course-title">
<span className="calendar-course-chevron" aria-hidden="true" />
<span>{course} курс</span>
</span>
<span className="calendar-course-meta">{courseRows.length} дней</span>
</summary>
<div className="calendar-semesters-grid">
{blocks.map(block => (
<section key={block.semesterNumber} className="calendar-semester-block">
<div className="calendar-semester-header">
<h4>{block.semesterNumber} семестр</h4>
<span>{block.caption}</span>
</div>
{renderSemesterTable(block.rows, maxWeekColumns)}
</section>
))}
</div>
</details>
);
})}
</div>
</div>
</section>
<section
className={`academic-calendar-tab-panel${activeSection === 'subjects' ? ' active' : ''}`}
id="academic-calendar-tab-subjects"
data-calendar-tab-panel="subjects"
role="tabpanel"
hidden={activeSection !== 'subjects'}
>
<div className="card create-card">
<div className="card-header-row">
<h2>Дисциплины графика</h2>
<button
type="button"
className="btn btn-md btn-secondary"
id="calendar-subject-refresh"
onClick={() => void loadCalendarSubjects()}
>
Обновить
</button>
</div>
<div className="form-row">
<div className="form-group">
<label htmlFor="calendar-subject-calendar">График</label>
<AsyncCombobox
ariaLabel="Поиск по направлению, профилю, форме или году"
placeholder="Поиск по направлению, профилю, форме или году"
load={(query, selectedId) => fetchCalendarOptions(query, selectedId)}
optionLabel={calendarOptionLabel}
optionDescription={calendarOptionDescription}
onSelect={calendar => {
setSubjectCalendarBoth(calendar);
void loadCalendarSubjects(calendar);
}}
selectedLabel={subjectCalendar ? calendarOptionLabel(subjectCalendar) : ''}
selectedValue={subjectCalendar ? String(subjectCalendar.id) : ''}
/>
</div>
<div className="form-group">
<label htmlFor="calendar-subject-semester">Семестр</label>
<select
id="calendar-subject-semester"
value={subjectSemester}
onChange={event => setSubjectSemester(event.target.value)}
>
{subjectCalendar ? (
Array.from({ length: subjectSemesterCount }, (_, index) => {
const semesterNumber = index + 1;
return (
<option key={semesterNumber} value={semesterNumber}>
{semesterOptionLabel(semesterNumber)}
</option>
);
})
) : (
<option value="">Выберите график</option>
)}
</select>
</div>
<div className="form-group">
<label htmlFor="calendar-subject-id">Дисциплина</label>
<select
id="calendar-subject-id"
value={sortedSubjects.some(item => String(item.id) === subjectIdValue)
? subjectIdValue
: ''}
onChange={event => setSubjectIdValue(event.target.value)}
>
{baseLoaded ? (
sortedSubjects.length ? (
<>
<option value="">Выберите дисциплину</option>
{sortedSubjects.map(subject => (
<option key={subject.id} value={subject.id}>
{subjectLabel(subject)}
</option>
))}
</>
) : (
<option value="">Нет дисциплин</option>
)
) : (
<option value="">Загрузка...</option>
)}
</select>
</div>
<button
type="button"
className="btn btn-md btn-secondary"
id="calendar-subject-add"
onClick={addCalendarSubject}
>
Добавить
</button>
<button
type="button"
className="btn btn-md btn-primary"
id="calendar-subject-save"
onClick={() => void saveCalendarSubjects()}
>
{subjectDirty ? 'Сохранить привязки •' : 'Сохранить привязки'}
</button>
</div>
<FormAlert id="calendar-subject-alert" alert={subjectAlert} />
</div>
<div className="card">
<div className="card-header-row">
<h2>Привязки по семестрам</h2>
<span id="calendar-subject-count" className="muted">
{subjectCalendar ? `${subjectRows.length} дисциплин` : ''}
</span>
</div>
<div id="calendar-subject-summary" className="calendar-subject-summary" ref={subjectSummaryRef}>
{!subjectCalendar ? (
<div className="loading-row">Выберите календарный график</div>
) : subjectsState === 'loading' ? (
<div className="loading-row">Загрузка...</div>
) : subjectsState === 'error' ? (
<div className="loading-row">Ошибка загрузки: {subjectsError}</div>
) : (
Array.from({ length: subjectSemesterCount }, (_, index) => {
const semesterNumber = index + 1;
const semesterRows = subjectRows
.filter(row => Number(row.semesterNumber) === semesterNumber);
return (
<section key={semesterNumber} className="calendar-subject-semester">
<div className="calendar-subject-semester-head">
<strong>{semesterOptionLabel(semesterNumber)}</strong>
<span>{semesterRows.length} дисциплин</span>
</div>
{semesterRows.length ? (
<div className="calendar-subject-chips">
{semesterRows.map(row => (
<span
key={`${row.semesterNumber}:${row.subjectId}`}
className="calendar-subject-chip"
>
<span>{subjectRowLabel(row)}</span>
<button
type="button"
className="btn btn-icon-sm btn-danger-subtle calendar-subject-remove"
aria-label={`Убрать ${subjectRowLabel(row)}`}
onClick={() => removeSubjectRow(row)}
>
×
</button>
</span>
))}
</div>
) : (
<div className="calendar-subject-empty">Нет дисциплин</div>
)}
</section>
);
})
)}
</div>
</div>
</section>
{dayDialogKeys && dayDialogRows.length > 0 && (
<CalendarDayDialog
rows={dayDialogRows}
activityTypes={activityTypes}
scopes={timeSlotScopes}
assignments={timeSlotDateAssignments}
onActivityChange={handleDialogActivityChange}
onClearAssignment={() => void clearTimeSlotDateAssignment()}
onClose={closeDayDialog}
alert={dayDialogTimeAlert}
/>
)}
{yearDeleteDialog && (
<ConfirmDialog
title="Удаление учебного года"
message="Удалить учебный год вместе с семестрами и календарными графиками?"
confirmLabel="Удалить"
danger
onConfirm={() => void confirmDeleteYear(yearDeleteDialog.id)}
onClose={() => setYearDeleteDialog(null)}
/>
)}
{calendarDeleteDialog && (
<ConfirmDialog
title="Удаление календарного графика"
message="Удалить календарный график? Связанные настройки больше не будут доступны."
confirmLabel="Удалить"
danger
onConfirm={() => void confirmDeleteCalendar(calendarDeleteDialog.id)}
onClose={() => setCalendarDeleteDialog(null)}
/>
)}
{tooltipState && tooltipParts && createPortal(
<div
className={`calendar-day-tooltip visible ${tooltipParts.colorClass}`.trim()}
role="tooltip"
ref={tooltipRef}
>
<div className="calendar-day-tooltip-head">
<span className="calendar-day-tooltip-code"><span>{tooltipParts.code}</span></span>
<div>
<strong>{tooltipParts.longDate}</strong>
<span>{tooltipParts.shortDay}, {tooltipParts.week} неделя</span>
</div>
</div>
<div className="calendar-day-tooltip-body">
<div>
<span>Курс</span>
<strong>{tooltipParts.course}</strong>
</div>
<div>
<span>Активность</span>
<strong>{tooltipParts.activityName}</strong>
</div>
<div>
<span>Время</span>
<strong>{tooltipParts.timeLabel}</strong>
</div>
</div>
</div>,
document.body
)}
</div>
);
}