Files
magistr/frontend/react/admin/tabs/ScheduleQualityTab.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

469 lines
26 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,
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>}
</>
);
}