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