407 lines
20 KiB
JavaScript
407 lines
20 KiB
JavaScript
import { api } from '../api.js';
|
||
import {
|
||
academicYearsFromSemesters,
|
||
resolveAcademicPeriod,
|
||
semestersForAcademicYear
|
||
} from '../academic-period.js';
|
||
import { escapeHtml } from '../utils.js';
|
||
import { trackUxEvent } from '../../../telemetry.js';
|
||
|
||
let qualityState = null;
|
||
|
||
export async function initScheduleQuality() {
|
||
const root = document.querySelector('.quality-lab');
|
||
if (!root) return;
|
||
qualityState = {
|
||
root,
|
||
analysis: null,
|
||
semesters: [],
|
||
academicYearId: '',
|
||
versions: [],
|
||
visibleProblems: [],
|
||
selectedProblemId: null,
|
||
applying: false
|
||
};
|
||
root.querySelector('#quality-run')?.addEventListener('click', runAnalysis);
|
||
root.querySelector('#quality-year')?.addEventListener('change', handleYearChange);
|
||
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', handleRulePanelClick);
|
||
await loadSemesters();
|
||
}
|
||
|
||
async function loadSemesters() {
|
||
const select = qualityState?.root.querySelector('#quality-semester');
|
||
if (!select) return;
|
||
try {
|
||
qualityState.semesters = await api.get('/api/schedule/semesters');
|
||
if (!qualityState.semesters?.length) {
|
||
renderPeriodSelects('', '');
|
||
qualityStatus('Сначала создайте учебный год и семестр.', 'warning');
|
||
return;
|
||
}
|
||
const selected = resolveAcademicPeriod(qualityState.semesters, {
|
||
academicYearId: localStorage.getItem('magistr.quality.yearId'),
|
||
semesterId: localStorage.getItem('magistr.quality.semesterId'),
|
||
date: localIsoDate(new Date())
|
||
});
|
||
renderPeriodSelects(selected.academicYearId, selected.semesterId);
|
||
await loadVersions();
|
||
} catch (error) {
|
||
select.innerHTML = '<option value="">Ошибка загрузки</option>';
|
||
qualityStatus(error.message || 'Не удалось загрузить семестры.', 'error');
|
||
}
|
||
}
|
||
|
||
async function handleYearChange() {
|
||
const yearId = qualityState?.root.querySelector('#quality-year')?.value || '';
|
||
const selected = resolveAcademicPeriod(qualityState.semesters, {
|
||
academicYearId: yearId,
|
||
date: localIsoDate(new Date())
|
||
});
|
||
renderPeriodSelects(yearId, selected.semesterId);
|
||
await loadVersions();
|
||
}
|
||
|
||
function renderPeriodSelects(academicYearId, semesterId) {
|
||
const yearSelect = qualityState?.root.querySelector('#quality-year');
|
||
const semesterSelect = qualityState?.root.querySelector('#quality-semester');
|
||
if (!yearSelect || !semesterSelect) return;
|
||
const years = academicYearsFromSemesters(qualityState.semesters);
|
||
qualityState.academicYearId = String(academicYearId || years[0]?.id || '');
|
||
yearSelect.innerHTML = years.length
|
||
? years.map(year => `<option value="${escapeHtml(year.id)}">${escapeHtml(year.title)}</option>`).join('')
|
||
: '<option value="">Учебные годы не настроены</option>';
|
||
yearSelect.value = qualityState.academicYearId;
|
||
|
||
const yearSemesters = semestersForAcademicYear(qualityState.semesters, qualityState.academicYearId);
|
||
semesterSelect.innerHTML = yearSemesters.length
|
||
? yearSemesters.map(semester => `
|
||
<option value="${escapeHtml(String(semester.id))}">${escapeHtml(semesterLabel(semester))}</option>
|
||
`).join('')
|
||
: '<option value="">Семестры не настроены</option>';
|
||
const selectedSemester = yearSemesters.find(semester => String(semester.id) === String(semesterId))
|
||
|| yearSemesters[0];
|
||
semesterSelect.value = String(selectedSemester?.id || '');
|
||
localStorage.setItem('magistr.quality.yearId', qualityState.academicYearId);
|
||
}
|
||
|
||
async function loadVersions() {
|
||
const semesterId = qualityState?.root.querySelector('#quality-semester')?.value;
|
||
const select = qualityState?.root.querySelector('#quality-version');
|
||
if (!select || !semesterId) return;
|
||
select.innerHTML = '<option value="">Загрузка версий…</option>';
|
||
try {
|
||
qualityState.versions = await api.get(
|
||
`/api/edu-office/schedule/versions?semesterId=${encodeURIComponent(semesterId)}`
|
||
);
|
||
select.innerHTML = qualityState.versions.map(version => `
|
||
<option value="${escapeHtml(String(version.id))}">
|
||
v${escapeHtml(String(version.versionNumber))} · ${escapeHtml(version.name)} · ${escapeHtml(versionStatusLabel(version.status))}
|
||
</option>
|
||
`).join('');
|
||
if (!qualityState.versions.length) {
|
||
select.innerHTML = '<option value="">Версии не созданы</option>';
|
||
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.yearId', qualityState.academicYearId);
|
||
localStorage.setItem('magistr.quality.semesterId', semesterId);
|
||
} catch (error) {
|
||
select.innerHTML = '<option value="">Ошибка загрузки</option>';
|
||
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();
|
||
trackUxEvent('качество_расписания_проверено', {
|
||
semesterId,
|
||
versionId,
|
||
score: Number(qualityState.analysis.score || 0)
|
||
});
|
||
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 = `
|
||
<span>${escapeHtml(versionStatusLabel(analysis.scheduleVersionStatus))} · v${escapeHtml(String(analysis.scheduleVersionNumber || '—'))}</span>
|
||
<span>${escapeHtml(String(analysis.lessonCount))} занятий</span>
|
||
<span>правил с проблемами: ${escapeHtml(String(analysis.totalProblemCount))}</span>
|
||
<span>штраф ${escapeHtml(String(analysis.rawPenalty))}</span>
|
||
`;
|
||
qualityState.root.querySelector('#quality-metrics').innerHTML = (analysis.metrics || [])
|
||
.map((metric, index) => `
|
||
<article class="quality-metric quality-metric-${index + 1}">
|
||
<span class="quality-metric-index">${String(index + 1).padStart(2, '0')}</span>
|
||
<strong>${escapeHtml(formatMetricValue(metric))}</strong>
|
||
<h4>${escapeHtml(metric.label)}</h4>
|
||
<p>${escapeHtml(metric.explanation)}</p>
|
||
<span class="quality-metric-penalty">штраф ${escapeHtml(String(metric.penalty))}</span>
|
||
</article>
|
||
`).join('');
|
||
const totalLabel = analysis.problemsTruncated
|
||
? `Показаны первые ${analysis.problems.length} из ${analysis.totalProblemCount} правил`
|
||
: `Правил с проблемами: ${analysis.totalProblemCount}`;
|
||
qualityState.root.querySelector('#quality-problem-total').textContent = totalLabel;
|
||
renderProblems();
|
||
renderRulePanelEmpty();
|
||
}
|
||
|
||
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 = `
|
||
<div class="quality-empty-state">
|
||
<strong>По выбранному фильтру проблем нет</strong>
|
||
<span>Измените уровень важности или категорию.</span>
|
||
</div>
|
||
`;
|
||
return;
|
||
}
|
||
container.innerHTML = qualityState.visibleProblems.map((problem, index) => {
|
||
const selected = problem.id === qualityState.selectedProblemId;
|
||
const affectedLessonCount = Number(problem.affectedLessonCount || 1);
|
||
const issueLabels = problem.issueLabels?.length ? problem.issueLabels : [problem.title];
|
||
const canEdit = qualityState.analysis.scheduleVersionStatus !== 'ARCHIVED' && problem.scheduleRuleId;
|
||
return `
|
||
<article class="quality-problem ${selected ? 'is-selected' : ''}" data-severity="${escapeHtml(problem.severity)}">
|
||
<button type="button" class="quality-problem-main" data-problem-index="${index}">
|
||
<span class="quality-severity">${escapeHtml(severityLabel(problem.severity))}</span>
|
||
<span class="quality-problem-copy">
|
||
<strong>${escapeHtml(problem.title)}</strong>
|
||
<span>${escapeHtml(problem.description)}</span>
|
||
<span class="quality-rule-issues">${issueLabels.map(label => `
|
||
<span>${escapeHtml(label)}</span>
|
||
`).join('')}</span>
|
||
<small>
|
||
Правило #${escapeHtml(String(problem.scheduleRuleId || '—'))}
|
||
· затронуто ${escapeHtml(String(affectedLessonCount))} ${escapeHtml(lessonWord(affectedLessonCount))}
|
||
· суммарный штраф
|
||
</small>
|
||
</span>
|
||
<span class="quality-penalty">−${escapeHtml(String(problem.penalty))}</span>
|
||
</button>
|
||
<div class="quality-problem-action">
|
||
${canEdit
|
||
? `<button type="button" class="btn btn-sm btn-secondary" data-problem-index="${index}" data-action="edit-rule">
|
||
Изменить правило
|
||
</button>`
|
||
: '<span>Только просмотр</span>'}
|
||
</div>
|
||
</article>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
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;
|
||
if (button.dataset.action === 'edit-rule') {
|
||
openRuleInConstructor(problem);
|
||
return;
|
||
}
|
||
qualityState.selectedProblemId = problem.id;
|
||
renderProblems();
|
||
renderRuleDetails(problem);
|
||
}
|
||
|
||
function renderRulePanelEmpty() {
|
||
const archived = qualityState?.analysis?.scheduleVersionStatus === 'ARCHIVED';
|
||
renderRulePanelMessage(
|
||
archived ? 'Архивная версия' : 'Работа с правилом',
|
||
archived
|
||
? 'Правила этой версии доступны только для просмотра. Для правок создайте новый черновик.'
|
||
: 'Выберите правило, чтобы увидеть его суммарное влияние и открыть в конструкторе.'
|
||
);
|
||
}
|
||
|
||
function renderRuleDetails(problem) {
|
||
const panel = qualityState?.root.querySelector('#quality-recommendations');
|
||
if (!panel) return;
|
||
const affectedLessonCount = Number(problem.affectedLessonCount || 1);
|
||
const issueLabels = problem.issueLabels?.length ? problem.issueLabels : [problem.title];
|
||
const canEdit = qualityState.analysis.scheduleVersionStatus !== 'ARCHIVED' && problem.scheduleRuleId;
|
||
panel.innerHTML = `
|
||
<div class="quality-recommendations-head">
|
||
<span class="quality-kicker">Правило #${escapeHtml(String(problem.scheduleRuleId || '—'))}</span>
|
||
<h3>${escapeHtml(problem.subjectName || problem.title)}</h3>
|
||
<p>Затронуто ${escapeHtml(String(affectedLessonCount))} ${escapeHtml(lessonWord(affectedLessonCount))}; суммарный штраф −${escapeHtml(String(problem.penalty))}.</p>
|
||
</div>
|
||
<div class="quality-rule-detail-list">
|
||
${issueLabels.map(label => `<span>${escapeHtml(label)}</span>`).join('')}
|
||
</div>
|
||
${canEdit ? `
|
||
<button type="button" class="btn btn-md btn-primary quality-edit-rule" data-edit-rule-id="${escapeHtml(String(problem.scheduleRuleId))}">
|
||
Открыть правило в конструкторе
|
||
</button>
|
||
` : '<p class="quality-recommendations-note">Архивное расписание нельзя изменить.</p>'}
|
||
`;
|
||
}
|
||
|
||
function handleRulePanelClick(event) {
|
||
const button = event.target.closest('[data-edit-rule-id]');
|
||
if (!button) return;
|
||
const problem = (qualityState?.analysis?.problems || [])
|
||
.find(item => String(item.scheduleRuleId) === button.dataset.editRuleId);
|
||
if (problem) openRuleInConstructor(problem);
|
||
}
|
||
|
||
function openRuleInConstructor(problem) {
|
||
if (!problem?.scheduleRuleId || qualityState?.analysis?.scheduleVersionStatus === 'ARCHIVED') return;
|
||
localStorage.setItem('magistr.schedule.semesterId', String(qualityState.analysis.semesterId));
|
||
localStorage.setItem('magistr.schedule.openVersionId', String(qualityState.analysis.scheduleVersionId));
|
||
localStorage.setItem('magistr.schedule.openRuleId', String(problem.scheduleRuleId));
|
||
document.querySelector('.nav-item[data-tab="schedule"]')?.click();
|
||
}
|
||
|
||
function renderRulePanelMessage(title, message) {
|
||
const panel = qualityState?.root.querySelector('#quality-recommendations');
|
||
if (!panel) return;
|
||
panel.innerHTML = `
|
||
<div class="quality-recommendations-empty">
|
||
<span class="quality-recommendations-mark">↗</span>
|
||
<h3>${escapeHtml(title)}</h3>
|
||
<p>${escapeHtml(message)}</p>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
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 problemTypes = problem.problemTypes?.length ? problem.problemTypes : [problem.type];
|
||
const categoryMatches = !category || problemTypes.some(type => problemCategory(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 lessonWord(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] || 'Версия';
|
||
}
|