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:
468
frontend/react/admin/tabs/ScheduleQualityTab.jsx
Normal file
468
frontend/react/admin/tabs/ScheduleQualityTab.jsx
Normal 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>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user