{version.name}
{draftBasisText(version)}
{version.ruleCount} правил · {version.slotCount} слотов · {formatVersionDateTime(version.createdAt)}import { useEffect, useMemo, useRef, useState } from 'react';
import {
academicYearsFromSemesters,
resolveAcademicPeriod,
semestersForAcademicYear
} from '../../../admin/js/academic-period.js';
import { trackUxEvent } from '../../../telemetry.js';
import {
QUALITY_SEMESTER_STORAGE_KEY,
QUALITY_VERSION_STORAGE_KEY,
SCHEDULE_OPEN_VERSION_STORAGE_KEY,
SCHEDULE_SEMESTER_STORAGE_KEY,
SCHEDULE_VERSION_STORAGE_KEY,
SCHEDULE_YEAR_STORAGE_KEY,
createScheduleDraftVersion,
draftBasisText,
fetchScheduleSemesters,
fetchScheduleVersionDiff,
fetchScheduleVersionHistory,
fetchScheduleVersionValidation,
fetchScheduleVersions,
formatLocalDate,
formatVersionDateTime,
historyActionLabel,
publishValidationSummary,
ruleChangeTypeLabel,
runScheduleVersionAction,
splitScheduleVersions,
versionSemesterLabel
} from '../schedule-versions-service.js';
const INITIAL_STATUS = Object.freeze({ message: 'Загружаем контур версий…', tone: '' });
export function ScheduleVersionsTab() {
const [semesters, setSemesters] = useState([]);
const [baseLoaded, setBaseLoaded] = useState(false);
const [academicYearId, setAcademicYearId] = useState('');
const [semesterId, setSemesterId] = useState('');
const [versions, setVersions] = useState([]);
const [history, setHistory] = useState([]);
const [loaded, setLoaded] = useState(false);
const [loading, setLoading] = useState(false);
const [status, setStatus] = useState(INITIAL_STATUS);
const [diffState, setDiffState] = useState(null);
const [modal, setModal] = useState(null);
const [modalValue, setModalValue] = useState('');
const [submitting, setSubmitting] = useState(false);
const latestRef = useRef(null);
latestRef.current = { academicYearId, semesterId, loading };
const years = useMemo(() => academicYearsFromSemesters(semesters), [semesters]);
const yearSemesters = useMemo(
() => semestersForAcademicYear(semesters, academicYearId),
[semesters, academicYearId]
);
const { published, drafts, archive } = useMemo(() => splitScheduleVersions(versions), [versions]);
useEffect(() => {
async function initialize() {
try {
const loadedSemesters = await fetchScheduleSemesters();
setSemesters(loadedSemesters);
setBaseLoaded(true);
if (!loadedSemesters.length) {
setStatus({ message: 'Сначала создайте учебный год и семестр.', tone: 'error' });
return;
}
const selected = resolveAcademicPeriod(loadedSemesters, {
academicYearId: window.localStorage.getItem(SCHEDULE_YEAR_STORAGE_KEY),
semesterId: window.localStorage.getItem(SCHEDULE_SEMESTER_STORAGE_KEY),
date: formatLocalDate(new Date())
});
setAcademicYearId(selected.academicYearId);
setSemesterId(selected.semesterId);
window.localStorage.setItem(SCHEDULE_YEAR_STORAGE_KEY, selected.academicYearId);
await loadWorkspace(selected.semesterId);
} catch (error) {
setStatus({ message: error.message || 'Не удалось загрузить семестры.', tone: 'error' });
}
}
void initialize();
}, []);
// Escape закрывает окно операции (в legacy — слушатель на document)
useEffect(() => {
if (!modal) return;
function handleKeydown(event) {
if (event.key === 'Escape') closeModal();
}
document.addEventListener('keydown', handleKeydown);
return () => document.removeEventListener('keydown', handleKeydown);
}, [modal]);
async function loadWorkspace(overrideSemesterId) {
const nextSemesterId = overrideSemesterId ?? latestRef.current.semesterId;
if (!nextSemesterId || latestRef.current.loading) return;
setLoading(true);
window.localStorage.setItem(SCHEDULE_YEAR_STORAGE_KEY, latestRef.current.academicYearId);
window.localStorage.setItem(SCHEDULE_SEMESTER_STORAGE_KEY, nextSemesterId);
setStatus({ message: 'Собираем версии, правила и журнал публикаций…', tone: 'loading' });
try {
const [nextVersions, nextHistory] = await Promise.all([
fetchScheduleVersions(nextSemesterId),
fetchScheduleVersionHistory(nextSemesterId)
]);
setVersions(nextVersions);
setHistory(nextHistory);
setLoaded(true);
trackUxEvent('версии_расписания_загружены', { semesterId: nextSemesterId, total: nextVersions.length });
setStatus({ message: 'Контур версий синхронизирован.', tone: 'success' });
} catch (error) {
setStatus({ message: error.message || 'Не удалось загрузить версии расписания.', tone: 'error' });
} finally {
setLoading(false);
}
}
function handleYearChange(event) {
const nextYearId = event.target.value || '';
const selected = resolveAcademicPeriod(semesters, {
academicYearId: nextYearId,
date: formatLocalDate(new Date())
});
setAcademicYearId(nextYearId);
setSemesterId(selected.semesterId);
window.localStorage.setItem(SCHEDULE_YEAR_STORAGE_KEY, nextYearId);
void loadWorkspace(selected.semesterId);
}
function handleSemesterChange(event) {
const nextSemesterId = event.target.value || '';
setSemesterId(nextSemesterId);
void loadWorkspace(nextSemesterId);
}
function handleVersionAction(version, action) {
if (action === 'edit') {
window.localStorage.setItem(SCHEDULE_SEMESTER_STORAGE_KEY, String(version.semesterId));
window.localStorage.setItem(SCHEDULE_OPEN_VERSION_STORAGE_KEY, String(version.id));
window.location.hash = 'schedule';
return;
}
if (action === 'quality') {
window.localStorage.setItem(QUALITY_SEMESTER_STORAGE_KEY, String(version.semesterId));
window.localStorage.setItem(QUALITY_VERSION_STORAGE_KEY, String(version.id));
window.location.hash = 'schedule-quality';
return;
}
if (action === 'diff') {
void loadDiff(version);
return;
}
if (action === 'publish') {
void openPublishModal(version);
return;
}
if (action === 'restore') {
openRestoreModal(version);
}
}
async function loadDiff(version) {
setDiffState({ phase: 'loading' });
try {
const diff = await fetchScheduleVersionDiff(version.id);
setDiffState({ phase: 'ready', diff });
} catch (error) {
setDiffState({ phase: 'error', message: error.message });
}
}
function openCreateModal() {
openModal({
mode: 'create',
version: published,
kicker: 'Изолированная рабочая копия',
title: 'Создать новый черновик',
copy: published
? `Правила версии ${published.versionNumber} будут скопированы. Пользователи продолжат видеть текущую публикацию.`
: 'Будет создан пустой черновик для первого расписания семестра.',
label: 'Название черновика',
submit: 'Создать черновик'
});
}
async function openPublishModal(version) {
setStatus({ message: 'Проверяем целостность черновика перед публикацией…', tone: 'loading' });
try {
const [validation, diff] = await Promise.all([
fetchScheduleVersionValidation(version.id),
fetchScheduleVersionDiff(version.id)
]);
openModal({
mode: 'publish',
version,
valid: validation.valid,
kicker: 'Атомарная публикация',
title: `Опубликовать версию ${version.versionNumber}`,
copy: 'После подтверждения текущая версия целиком уйдёт в архив, а этот черновик станет виден студентам и преподавателям.',
label: 'Причина изменений',
submit: 'Опубликовать',
summary: publishValidationSummary(validation, diff)
});
setStatus(validation.valid
? { message: 'Черновик готов к публикации.', tone: 'success' }
: { message: 'Черновик требует исправлений.', tone: 'error' });
} catch (error) {
setStatus({ message: error.message || 'Не удалось проверить черновик.', tone: 'error' });
}
}
function openRestoreModal(version) {
openModal({
mode: 'restore',
version,
kicker: 'Контролируемый возврат',
title: `Восстановить версию ${version.versionNumber}`,
copy: 'Текущая публикация будет архивирована, а выбранная версия снова станет активной одной транзакцией.',
label: 'Причина восстановления',
submit: 'Восстановить'
});
}
function openModal(config) {
setModal(config);
setModalValue(config.value || '');
setSubmitting(false);
}
function closeModal() {
setModal(null);
setModalValue('');
setSubmitting(false);
}
async function handleSubmit(event) {
event.preventDefault();
if (!modal || modal.valid === false || submitting) return;
const value = modalValue.trim();
if (!value) {
setStatus({
message: modal.mode === 'create' ? 'Укажите название черновика.' : 'Укажите причину операции.',
tone: 'error'
});
return;
}
setSubmitting(true);
try {
if (modal.mode === 'create') {
const created = await createScheduleDraftVersion({
semesterId: Number(latestRef.current.semesterId),
name: value,
basedOnVersionId: modal.version?.id || null
});
window.localStorage.setItem(SCHEDULE_VERSION_STORAGE_KEY, String(created.id));
setStatus({ message: `Черновик «${created.name}» создан.`, tone: 'success' });
} else {
await runScheduleVersionAction(modal.version.id, modal.mode, value);
setStatus({
message: modal.mode === 'publish'
? 'Версия опубликована атомарно.'
: 'Предыдущая версия восстановлена.',
tone: 'success'
});
}
closeModal();
await loadWorkspace();
} catch (error) {
setStatus({ message: error.message || 'Операцию не удалось выполнить.', tone: 'error' });
setSubmitting(false);
}
}
const yearOptions = !baseLoaded
?
: (years.length
? years.map(year => )
: );
const semesterOptions = !baseLoaded
?
: (yearSemesters.length
? yearSemesters.map(semester => (
))
: );
return (
Черновики изолированы от студентов и преподавателей. Сравните правила и занятия, проверьте целостность и опубликуйте весь пакет одной операцией. {published.changeReason || 'Причина первой публикации не указана'} Создайте первый черновик и опубликуйте его после проверки. {draftBasisText(version)} Создайте копию опубликованной версии — её изменения останутся невидимыми до публикации. {version.changeReason || 'Причина не указана'} Предыдущих публикаций пока нет. {item.reason || 'Без комментария'} Действия ещё не зафиксированы. {modal.copy}Расписание меняется за кулисами
{published.name} · версия {published.versionNumber}
Опубликованной версии нет
Черновики
{version.name}
Нет активных черновиков
Архив публикаций
{version.name}
Журнал действий
{modal.title}
Правила: +{diff.addedRuleCount} · ~{diff.changedRuleCount} · −{diff.removedRuleCount} · без изменений {diff.unchangedRuleCount}
Структурных изменений между версиями нет.