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
This commit is contained in:
dipatrik10
2026-09-28 23:15:04 +03:00
parent c6083b35e0
commit 97bf3cc50a
167 changed files with 31283 additions and 19033 deletions

View File

@@ -0,0 +1,468 @@
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,
QUALITY_YEAR_STORAGE_KEY,
SCHEDULE_OPEN_RULE_STORAGE_KEY,
SCHEDULE_OPEN_VERSION_STORAGE_KEY,
SCHEDULE_SEMESTER_STORAGE_KEY
} from '../schedule-versions-service.js';
import {
fetchScheduleQualityAnalysis,
fetchScheduleSemesters,
fetchScheduleVersions,
filterQualityProblems,
formatLocalDate,
formatMetricValue,
formatQualityDate,
lessonWord,
problemTotalLabel,
qualityBand,
qualitySemesterLabel,
severityLabel,
versionStatusLabel
} from '../schedule-quality-service.js';
const INITIAL_STATUS = Object.freeze({ message: 'Выберите семестр, чтобы получить объяснимую оценку.', tone: '' });
export function ScheduleQualityTab() {
const [semesters, setSemesters] = useState([]);
const [semestersPhase, setSemestersPhase] = useState('loading');
const [academicYearId, setAcademicYearId] = useState('');
const [semesterId, setSemesterId] = useState('');
const [versions, setVersions] = useState([]);
const [versionPhase, setVersionPhase] = useState('idle');
const [versionId, setVersionId] = useState('');
const [analysis, setAnalysis] = useState(null);
const [resultsHidden, setResultsHidden] = useState(true);
const [analyzing, setAnalyzing] = useState(false);
const [status, setStatus] = useState(INITIAL_STATUS);
const [severityFilter, setSeverityFilter] = useState('');
const [categoryFilter, setCategoryFilter] = useState('');
const [selectedProblemId, setSelectedProblemId] = useState(null);
const latestRef = useRef(null);
latestRef.current = { academicYearId, semesterId, versionId };
const years = useMemo(() => academicYearsFromSemesters(semesters), [semesters]);
const yearSemesters = useMemo(
() => semestersForAcademicYear(semesters, academicYearId),
[semesters, academicYearId]
);
const visibleProblems = useMemo(
() => analysis ? filterQualityProblems(analysis.problems || [], severityFilter, categoryFilter) : [],
[analysis, severityFilter, categoryFilter]
);
const band = analysis ? qualityBand(analysis.score) : null;
const selectedProblem = analysis
? (analysis.problems || []).find(problem => problem.id === selectedProblemId)
: null;
useEffect(() => {
async function initialize() {
try {
const loadedSemesters = await fetchScheduleSemesters();
setSemesters(loadedSemesters);
if (!loadedSemesters.length) {
setSemestersPhase('empty');
setStatus({ message: 'Сначала создайте учебный год и семестр.', tone: 'warning' });
return;
}
setSemestersPhase('ready');
const selected = resolveAcademicPeriod(loadedSemesters, {
academicYearId: window.localStorage.getItem(QUALITY_YEAR_STORAGE_KEY),
semesterId: window.localStorage.getItem(QUALITY_SEMESTER_STORAGE_KEY),
date: formatLocalDate(new Date())
});
setAcademicYearId(selected.academicYearId);
setSemesterId(selected.semesterId);
window.localStorage.setItem(QUALITY_YEAR_STORAGE_KEY, selected.academicYearId);
await loadVersions(selected.semesterId);
} catch (error) {
setSemestersPhase('error');
setStatus({ message: error.message || 'Не удалось загрузить семестры.', tone: 'error' });
}
}
void initialize();
}, []);
async function loadVersions(overrideSemesterId) {
const nextSemesterId = overrideSemesterId ?? latestRef.current.semesterId;
if (!nextSemesterId) return;
setVersionPhase('loading');
try {
const loadedVersions = await fetchScheduleVersions(nextSemesterId);
setVersions(loadedVersions);
if (!loadedVersions.length) {
setVersionPhase('empty');
return;
}
setVersionPhase('ready');
const savedVersionId = window.localStorage.getItem(QUALITY_VERSION_STORAGE_KEY);
const saved = loadedVersions.find(version => String(version.id) === savedVersionId);
const published = loadedVersions.find(version => version.status === 'PUBLISHED');
setVersionId(String((saved || published || loadedVersions[0]).id));
window.localStorage.setItem(QUALITY_YEAR_STORAGE_KEY, latestRef.current.academicYearId);
window.localStorage.setItem(QUALITY_SEMESTER_STORAGE_KEY, nextSemesterId);
} catch (error) {
setVersionPhase('error');
setStatus({ message: error.message || 'Не удалось загрузить версии расписания.', tone: 'error' });
}
}
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(QUALITY_YEAR_STORAGE_KEY, nextYearId);
void loadVersions(selected.semesterId);
}
function handleSemesterChange(event) {
const nextSemesterId = event.target.value || '';
setSemesterId(nextSemesterId);
void loadVersions(nextSemesterId);
}
async function runAnalysis() {
const currentSemesterId = latestRef.current.semesterId;
const currentVersionId = latestRef.current.versionId;
if (!currentSemesterId) {
setStatus({ message: 'Выберите семестр для анализа.', tone: 'warning' });
return;
}
if (!currentVersionId) {
setStatus({ message: 'Выберите версию расписания для анализа.', tone: 'warning' });
return;
}
window.localStorage.setItem(QUALITY_SEMESTER_STORAGE_KEY, currentSemesterId);
window.localStorage.setItem(QUALITY_VERSION_STORAGE_KEY, currentVersionId);
setAnalyzing(true);
setResultsHidden(true);
setStatus({ message: 'Строим фактическое расписание и рассчитываем показатели…', tone: 'loading' });
try {
const nextAnalysis = await fetchScheduleQualityAnalysis(currentSemesterId, currentVersionId);
setAnalysis(nextAnalysis);
setSelectedProblemId(null);
setResultsHidden(false);
trackUxEvent('качество_расписания_проверено', {
semesterId: currentSemesterId,
versionId: currentVersionId,
score: Number(nextAnalysis.score || 0)
});
setStatus({
message: nextAnalysis.scheduleVersionStatus === 'DRAFT'
? 'Черновик проверен. Проблемные правила можно открыть в конструкторе до публикации.'
: 'Анализ завершён. Проблемные правила можно открыть в конструкторе расписания.',
tone: 'success'
});
} catch (error) {
setStatus({ message: error.message || 'Не удалось выполнить анализ качества.', tone: 'error' });
} finally {
setAnalyzing(false);
}
}
function handleProblemClick(problem, action) {
if (action === 'edit-rule') {
openRuleInConstructor(problem);
return;
}
setSelectedProblemId(problem.id);
}
function openRuleInConstructor(problem) {
if (!problem?.scheduleRuleId || analysis?.scheduleVersionStatus === 'ARCHIVED') return;
window.localStorage.setItem(SCHEDULE_SEMESTER_STORAGE_KEY, String(analysis.semesterId));
window.localStorage.setItem(SCHEDULE_OPEN_VERSION_STORAGE_KEY, String(analysis.scheduleVersionId));
window.localStorage.setItem(SCHEDULE_OPEN_RULE_STORAGE_KEY, String(problem.scheduleRuleId));
window.location.hash = 'schedule';
}
const yearOptions = semestersPhase === 'ready' || semestersPhase === 'empty'
? (years.length
? years.map(year => <option key={String(year.id)} value={String(year.id)}>{year.title}</option>)
: <option value="">Учебные годы не настроены</option>)
: <option value="">Загрузка годов…</option>;
const semesterOptions = semestersPhase === 'error'
? <option value="">Ошибка загрузки</option>
: (semestersPhase === 'ready' || semestersPhase === 'empty'
? (yearSemesters.length
? yearSemesters.map(semester => (
<option key={String(semester.id)} value={String(semester.id)}>
{qualitySemesterLabel(semester)}
</option>
))
: <option value="">Семестры не настроены</option>)
: <option value="">Сначала выберите год</option>);
const versionPlaceholder = {
idle: 'Сначала выберите семестр',
loading: 'Загрузка версий…',
empty: 'Версии не созданы',
error: 'Ошибка загрузки'
}[versionPhase];
const versionOptions = versionPhase === 'ready'
? versions.map(version => (
<option key={String(version.id)} value={String(version.id)}>
v{version.versionNumber} · {version.name} · {versionStatusLabel(version.status)}
</option>
))
: <option value="">{versionPlaceholder}</option>;
const archived = analysis?.scheduleVersionStatus === 'ARCHIVED';
return (
<section className="quality-lab" aria-labelledby="quality-lab-title">
<header className="quality-hero">
<div className="quality-hero-copy">
<span className="quality-kicker">Диагностика · публикация или черновик</span>
<h2 id="quality-lab-title">Качество видно до перестановок</h2>
<p>
Анализатор находит окна, перегруженные дни, неудачные аудитории и нарушения
пожеланий. Все проблемные занятия объединяются по правилу, а их штрафы
суммируются. Черновик можно оценить до публикации.
</p>
</div>
<div className="quality-run-panel">
<label htmlFor="quality-year">Учебный год</label>
<select id="quality-year" value={academicYearId} onChange={handleYearChange}>
{yearOptions}
</select>
<label htmlFor="quality-semester">Семестр</label>
<select id="quality-semester" value={semesterId} onChange={handleSemesterChange}>
{semesterOptions}
</select>
<label htmlFor="quality-version">Версия</label>
<select id="quality-version" value={versionId} onChange={event => setVersionId(event.target.value)}>
{versionOptions}
</select>
<button
type="button"
className="btn btn-md btn-primary"
id="quality-run"
disabled={analyzing}
onClick={() => void runAnalysis()}
>
{analyzing ? 'Анализируем…' : 'Выполнить анализ'}
</button>
</div>
</header>
<div
className="quality-status"
id="quality-status"
role="status"
aria-live="polite"
data-tone={status.tone || undefined}
>
{status.message}
</div>
<div className="quality-results" id="quality-results" hidden={resultsHidden}>
{analysis ? (
<>
<section className="quality-scoreboard" aria-label="Итог анализа">
<div className="quality-score-orbit" id="quality-score-orbit" data-band={band.code}>
<svg className="quality-score-ring" viewBox="0 0 36 36" aria-hidden="true">
<circle className="quality-score-ring-track" cx="18" cy="18" r="15.9155"></circle>
<circle
className="quality-score-ring-value"
id="quality-score-ring-value"
cx="18"
cy="18"
r="15.9155"
pathLength="100"
strokeDasharray={`${Math.max(0, Math.min(100, analysis.score))} 100`}
></circle>
</svg>
<div className="quality-score-inner">
<strong id="quality-score">{analysis.score}</strong>
<span>из 100</span>
</div>
</div>
<div className="quality-score-copy">
<span className="quality-score-band" id="quality-score-band" data-band={band.code}>
{band.label}
</span>
<h3 id="quality-semester-label">{analysis.semesterLabel}</h3>
<p id="quality-score-summary">{band.summary}</p>
<div className="quality-score-meta" id="quality-score-meta">
<span>{versionStatusLabel(analysis.scheduleVersionStatus)} · v{analysis.scheduleVersionNumber || '—'}</span>
<span>{analysis.lessonCount} занятий</span>
<span>правил с проблемами: {analysis.totalProblemCount}</span>
<span>штраф {analysis.rawPenalty}</span>
</div>
</div>
</section>
<section className="quality-metrics" id="quality-metrics" aria-label="Метрики качества">
{(analysis.metrics || []).map((metric, index) => (
<article className={`quality-metric quality-metric-${index + 1}`} key={index}>
<span className="quality-metric-index">{String(index + 1).padStart(2, '0')}</span>
<strong>{formatMetricValue(metric)}</strong>
<h4>{metric.label}</h4>
<p>{metric.explanation}</p>
<span className="quality-metric-penalty">штраф {metric.penalty}</span>
</article>
))}
</section>
<section className="quality-workbench">
<div className="quality-problems-column">
<div className="quality-section-heading">
<div>
<span className="quality-kicker">Карта правил</span>
<h3>Правила, снижающие оценку</h3>
</div>
<span className="quality-problem-total" id="quality-problem-total">
{problemTotalLabel(analysis)}
</span>
</div>
<div className="quality-filters">
<label>
Важность
<select
id="quality-severity-filter"
value={severityFilter}
onChange={event => setSeverityFilter(event.target.value)}
>
<option value="">Все уровни</option>
<option value="CRITICAL">Критические</option>
<option value="HIGH">Высокие</option>
<option value="MEDIUM">Средние</option>
<option value="LOW">Низкие</option>
</select>
</label>
<label>
Категория
<select
id="quality-type-filter"
value={categoryFilter}
onChange={event => setCategoryFilter(event.target.value)}
>
<option value="">Все категории</option>
<option value="GAPS">Окна и компактность</option>
<option value="LOAD">Дневная нагрузка</option>
<option value="ROOM">Аудитории</option>
<option value="PREFERENCE">Пожелания</option>
</select>
</label>
</div>
<div className="quality-problems" id="quality-problems">
{visibleProblems.length ? visibleProblems.map(problem => {
const selected = problem.id === selectedProblemId;
const affectedLessonCount = Number(problem.affectedLessonCount || 1);
const issueLabels = problem.issueLabels?.length ? problem.issueLabels : [problem.title];
const canEdit = analysis.scheduleVersionStatus !== 'ARCHIVED' && problem.scheduleRuleId;
return (
<article
className={`quality-problem${selected ? ' is-selected' : ''}`}
key={String(problem.id)}
data-severity={problem.severity}
>
<button
type="button"
className="quality-problem-main"
onClick={() => handleProblemClick(problem)}
>
<span className="quality-severity">{severityLabel(problem.severity)}</span>
<span className="quality-problem-copy">
<strong>{problem.title}</strong>
<span>{problem.description}</span>
<span className="quality-rule-issues">
{issueLabels.map((label, index) => <span key={index}>{label}</span>)}
</span>
<small>
Правило #{problem.scheduleRuleId || '—'}
· затронуто {affectedLessonCount} {lessonWord(affectedLessonCount)}
· суммарный штраф
</small>
</span>
<span className="quality-penalty">−{problem.penalty}</span>
</button>
<div className="quality-problem-action">
{canEdit ? (
<button
type="button"
className="btn btn-sm btn-secondary"
onClick={() => handleProblemClick(problem, 'edit-rule')}
>
Изменить правило
</button>
) : <span>Только просмотр</span>}
</div>
</article>
);
}) : (
<div className="quality-empty-state">
<strong>По выбранному фильтру проблем нет</strong>
<span>Измените уровень важности или категорию.</span>
</div>
)}
</div>
</div>
<aside className="quality-recommendations" id="quality-recommendations" aria-live="polite">
{selectedProblem ? (
<RuleDetails problem={selectedProblem} archived={archived} onEdit={openRuleInConstructor} />
) : (
<div className="quality-recommendations-empty">
<span className="quality-recommendations-mark">↗</span>
<h3>{archived ? 'Архивная версия' : 'Работа с правилом'}</h3>
<p>
{archived
? 'Правила этой версии доступны только для просмотра. Для правок создайте новый черновик.'
: 'Выберите правило, чтобы увидеть его суммарное влияние и открыть его в конструкторе.'}
</p>
</div>
)}
</aside>
</section>
</>
) : null}
</div>
</section>
);
}
function RuleDetails({ problem, archived, onEdit }) {
const affectedLessonCount = Number(problem.affectedLessonCount || 1);
const issueLabels = problem.issueLabels?.length ? problem.issueLabels : [problem.title];
const canEdit = !archived && problem.scheduleRuleId;
return (
<>
<div className="quality-recommendations-head">
<span className="quality-kicker">Правило #{problem.scheduleRuleId || '—'}</span>
<h3>{problem.subjectName || problem.title}</h3>
<p>Затронуто {affectedLessonCount} {lessonWord(affectedLessonCount)}; суммарный штраф −{problem.penalty}.</p>
</div>
<div className="quality-rule-detail-list">
{issueLabels.map((label, index) => <span key={index}>{label}</span>)}
</div>
{canEdit ? (
<button
type="button"
className="btn btn-md btn-primary quality-edit-rule"
onClick={() => onEdit(problem)}
>
Открыть правило в конструкторе
</button>
) : <p className="quality-recommendations-note">Архивное расписание нельзя изменить.</p>}
</>
);
}