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 (
Контур публикации

Расписание меняется за кулисами

Черновики изолированы от студентов и преподавателей. Сравните правила и занятия, проверьте целостность и опубликуйте весь пакет одной операцией.

{status.message}
{loaded ? (published ? ( <>
LIVE
Сейчас видят пользователи

{published.name} · версия {published.versionNumber}

{published.changeReason || 'Причина первой публикации не указана'}

{published.ruleCount} правил {published.slotCount} слотов {formatVersionDateTime(published.publishedAt)}
) : ( <>
ПУСТО

Опубликованной версии нет

Создайте первый черновик и опубликуйте его после проверки.

)) : null}
Подготовка

Черновики

{drafts.length}
{loaded ? (drafts.length ? drafts.map(version => (
Черновик · v{version.versionNumber}

{version.name}

{draftBasisText(version)}

{version.ruleCount} правил · {version.slotCount} слотов · {formatVersionDateTime(version.createdAt)}
)) : (
+

Нет активных черновиков

Создайте копию опубликованной версии — её изменения останутся невидимыми до публикации.

)) : null}
Возврат без паники

Архив публикаций

{loaded ? (archive.length ? archive.map(version => (
Архив · v{version.versionNumber}

{version.name}

{version.changeReason || 'Причина не указана'}

{version.ruleCount} правил · публикация {formatVersionDateTime(version.publishedAt)}
)) :

Предыдущих публикаций пока нет.

) : null}
Неизменяемый след

Журнал действий

{loaded ? (history.length ? history.map((item, index) => (
{historyActionLabel(item.action)}: v{item.versionNumber} · {item.versionName}

{item.reason || 'Без комментария'}

{item.actorName || 'Система'}
)) :

Действия ещё не зафиксированы.

) : null}
{modal ? (
{ if (event.target === event.currentTarget) closeModal(); }} >
{modal.kicker}

{modal.title}

{modal.copy}

) : null}
); } function VersionDiff({ diff }) { return ( <> {diff.baseVersionLabel} → {diff.targetVersionLabel}

Что изменится после публикации

+{diff.addedLessonCount}занятий
{diff.changedLessonCount}изменено
−{diff.removedLessonCount}удалено

Правила: +{diff.addedRuleCount} · ~{diff.changedRuleCount} · −{diff.removedRuleCount} · без изменений {diff.unchangedRuleCount}

{(diff.ruleChanges || []).length ? diff.ruleChanges.map((change, index) => (
{ruleChangeTypeLabel(change.type)} · {change.subjectName} {(change.groupNames || []).join(', ') || 'Группы не указаны'} {change.beforeSummary ? Было: {change.beforeSummary} : null} {change.afterSummary ? Стало: {change.afterSummary} : null}
)) : (

Правила совпадают

Структурных изменений между версиями нет.

)}
); }