{metric.label}
{metric.explanation}
штраф {metric.penalty}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 => )
: )
: ;
const semesterOptions = semestersPhase === 'error'
?
: (semestersPhase === 'ready' || semestersPhase === 'empty'
? (yearSemesters.length
? yearSemesters.map(semester => (
))
: )
: );
const versionPlaceholder = {
idle: 'Сначала выберите семестр',
loading: 'Загрузка версий…',
empty: 'Версии не созданы',
error: 'Ошибка загрузки'
}[versionPhase];
const versionOptions = versionPhase === 'ready'
? versions.map(version => (
))
: ;
const archived = analysis?.scheduleVersionStatus === 'ARCHIVED';
return (
Анализатор находит окна, перегруженные дни, неудачные аудитории и нарушения
пожеланий. Все проблемные занятия объединяются по правилу, а их штрафы
суммируются. Черновик можно оценить до публикации.
{band.summary} {metric.explanation}Качество видно до перестановок
{analysis.semesterLabel}
{metric.label}
Правила, снижающие оценку
Затронуто {affectedLessonCount} {lessonWord(affectedLessonCount)}; суммарный штраф −{problem.penalty}.
Архивное расписание нельзя изменить.
} > ); }