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 (
Диагностика · публикация или черновик

Качество видно до перестановок

Анализатор находит окна, перегруженные дни, неудачные аудитории и нарушения пожеланий. Все проблемные занятия объединяются по правилу, а их штрафы суммируются. Черновик можно оценить до публикации.

{status.message}
); } 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 ( <>
Правило #{problem.scheduleRuleId || '—'}

{problem.subjectName || problem.title}

Затронуто {affectedLessonCount} {lessonWord(affectedLessonCount)}; суммарный штраф −{problem.penalty}.

{issueLabels.map((label, index) => {label})}
{canEdit ? ( ) :

Архивное расписание нельзя изменить.

} ); }