import { api } from '../api.js';
import { escapeHtml } from '../utils.js';
let qualityState = null;
export async function initScheduleQuality() {
const root = document.querySelector('.quality-lab');
if (!root) return;
qualityState = { root, analysis: null, versions: [], visibleProblems: [], selectedProblemId: null, applying: false };
root.querySelector('#quality-run')?.addEventListener('click', runAnalysis);
root.querySelector('#quality-semester')?.addEventListener('change', loadVersions);
root.querySelector('#quality-severity-filter')?.addEventListener('change', renderProblems);
root.querySelector('#quality-type-filter')?.addEventListener('change', renderProblems);
root.querySelector('#quality-problems')?.addEventListener('click', handleProblemClick);
root.querySelector('#quality-recommendations')?.addEventListener('click', handleRecommendationClick);
await loadSemesters();
}
async function loadSemesters() {
const select = qualityState?.root.querySelector('#quality-semester');
if (!select) return;
try {
const semesters = await api.get('/api/schedule/semesters');
select.innerHTML = (semesters || []).map(semester => `
${escapeHtml(semesterLabel(semester))}
`).join('');
if (!semesters?.length) {
select.innerHTML = 'Семестры не настроены ';
qualityStatus('Сначала создайте учебный год и семестр.', 'warning');
return;
}
const today = localIsoDate(new Date());
const current = semesters.find(semester =>
semester.startDate <= today && semester.endDate >= today
);
select.value = String(current?.id ?? semesters[0].id);
const savedSemesterId = localStorage.getItem('magistr.quality.semesterId');
if (semesters.some(semester => String(semester.id) === savedSemesterId)) {
select.value = savedSemesterId;
}
await loadVersions();
} catch (error) {
select.innerHTML = 'Ошибка загрузки ';
qualityStatus(error.message || 'Не удалось загрузить семестры.', 'error');
}
}
async function loadVersions() {
const semesterId = qualityState?.root.querySelector('#quality-semester')?.value;
const select = qualityState?.root.querySelector('#quality-version');
if (!select || !semesterId) return;
select.innerHTML = 'Загрузка версий… ';
try {
qualityState.versions = await api.get(
`/api/edu-office/schedule/versions?semesterId=${encodeURIComponent(semesterId)}`
);
select.innerHTML = qualityState.versions.map(version => `
v${escapeHtml(String(version.versionNumber))} · ${escapeHtml(version.name)} · ${escapeHtml(versionStatusLabel(version.status))}
`).join('');
if (!qualityState.versions.length) {
select.innerHTML = 'Версии не созданы ';
return;
}
const savedVersionId = localStorage.getItem('magistr.quality.versionId');
const saved = qualityState.versions.find(version => String(version.id) === savedVersionId);
const published = qualityState.versions.find(version => version.status === 'PUBLISHED');
select.value = String((saved || published || qualityState.versions[0]).id);
localStorage.setItem('magistr.quality.semesterId', semesterId);
} catch (error) {
select.innerHTML = 'Ошибка загрузки ';
qualityStatus(error.message || 'Не удалось загрузить версии расписания.', 'error');
}
}
async function runAnalysis() {
const semesterId = qualityState?.root.querySelector('#quality-semester')?.value;
const versionId = qualityState?.root.querySelector('#quality-version')?.value;
if (!semesterId) {
qualityStatus('Выберите семестр для анализа.', 'warning');
return;
}
if (!versionId) {
qualityStatus('Выберите версию расписания для анализа.', 'warning');
return;
}
localStorage.setItem('magistr.quality.semesterId', semesterId);
localStorage.setItem('magistr.quality.versionId', versionId);
const button = qualityState.root.querySelector('#quality-run');
button.disabled = true;
button.textContent = 'Анализируем…';
qualityStatus('Строим фактическое расписание и рассчитываем показатели…', 'loading');
qualityState.root.querySelector('#quality-results').hidden = true;
try {
qualityState.analysis = await api.get(
`/api/edu-office/schedule/quality?semesterId=${encodeURIComponent(semesterId)}&versionId=${encodeURIComponent(versionId)}`
);
qualityState.selectedProblemId = null;
renderAnalysis();
qualityStatus(
qualityState.analysis.scheduleVersionStatus === 'DRAFT'
? 'Черновик проверен. Исправления вносите в его правила до публикации.'
: 'Анализ завершён. Рекомендации ничего не изменят без подтверждения.',
'success'
);
} catch (error) {
qualityStatus(error.message || 'Не удалось выполнить анализ качества.', 'error');
} finally {
button.disabled = false;
button.textContent = 'Выполнить анализ';
}
}
function renderAnalysis() {
const analysis = qualityState?.analysis;
if (!analysis) return;
qualityState.root.querySelector('#quality-results').hidden = false;
const band = qualityBand(analysis.score);
const orbit = qualityState.root.querySelector('#quality-score-orbit');
orbit.dataset.band = band.code;
qualityState.root.querySelector('#quality-score-ring-value')
.setAttribute('stroke-dasharray', `${Math.max(0, Math.min(100, analysis.score))} 100`);
qualityState.root.querySelector('#quality-score').textContent = String(analysis.score);
qualityState.root.querySelector('#quality-score-band').textContent = band.label;
qualityState.root.querySelector('#quality-score-band').dataset.band = band.code;
qualityState.root.querySelector('#quality-semester-label').textContent = analysis.semesterLabel;
qualityState.root.querySelector('#quality-score-summary').textContent = band.summary;
qualityState.root.querySelector('#quality-score-meta').innerHTML = `
${escapeHtml(versionStatusLabel(analysis.scheduleVersionStatus))} · v${escapeHtml(String(analysis.scheduleVersionNumber || '—'))}
${escapeHtml(String(analysis.lessonCount))} занятий
${escapeHtml(String(analysis.totalProblemCount))} проблем
штраф ${escapeHtml(String(analysis.rawPenalty))}
`;
qualityState.root.querySelector('#quality-metrics').innerHTML = (analysis.metrics || [])
.map((metric, index) => `
${String(index + 1).padStart(2, '0')}
${escapeHtml(formatMetricValue(metric))}
${escapeHtml(metric.label)}
${escapeHtml(metric.explanation)}
штраф ${escapeHtml(String(metric.penalty))}
`).join('');
const totalLabel = analysis.problemsTruncated
? `Показаны первые ${analysis.problems.length} из ${analysis.totalProblemCount}`
: `${analysis.totalProblemCount} ${problemWord(analysis.totalProblemCount)}`;
qualityState.root.querySelector('#quality-problem-total').textContent = totalLabel;
renderProblems();
renderRecommendationsEmpty();
}
function renderProblems() {
const container = qualityState?.root.querySelector('#quality-problems');
if (!container || !qualityState.analysis) return;
const severity = qualityState.root.querySelector('#quality-severity-filter')?.value || '';
const category = qualityState.root.querySelector('#quality-type-filter')?.value || '';
qualityState.visibleProblems = filterQualityProblems(
qualityState.analysis.problems || [], severity, category
);
if (!qualityState.visibleProblems.length) {
container.innerHTML = `
По выбранному фильтру проблем нет
Измените уровень важности или категорию.
`;
return;
}
container.innerHTML = qualityState.visibleProblems.map((problem, index) => {
const selected = problem.id === qualityState.selectedProblemId;
return `
${escapeHtml(severityLabel(problem.severity))}
${escapeHtml(problem.title)}
${escapeHtml(problem.description)}
${escapeHtml(formatDate(problem.lessonDate))}
· ${escapeHtml(problem.subjectName || 'Занятие')}
${problem.timeSlotOrder ? ` · ${escapeHtml(String(problem.timeSlotOrder))} пара` : ''}
−${escapeHtml(String(problem.penalty))}
${problem.optimizable && qualityState.analysis.scheduleVersionStatus === 'PUBLISHED'
? `
Подобрать улучшения
`
: `${escapeHtml(problemActionLabel(problem))} `}
`;
}).join('');
}
async function handleProblemClick(event) {
const button = event.target.closest('[data-problem-index]');
if (!button) return;
const problem = qualityState?.visibleProblems[Number(button.dataset.problemIndex)];
if (!problem) return;
qualityState.selectedProblemId = problem.id;
renderProblems();
if (qualityState.analysis.scheduleVersionStatus !== 'PUBLISHED') {
renderRecommendationsMessage(
qualityState.analysis.scheduleVersionStatus === 'DRAFT'
? 'Черновик остаётся изолированным'
: 'Архивная версия доступна только для проверки',
qualityState.analysis.scheduleVersionStatus === 'DRAFT'
? 'Откройте его в конструкторе правил и внесите изменения там. Точечные изменения создаются только для опубликованного расписания.'
: 'Чтобы снова менять это расписание, восстановите версию или создайте на её основе новый черновик.'
);
return;
}
if (!problem.optimizable) {
renderRecommendationsMessage(
'Ручное решение сохранено',
'Для этой пары уже действует точечное изменение. Анализатор не заменяет его автоматически.'
);
return;
}
await loadRecommendations(problem);
}
async function loadRecommendations(problem) {
const panel = qualityState.root.querySelector('#quality-recommendations');
panel.innerHTML = `
Проверяем варианты
Каждый кандидат проходит полную проверку конфликтов.
`;
try {
const params = new URLSearchParams({
semesterId: String(qualityState.analysis.semesterId),
versionId: String(qualityState.analysis.scheduleVersionId),
scheduleRuleSlotId: String(problem.scheduleRuleSlotId),
lessonDate: problem.lessonDate
});
const result = await api.get(
`/api/edu-office/schedule/quality/recommendations?${params.toString()}`
);
renderRecommendations(problem, result);
} catch (error) {
renderRecommendationsMessage(
'Не удалось подобрать варианты',
error.message || 'Повторите попытку после обновления анализа.'
);
}
}
function renderRecommendations(problem, result) {
const panel = qualityState.root.querySelector('#quality-recommendations');
const candidates = result.candidates || [];
if (!candidates.length) {
renderRecommendationsMessage(
'Свободных улучшений не найдено',
'Текущее занятие нельзя локально перенести или заменить аудиторию без нового конфликта.'
);
return;
}
panel.innerHTML = `
Проверенные варианты
${escapeHtml(problem.title)}
${escapeHtml(formatDate(problem.lessonDate))} · ${escapeHtml(result.subjectName || 'Занятие')}
${candidates.map((candidate, index) => recommendationCard(candidate, index)).join('')}
Применение создаёт обычное точечное изменение и не переписывает правило семестра.
`;
panel._qualityCandidates = candidates;
}
function recommendationCard(candidate, index) {
const impactClass = candidate.penaltyDelta > 0
? 'is-positive'
: candidate.penaltyDelta < 0 ? 'is-negative' : 'is-neutral';
const impact = candidate.scoreDelta > 0
? `+${candidate.scoreDelta} к оценке`
: candidate.scoreDelta < 0 ? `${candidate.scoreDelta} к оценке` : 'оценка без изменений';
return `
${candidate.action === 'MOVE' ? 'Новое время' : 'Новая аудитория'}
${escapeHtml(candidate.label)}
${escapeHtml(candidate.description)}
${escapeHtml(impact)}
результат ${escapeHtml(String(candidate.resultingScore))}/100
${changeList('Улучшится', candidate.improvements, 'positive')}
${changeList('Компромиссы', candidate.tradeoffs, 'negative')}
Применить после подтверждения
`;
}
function changeList(label, items, tone) {
if (!items?.length) return '';
return `
${escapeHtml(label)}
${items.map(item => `${escapeHtml(item)} `).join('')}
`;
}
async function handleRecommendationClick(event) {
const button = event.target.closest('[data-candidate-index]');
if (!button || qualityState?.applying) return;
const panel = qualityState.root.querySelector('#quality-recommendations');
const candidate = panel._qualityCandidates?.[Number(button.dataset.candidateIndex)];
if (!candidate) return;
const confirmed = window.confirm(
`Применить вариант «${candidate.label}»?\n\nБудет создано точечное изменение расписания.`
);
if (!confirmed) return;
qualityState.applying = true;
button.disabled = true;
button.textContent = 'Применяем…';
try {
await api.post('/api/edu-office/schedule/overrides', candidate.override);
qualityStatus('Рекомендация применена как точечное изменение. Анализ обновлён.', 'success');
await runAnalysis();
} catch (error) {
qualityStatus(error.message || 'Не удалось применить рекомендацию.', 'error');
button.disabled = false;
button.textContent = 'Применить после подтверждения';
} finally {
qualityState.applying = false;
}
}
function renderRecommendationsEmpty() {
if (qualityState?.analysis?.scheduleVersionStatus !== 'PUBLISHED') {
const isDraft = qualityState.analysis.scheduleVersionStatus === 'DRAFT';
renderRecommendationsMessage(
isDraft ? 'Режим проверки черновика' : 'Режим проверки архива',
isDraft
? 'Проблемы рассчитаны по рабочей версии. Откройте черновик в конструкторе, чтобы исправить правила до публикации.'
: 'Метрики рассчитаны без изменения архивной версии. Для правок создайте новый черновик или восстановите эту публикацию.'
);
return;
}
renderRecommendationsMessage(
'Локальные улучшения',
'Выберите проблему, чтобы проверить свободные слоты и аудитории.'
);
}
function problemActionLabel(problem) {
if (qualityState?.analysis?.scheduleVersionStatus === 'DRAFT') {
return 'Исправляется в конструкторе';
}
if (qualityState?.analysis?.scheduleVersionStatus === 'ARCHIVED') {
return 'Только просмотр';
}
return problem.optimizable ? 'Нет доступного действия' : 'Закреплено вручную';
}
function renderRecommendationsMessage(title, message) {
const panel = qualityState?.root.querySelector('#quality-recommendations');
if (!panel) return;
panel.innerHTML = `
↗
${escapeHtml(title)}
${escapeHtml(message)}
`;
panel._qualityCandidates = [];
}
function qualityStatus(message, tone = '') {
const status = qualityState?.root.querySelector('#quality-status');
if (!status) return;
status.textContent = message;
status.dataset.tone = tone;
}
export function filterQualityProblems(problems, severity = '', category = '') {
return (problems || []).filter(problem => {
const severityMatches = !severity || problem.severity === severity;
const categoryMatches = !category || problemCategory(problem.type) === category;
return severityMatches && categoryMatches;
});
}
function problemCategory(type = '') {
if (type.includes('GAP') || type.includes('COMPACTNESS')) return 'GAPS';
if (type.includes('LOAD')) return 'LOAD';
if (type.includes('ROOM')) return 'ROOM';
return 'PREFERENCE';
}
export function qualityBand(score) {
if (score >= 90) {
return { code: 'excellent', label: 'Сильное расписание', summary: 'Найдены только локальные неудобства с небольшим влиянием.' };
}
if (score >= 75) {
return { code: 'stable', label: 'Устойчивое расписание', summary: 'Расписание работоспособно, но несколько перестановок заметно улучшат комфорт.' };
}
if (score >= 55) {
return { code: 'attention', label: 'Нужна доработка', summary: 'Мягкие ограничения и распределение нагрузки требуют внимания учебного отдела.' };
}
return { code: 'critical', label: 'Высокая цена неудобств', summary: 'Сначала разберите критические проблемы и перегруженные дни.' };
}
export function formatMetricValue(metric) {
const value = Number(metric?.value || 0);
const formatted = Number.isInteger(value)
? String(value)
: value.toLocaleString('ru-RU', { maximumFractionDigits: 1 });
return `${formatted}${metric?.unit === '%' ? '%' : metric?.unit ? ` ${metric.unit}` : ''}`;
}
function severityLabel(severity) {
return { CRITICAL: 'Критично', HIGH: 'Высокая', MEDIUM: 'Средняя', LOW: 'Низкая' }[severity] || severity;
}
function semesterLabel(semester) {
const type = semester.semesterType === 'autumn' ? 'Осенний' : 'Весенний';
return `${semester.academicYearTitle} · ${type} · ${formatDate(semester.startDate)}—${formatDate(semester.endDate)}`;
}
function formatDate(value) {
if (!value) return 'дата не указана';
const [year, month, day] = String(value).split('-');
return `${day}.${month}.${year}`;
}
function localIsoDate(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
function problemWord(count) {
const mod100 = count % 100;
const mod10 = count % 10;
if (mod100 >= 11 && mod100 <= 14) return 'проблем';
if (mod10 === 1) return 'проблема';
if (mod10 >= 2 && mod10 <= 4) return 'проблемы';
return 'проблем';
}
export function versionStatusLabel(status) {
return { DRAFT: 'Черновик', PUBLISHED: 'Опубликована', ARCHIVED: 'Архив' }[status] || 'Версия';
}