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

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

623 lines
31 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useRef, useState } from 'react';
import {
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
? <option value="">Загрузка годов…</option>
: (years.length
? years.map(year => <option key={String(year.id)} value={String(year.id)}>{year.title}</option>)
: <option value="">Учебные годы не настроены</option>);
const semesterOptions = !baseLoaded
? <option value="">Сначала выберите год</option>
: (yearSemesters.length
? yearSemesters.map(semester => (
<option key={String(semester.id)} value={String(semester.id)}>
{versionSemesterLabel(semester)}
</option>
))
: <option value="">Семестры не настроены</option>);
return (
<section className="version-studio" aria-labelledby="version-studio-title">
<header className="version-hero">
<div>
<span className="version-eyebrow">Контур публикации</span>
<h2 id="version-studio-title">Расписание меняется за кулисами</h2>
<p>Черновики изолированы от студентов и преподавателей. Сравните правила и занятия, проверьте целостность и опубликуйте весь пакет одной операцией.</p>
</div>
<div className="version-hero-controls">
<div className="version-hero-period-grid">
<div>
<label htmlFor="version-year">Учебный год</label>
<select id="version-year" value={academicYearId} onChange={handleYearChange}>
{yearOptions}
</select>
</div>
<div>
<label htmlFor="version-semester">Рабочий семестр</label>
<select id="version-semester" value={semesterId} onChange={handleSemesterChange}>
{semesterOptions}
</select>
</div>
</div>
<button type="button" className="btn btn-md btn-primary" id="version-create" onClick={openCreateModal}>
Новый черновик
</button>
</div>
</header>
<div
className="version-status"
id="version-status"
role="status"
aria-live="polite"
data-tone={status.tone || undefined}
>
{status.message}
</div>
<section className="version-current" id="version-current" aria-label="Опубликованная версия">
{loaded ? (published ? (
<>
<div className="version-live-mark">LIVE</div>
<div>
<span className="version-eyebrow">Сейчас видят пользователи</span>
<h3>{published.name} · версия {published.versionNumber}</h3>
<p>{published.changeReason || 'Причина первой публикации не указана'}</p>
<div className="version-stats">
<span>{published.ruleCount} правил</span>
<span>{published.slotCount} слотов</span>
<span>{formatVersionDateTime(published.publishedAt)}</span>
</div>
</div>
<div className="version-card-actions">
<button
type="button"
className="btn btn-sm btn-primary"
onClick={() => handleVersionAction(published, 'edit')}
>
Редактировать
</button>
<button
type="button"
className="btn btn-sm btn-secondary"
onClick={() => handleVersionAction(published, 'quality')}
>
Проверить качество
</button>
</div>
</>
) : (
<>
<div className="version-live-mark">ПУСТО</div>
<div>
<h3>Опубликованной версии нет</h3>
<p>Создайте первый черновик и опубликуйте его после проверки.</p>
</div>
</>
)) : null}
</section>
<div className="version-workbench">
<section className="version-drafts-panel">
<div className="version-section-head">
<div>
<span className="version-eyebrow">Подготовка</span>
<h3>Черновики</h3>
</div>
<span id="version-draft-count">{drafts.length}</span>
</div>
<div className="version-draft-list" id="version-drafts">
{loaded ? (drafts.length ? drafts.map(version => (
<article
className="version-card"
key={String(version.id)}
data-number={String(version.versionNumber).padStart(2, '0')}
>
<div className="version-card-top">
<div>
<span className="version-status-chip">Черновик · v{version.versionNumber}</span>
<h4>{version.name}</h4>
</div>
</div>
<p>{draftBasisText(version)}</p>
<small>
{version.ruleCount} правил · {version.slotCount} слотов · {formatVersionDateTime(version.createdAt)}
</small>
<div className="version-card-actions">
<button
type="button"
className="btn btn-sm btn-primary"
onClick={() => handleVersionAction(version, 'edit')}
>
Редактировать
</button>
<button
type="button"
className="btn btn-sm btn-secondary"
onClick={() => handleVersionAction(version, 'diff')}
>
Сравнить
</button>
<button
type="button"
className="btn btn-sm btn-secondary"
onClick={() => handleVersionAction(version, 'quality')}
>
Качество
</button>
<button
type="button"
className="btn btn-sm btn-secondary"
onClick={() => handleVersionAction(version, 'publish')}
>
Опубликовать
</button>
</div>
</article>
)) : (
<div className="version-empty">
<span>+</span>
<h3>Нет активных черновиков</h3>
<p>Создайте копию опубликованной версии — её изменения останутся невидимыми до публикации.</p>
</div>
)) : null}
</div>
</section>
<aside className="version-diff-panel" id="version-diff">
{diffState === null ? (
<div className="version-empty">
<span>Δ</span>
<h3>Сравнение версий</h3>
<p>Выберите «Сравнить» у черновика, чтобы увидеть изменения правил и сформированных занятий.</p>
</div>
) : diffState.phase === 'loading' ? (
<div className="version-empty">
<span>…</span>
<h3>Сравниваем расписания</h3>
<p>Генерируем занятия обеих версий по всему семестру.</p>
</div>
) : diffState.phase === 'error' ? (
<div className="version-empty">
<span>!</span>
<h3>Сравнение недоступно</h3>
<p>{diffState.message}</p>
</div>
) : (
<VersionDiff diff={diffState.diff} />
)}
</aside>
</div>
<section className="version-archive-panel">
<div className="version-section-head">
<div>
<span className="version-eyebrow">Возврат без паники</span>
<h3>Архив публикаций</h3>
</div>
</div>
<div className="version-archive-list" id="version-archive">
{loaded ? (archive.length ? archive.map(version => (
<article
className="version-card"
key={String(version.id)}
data-number={String(version.versionNumber).padStart(2, '0')}
>
<span className="version-status-chip">Архив · v{version.versionNumber}</span>
<h4>{version.name}</h4>
<p>{version.changeReason || 'Причина не указана'}</p>
<small>{version.ruleCount} правил · публикация {formatVersionDateTime(version.publishedAt)}</small>
<div className="version-card-actions">
<button
type="button"
className="btn btn-sm btn-secondary"
onClick={() => handleVersionAction(version, 'diff')}
>
Сравнить
</button>
<button
type="button"
className="btn btn-sm btn-secondary"
onClick={() => handleVersionAction(version, 'quality')}
>
Качество
</button>
<button
type="button"
className="btn btn-sm btn-danger"
onClick={() => handleVersionAction(version, 'restore')}
>
Восстановить
</button>
</div>
</article>
)) : <p className="version-empty-note">Предыдущих публикаций пока нет.</p>) : null}
</div>
</section>
<section className="version-history-panel">
<div className="version-section-head">
<div>
<span className="version-eyebrow">Неизменяемый след</span>
<h3>Журнал действий</h3>
</div>
</div>
<div className="version-history" id="version-history">
{loaded ? (history.length ? history.map((item, index) => (
<article className="version-history-row" key={index}>
<time>{formatVersionDateTime(item.createdAt)}</time>
<div>
<strong>
{historyActionLabel(item.action)}: v{item.versionNumber} · {item.versionName}
</strong>
<p>{item.reason || 'Без комментария'}</p>
</div>
<span className="version-status-chip">{item.actorName || 'Система'}</span>
</article>
)) : <p className="version-empty-note">Действия ещё не зафиксированы.</p>) : null}
</div>
</section>
{modal ? (
<div
className="version-modal"
id="version-modal"
onClick={event => {
if (event.target === event.currentTarget) closeModal();
}}
>
<div className="version-modal-card" role="dialog" aria-modal="true" aria-labelledby="version-modal-title">
<button
type="button"
className="btn btn-icon-md btn-ghost version-modal-close"
id="version-modal-close"
aria-label="Закрыть окно"
onClick={closeModal}
>
&times;
</button>
<span className="version-eyebrow" id="version-modal-kicker">{modal.kicker}</span>
<h3 id="version-modal-title">{modal.title}</h3>
<p id="version-modal-copy">{modal.copy}</p>
<form id="version-modal-form" onSubmit={handleSubmit}>
<label htmlFor="version-modal-value" id="version-modal-label">{modal.label}</label>
<textarea
id="version-modal-value"
rows="3"
maxLength="2000"
required
autoFocus
value={modalValue}
onChange={event => setModalValue(event.target.value)}
></textarea>
<div className="version-modal-summary" id="version-modal-summary" hidden={!modal.summary}>
{modal.summary || ''}
</div>
<div className="version-modal-actions">
<button type="button" className="btn btn-md btn-ghost" id="version-modal-cancel" onClick={closeModal}>
Отмена
</button>
<button
type="submit"
className="btn btn-md btn-primary"
id="version-modal-submit"
disabled={modal.valid === false || submitting}
>
{submitting ? 'Выполняем…' : modal.submit}
</button>
</div>
</form>
</div>
</div>
) : null}
</section>
);
}
function VersionDiff({ diff }) {
return (
<>
<span className="version-eyebrow">{diff.baseVersionLabel} → {diff.targetVersionLabel}</span>
<h3>Что изменится после публикации</h3>
<div className="version-diff-score">
<div><strong>+{diff.addedLessonCount}</strong><span>занятий</span></div>
<div><strong>{diff.changedLessonCount}</strong><span>изменено</span></div>
<div><strong>−{diff.removedLessonCount}</strong><span>удалено</span></div>
</div>
<p className="version-diff-caption">
Правила: +{diff.addedRuleCount} · ~{diff.changedRuleCount} · −{diff.removedRuleCount} · без изменений {diff.unchangedRuleCount}
</p>
<div className="version-change-list">
{(diff.ruleChanges || []).length
? diff.ruleChanges.map((change, index) => (
<article className="version-change" key={index} data-type={change.type}>
<strong>{ruleChangeTypeLabel(change.type)} · {change.subjectName}</strong>
<span>{(change.groupNames || []).join(', ') || 'Группы не указаны'}</span>
{change.beforeSummary ? <span>Было: {change.beforeSummary}</span> : null}
{change.afterSummary ? <span>Стало: {change.afterSummary}</span> : null}
</article>
))
: (
<div className="version-empty">
<h3>Правила совпадают</h3>
<p>Структурных изменений между версиями нет.</p>
</div>
)}
</div>
</>
);
}