задачи 2 и 8

This commit is contained in:
Zuev
2026-08-11 18:18:08 +03:00
parent 519864b962
commit 18a97b293a
59 changed files with 6237 additions and 276 deletions

View File

@@ -101,6 +101,22 @@ const ROUTES = {
await module.initSchedule();
}
},
'schedule-versions': {
title: 'Версии расписания',
file: '/admin/views/schedule-versions.html',
init: async () => {
const module = await import('./views/schedule-versions.js');
await module.initScheduleVersions();
}
},
'schedule-quality': {
title: 'Анализ качества расписания',
file: '/admin/views/schedule-quality.html',
init: async () => {
const module = await import('./views/schedule-quality.js');
await module.initScheduleQuality();
}
},
'academic-calendar': {
title: 'Календарный учебный график',
file: '/admin/views/academic-calendar.html',

View File

@@ -1,13 +1,13 @@
const capabilities = {
ADMIN: {
defaultAdminTab: 'dashboard',
adminTabs: ['dashboard', 'teacher-requests', 'schedule', 'academic-calendar', 'auditorium-workload', 'schedule-view', 'teacher-absences', 'classrooms', 'groups', 'university-structure', 'subjects', 'users'],
adminTabs: ['dashboard', 'teacher-requests', 'schedule', 'schedule-versions', 'schedule-quality', 'academic-calendar', 'auditorium-workload', 'schedule-view', 'teacher-absences', 'classrooms', 'groups', 'university-structure', 'subjects', 'users'],
defaultSettingsTab: 'general',
settingsTabs: ['general', 'time-slots', 'database', 'edu-forms']
},
EDUCATION_OFFICE: {
defaultAdminTab: 'dashboard',
adminTabs: ['dashboard', 'schedule', 'academic-calendar', 'auditorium-workload', 'schedule-view', 'teacher-absences', 'classrooms'],
adminTabs: ['dashboard', 'schedule', 'schedule-versions', 'schedule-quality', 'academic-calendar', 'auditorium-workload', 'schedule-view', 'teacher-absences', 'classrooms'],
defaultSettingsTab: 'time-slots',
settingsTabs: ['time-slots', 'edu-forms']
},

View File

@@ -0,0 +1,462 @@
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 => `
<option value="${escapeHtml(String(semester.id))}">
${escapeHtml(semesterLabel(semester))}
</option>
`).join('');
if (!semesters?.length) {
select.innerHTML = '<option value="">Семестры не настроены</option>';
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 = '<option value="">Ошибка загрузки</option>';
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 = '<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.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();
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} ${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 = `
<div class="quality-empty-state">
<strong>По выбранному фильтру проблем нет</strong>
<span>Измените уровень важности или категорию.</span>
</div>
`;
return;
}
container.innerHTML = qualityState.visibleProblems.map((problem, index) => {
const selected = problem.id === qualityState.selectedProblemId;
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>
<small>
${escapeHtml(formatDate(problem.lessonDate))}
· ${escapeHtml(problem.subjectName || 'Занятие')}
${problem.timeSlotOrder ? ` · ${escapeHtml(String(problem.timeSlotOrder))} пара` : ''}
</small>
</span>
<span class="quality-penalty">−${escapeHtml(String(problem.penalty))}</span>
</button>
<div class="quality-problem-action">
${problem.optimizable && qualityState.analysis.scheduleVersionStatus === 'PUBLISHED'
? `<button type="button" class="btn btn-sm btn-secondary" data-problem-index="${index}">
Подобрать улучшения
</button>`
: `<span>${escapeHtml(problemActionLabel(problem))}</span>`}
</div>
</article>
`;
}).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 = `
<div class="quality-recommendations-loading">
<span class="quality-loader"></span>
<strong>Проверяем варианты</strong>
<p>Каждый кандидат проходит полную проверку конфликтов.</p>
</div>
`;
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 = `
<div class="quality-recommendations-head">
<span class="quality-kicker">Проверенные варианты</span>
<h3>${escapeHtml(problem.title)}</h3>
<p>${escapeHtml(formatDate(problem.lessonDate))} · ${escapeHtml(result.subjectName || 'Занятие')}</p>
</div>
<div class="quality-candidates">
${candidates.map((candidate, index) => recommendationCard(candidate, index)).join('')}
</div>
<p class="quality-recommendations-note">
Применение создаёт обычное точечное изменение и не переписывает правило семестра.
</p>
`;
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 `
<article class="quality-candidate ${impactClass}">
<div class="quality-candidate-top">
<span>${candidate.action === 'MOVE' ? 'Новое время' : 'Новая аудитория'}</span>
<strong>${escapeHtml(candidate.label)}</strong>
</div>
<p>${escapeHtml(candidate.description)}</p>
<div class="quality-candidate-impact">
<span>${escapeHtml(impact)}</span>
<span>результат ${escapeHtml(String(candidate.resultingScore))}/100</span>
</div>
${changeList('Улучшится', candidate.improvements, 'positive')}
${changeList('Компромиссы', candidate.tradeoffs, 'negative')}
<button type="button" class="btn btn-md btn-primary quality-apply" data-candidate-index="${index}">
Применить после подтверждения
</button>
</article>
`;
}
function changeList(label, items, tone) {
if (!items?.length) return '';
return `
<div class="quality-change-list is-${tone}">
<strong>${escapeHtml(label)}</strong>
${items.map(item => `<span>${escapeHtml(item)}</span>`).join('')}
</div>
`;
}
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 = `
<div class="quality-recommendations-empty">
<span class="quality-recommendations-mark">↗</span>
<h3>${escapeHtml(title)}</h3>
<p>${escapeHtml(message)}</p>
</div>
`;
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] || 'Версия';
}

View File

@@ -0,0 +1,423 @@
import { api } from '../api.js';
import { escapeHtml } from '../utils.js';
let versionState = null;
export async function initScheduleVersions() {
const root = document.querySelector('.version-studio');
if (!root) return;
versionState = {
root,
semesters: [],
versions: [],
history: [],
modal: null,
loading: false
};
bindEvents();
await loadSemesters();
}
function bindEvents() {
const root = versionState.root;
root.querySelector('#version-semester')?.addEventListener('change', loadWorkspace);
root.querySelector('#version-create')?.addEventListener('click', () => openCreateModal());
root.querySelector('#version-drafts')?.addEventListener('click', handleVersionAction);
root.querySelector('#version-archive')?.addEventListener('click', handleVersionAction);
root.querySelector('#version-current')?.addEventListener('click', handleVersionAction);
root.querySelector('#version-modal-close')?.addEventListener('click', closeModal);
root.querySelector('#version-modal-cancel')?.addEventListener('click', closeModal);
root.querySelector('#version-modal')?.addEventListener('click', event => {
if (event.target.id === 'version-modal') closeModal();
});
root.querySelector('#version-modal-form')?.addEventListener('submit', submitModal);
document.addEventListener('keydown', event => {
if (event.key === 'Escape' && !root.querySelector('#version-modal')?.hidden) closeModal();
});
}
async function loadSemesters() {
const select = versionState.root.querySelector('#version-semester');
try {
versionState.semesters = await api.get('/api/schedule/semesters');
select.innerHTML = versionState.semesters.map(semester => `
<option value="${escapeHtml(String(semester.id))}">${escapeHtml(semesterLabel(semester))}</option>
`).join('');
if (!versionState.semesters.length) {
select.innerHTML = '<option value="">Семестры не настроены</option>';
setStatus('Сначала создайте учебный год и семестр.', 'error');
return;
}
const savedSemester = localStorage.getItem('magistr.schedule.semesterId');
const today = localIsoDate(new Date());
const current = versionState.semesters.find(item => item.startDate <= today && item.endDate >= today);
const selected = versionState.semesters.find(item => String(item.id) === savedSemester)
|| current
|| versionState.semesters[0];
select.value = String(selected.id);
await loadWorkspace();
} catch (error) {
setStatus(error.message || 'Не удалось загрузить семестры.', 'error');
}
}
async function loadWorkspace() {
const semesterId = selectedSemesterId();
if (!semesterId || versionState.loading) return;
versionState.loading = true;
localStorage.setItem('magistr.schedule.semesterId', semesterId);
setStatus('Собираем версии, правила и журнал публикаций…', 'loading');
try {
[versionState.versions, versionState.history] = await Promise.all([
api.get(`/api/edu-office/schedule/versions?semesterId=${encodeURIComponent(semesterId)}`),
api.get(`/api/edu-office/schedule/versions/history?semesterId=${encodeURIComponent(semesterId)}`)
]);
renderWorkspace();
setStatus('Контур версий синхронизирован.', 'success');
} catch (error) {
setStatus(error.message || 'Не удалось загрузить версии расписания.', 'error');
} finally {
versionState.loading = false;
}
}
function renderWorkspace() {
const published = versionState.versions.find(version => version.status === 'PUBLISHED');
const drafts = versionState.versions.filter(version => version.status === 'DRAFT');
const archive = versionState.versions.filter(version => version.status === 'ARCHIVED');
renderCurrent(published);
renderDrafts(drafts);
renderArchive(archive);
renderHistory(versionState.history);
}
function renderCurrent(version) {
const container = versionState.root.querySelector('#version-current');
if (!version) {
container.innerHTML = `
<div class="version-live-mark">ПУСТО</div>
<div><h3>Опубликованной версии нет</h3><p>Создайте первый черновик и опубликуйте его после проверки.</p></div>
`;
return;
}
container.innerHTML = `
<div class="version-live-mark">LIVE</div>
<div>
<span class="version-eyebrow">Сейчас видят пользователи</span>
<h3>${escapeHtml(version.name)} · версия ${escapeHtml(String(version.versionNumber))}</h3>
<p>${escapeHtml(version.changeReason || 'Причина первой публикации не указана')}</p>
<div class="version-stats">
<span>${escapeHtml(String(version.ruleCount))} правил</span>
<span>${escapeHtml(String(version.slotCount))} слотов</span>
<span>${escapeHtml(formatDateTime(version.publishedAt))}</span>
</div>
</div>
<div class="version-card-actions">
<button type="button" class="btn btn-sm btn-secondary" data-action="quality" data-id="${version.id}">Проверить качество</button>
</div>
`;
}
function renderDrafts(drafts) {
const container = versionState.root.querySelector('#version-drafts');
versionState.root.querySelector('#version-draft-count').textContent = String(drafts.length);
if (!drafts.length) {
container.innerHTML = `
<div class="version-empty">
<span>+</span><h3>Нет активных черновиков</h3>
<p>Создайте копию опубликованной версии — её изменения останутся невидимыми до публикации.</p>
</div>
`;
return;
}
container.innerHTML = drafts.map(version => `
<article class="version-card" data-number="${escapeHtml(String(version.versionNumber).padStart(2, '0'))}">
<div class="version-card-top">
<div>
<span class="version-status-chip">Черновик · v${escapeHtml(String(version.versionNumber))}</span>
<h4>${escapeHtml(version.name)}</h4>
</div>
</div>
<p>Основа: ${version.basedOnVersionId ? `версия #${escapeHtml(String(version.basedOnVersionId))}` : 'пустое расписание'}</p>
<small>${escapeHtml(String(version.ruleCount))} правил · ${escapeHtml(String(version.slotCount))} слотов · ${escapeHtml(formatDateTime(version.createdAt))}</small>
<div class="version-card-actions">
<button type="button" class="btn btn-sm btn-primary" data-action="edit" data-id="${version.id}">Редактировать</button>
<button type="button" class="btn btn-sm btn-secondary" data-action="diff" data-id="${version.id}">Сравнить</button>
<button type="button" class="btn btn-sm btn-secondary" data-action="quality" data-id="${version.id}">Качество</button>
<button type="button" class="btn btn-sm btn-secondary" data-action="publish" data-id="${version.id}">Опубликовать</button>
</div>
</article>
`).join('');
}
function renderArchive(versions) {
const container = versionState.root.querySelector('#version-archive');
if (!versions.length) {
container.innerHTML = '<p class="version-empty-note">Предыдущих публикаций пока нет.</p>';
return;
}
container.innerHTML = versions.map(version => `
<article class="version-card" data-number="${escapeHtml(String(version.versionNumber).padStart(2, '0'))}">
<span class="version-status-chip">Архив · v${escapeHtml(String(version.versionNumber))}</span>
<h4>${escapeHtml(version.name)}</h4>
<p>${escapeHtml(version.changeReason || 'Причина не указана')}</p>
<small>${escapeHtml(String(version.ruleCount))} правил · публикация ${escapeHtml(formatDateTime(version.publishedAt))}</small>
<div class="version-card-actions">
<button type="button" class="btn btn-sm btn-secondary" data-action="diff" data-id="${version.id}">Сравнить</button>
<button type="button" class="btn btn-sm btn-secondary" data-action="quality" data-id="${version.id}">Качество</button>
<button type="button" class="btn btn-sm btn-danger" data-action="restore" data-id="${version.id}">Восстановить</button>
</div>
</article>
`).join('');
}
function renderHistory(history) {
const container = versionState.root.querySelector('#version-history');
if (!history.length) {
container.innerHTML = '<p class="version-empty-note">Действия ещё не зафиксированы.</p>';
return;
}
container.innerHTML = history.map(item => `
<article class="version-history-row">
<time>${escapeHtml(formatDateTime(item.createdAt))}</time>
<div>
<strong>${escapeHtml(historyActionLabel(item.action))}: v${escapeHtml(String(item.versionNumber))} · ${escapeHtml(item.versionName)}</strong>
<p>${escapeHtml(item.reason || 'Без комментария')}</p>
</div>
<span class="version-status-chip">${escapeHtml(item.actorName || 'Система')}</span>
</article>
`).join('');
}
async function handleVersionAction(event) {
const button = event.target.closest('[data-action][data-id]');
if (!button) return;
const version = versionState.versions.find(item => String(item.id) === button.dataset.id);
if (!version) return;
const action = button.dataset.action;
if (action === 'edit') {
localStorage.setItem('magistr.schedule.versionId', String(version.id));
navigateTo('schedule');
return;
}
if (action === 'quality') {
localStorage.setItem('magistr.quality.semesterId', String(version.semesterId));
localStorage.setItem('magistr.quality.versionId', String(version.id));
navigateTo('schedule-quality');
return;
}
if (action === 'diff') {
await loadDiff(version);
return;
}
if (action === 'publish') {
await openPublishModal(version);
return;
}
if (action === 'restore') {
openActionModal('restore', version);
}
}
async function loadDiff(version) {
const panel = versionState.root.querySelector('#version-diff');
panel.innerHTML = '<div class="version-empty"><span>…</span><h3>Сравниваем расписания</h3><p>Генерируем занятия обеих версий по всему семестру.</p></div>';
try {
const diff = await api.get(`/api/edu-office/schedule/versions/${version.id}/diff`);
renderDiff(diff);
} catch (error) {
panel.innerHTML = `<div class="version-empty"><span>!</span><h3>Сравнение недоступно</h3><p>${escapeHtml(error.message)}</p></div>`;
}
}
function renderDiff(diff) {
const panel = versionState.root.querySelector('#version-diff');
panel.innerHTML = `
<span class="version-eyebrow">${escapeHtml(diff.baseVersionLabel)} → ${escapeHtml(diff.targetVersionLabel)}</span>
<h3>Что изменится после публикации</h3>
<div class="version-diff-score">
<div><strong>+${escapeHtml(String(diff.addedLessonCount))}</strong><span>занятий</span></div>
<div><strong>${escapeHtml(String(diff.changedLessonCount))}</strong><span>изменено</span></div>
<div><strong>−${escapeHtml(String(diff.removedLessonCount))}</strong><span>удалено</span></div>
</div>
<p class="version-diff-caption">Правила: +${escapeHtml(String(diff.addedRuleCount))} · ~${escapeHtml(String(diff.changedRuleCount))} · −${escapeHtml(String(diff.removedRuleCount))} · без изменений ${escapeHtml(String(diff.unchangedRuleCount))}</p>
<div class="version-change-list">
${(diff.ruleChanges || []).length
? diff.ruleChanges.map(ruleChangeCard).join('')
: '<div class="version-empty"><h3>Правила совпадают</h3><p>Структурных изменений между версиями нет.</p></div>'}
</div>
`;
}
function ruleChangeCard(change) {
const label = { ADDED: 'Добавлено', REMOVED: 'Удалено', CHANGED: 'Изменено' }[change.type] || change.type;
return `
<article class="version-change" data-type="${escapeHtml(change.type)}">
<strong>${escapeHtml(label)} · ${escapeHtml(change.subjectName)}</strong>
<span>${escapeHtml((change.groupNames || []).join(', ') || 'Группы не указаны')}</span>
${change.beforeSummary ? `<span>Было: ${escapeHtml(change.beforeSummary)}</span>` : ''}
${change.afterSummary ? `<span>Стало: ${escapeHtml(change.afterSummary)}</span>` : ''}
</article>
`;
}
function openCreateModal() {
const published = versionState.versions.find(version => version.status === 'PUBLISHED');
openModal({
mode: 'create',
version: published,
kicker: 'Изолированная рабочая копия',
title: 'Создать новый черновик',
copy: published
? `Правила версии ${published.versionNumber} будут скопированы. Пользователи продолжат видеть текущую публикацию.`
: 'Будет создан пустой черновик для первого расписания семестра.',
label: 'Название черновика',
value: '',
submit: 'Создать черновик'
});
}
async function openPublishModal(version) {
setStatus('Проверяем целостность черновика перед публикацией…', 'loading');
try {
const [validation, diff] = await Promise.all([
api.get(`/api/edu-office/schedule/versions/${version.id}/validate`),
api.get(`/api/edu-office/schedule/versions/${version.id}/diff`)
]);
const summary = validation.valid
? `Проверка пройдена: ${validation.ruleCount} правил, ${validation.slotCount} слотов. Занятия: +${diff.addedLessonCount}, ~${diff.changedLessonCount}, −${diff.removedLessonCount}.`
: `Публикация заблокирована: ${(validation.issues || []).join('; ')}`;
openModal({
mode: 'publish',
version,
valid: validation.valid,
kicker: 'Атомарная публикация',
title: `Опубликовать версию ${version.versionNumber}`,
copy: 'После подтверждения текущая версия целиком уйдёт в архив, а этот черновик станет виден студентам и преподавателям.',
label: 'Причина изменений',
value: '',
submit: 'Опубликовать',
summary
});
setStatus(validation.valid ? 'Черновик готов к публикации.' : 'Черновик требует исправлений.', validation.valid ? 'success' : 'error');
} catch (error) {
setStatus(error.message || 'Не удалось проверить черновик.', 'error');
}
}
function openActionModal(mode, version) {
openModal({
mode,
version,
kicker: 'Контролируемый возврат',
title: `Восстановить версию ${version.versionNumber}`,
copy: 'Текущая публикация будет архивирована, а выбранная версия снова станет активной одной транзакцией.',
label: 'Причина восстановления',
value: '',
submit: 'Восстановить'
});
}
function openModal(config) {
versionState.modal = config;
const root = versionState.root;
root.querySelector('#version-modal-kicker').textContent = config.kicker;
root.querySelector('#version-modal-title').textContent = config.title;
root.querySelector('#version-modal-copy').textContent = config.copy;
root.querySelector('#version-modal-label').textContent = config.label;
root.querySelector('#version-modal-value').value = config.value || '';
const summary = root.querySelector('#version-modal-summary');
summary.hidden = !config.summary;
summary.textContent = config.summary || '';
const submit = root.querySelector('#version-modal-submit');
submit.textContent = config.submit;
submit.disabled = config.valid === false;
root.querySelector('#version-modal').hidden = false;
root.querySelector('#version-modal-value').focus();
}
function closeModal() {
const modal = versionState?.root.querySelector('#version-modal');
if (modal) modal.hidden = true;
if (versionState) versionState.modal = null;
}
async function submitModal(event) {
event.preventDefault();
const config = versionState.modal;
if (!config || config.valid === false) return;
const value = versionState.root.querySelector('#version-modal-value').value.trim();
if (!value) {
setStatus(config.mode === 'create' ? 'Укажите название черновика.' : 'Укажите причину операции.', 'error');
return;
}
const submit = versionState.root.querySelector('#version-modal-submit');
submit.disabled = true;
submit.textContent = 'Выполняем…';
try {
if (config.mode === 'create') {
const created = await api.post('/api/edu-office/schedule/versions', {
semesterId: Number(selectedSemesterId()),
name: value,
basedOnVersionId: config.version?.id || null
});
localStorage.setItem('magistr.schedule.versionId', String(created.id));
setStatus(`Черновик «${created.name}» создан.`, 'success');
} else {
await api.post(
`/api/edu-office/schedule/versions/${config.version.id}/${config.mode}`,
{ reason: value }
);
setStatus(config.mode === 'publish' ? 'Версия опубликована атомарно.' : 'Предыдущая версия восстановлена.', 'success');
}
closeModal();
await loadWorkspace();
} catch (error) {
setStatus(error.message || 'Операцию не удалось выполнить.', 'error');
submit.disabled = false;
submit.textContent = config.submit;
}
}
function navigateTo(tab) {
document.querySelector(`.nav-item[data-tab="${tab}"]`)?.click();
}
function selectedSemesterId() {
return versionState?.root.querySelector('#version-semester')?.value || '';
}
function setStatus(message, tone = '') {
const status = versionState?.root.querySelector('#version-status');
if (!status) return;
status.textContent = message;
status.dataset.tone = tone;
}
export function historyActionLabel(action) {
return {
CREATED: 'Создан черновик',
PUBLISHED: 'Опубликовано',
ARCHIVED: 'Архивировано',
RESTORED: 'Восстановлено'
}[action] || action;
}
function semesterLabel(semester) {
const type = semester.semesterType === 'autumn' ? 'Осенний' : 'Весенний';
return `${semester.academicYearTitle} · ${type} семестр`;
}
function formatDateTime(value) {
if (!value) return 'дата не указана';
return new Intl.DateTimeFormat('ru-RU', {
day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit'
}).format(new Date(value));
}
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}`;
}

View File

@@ -67,6 +67,9 @@ export async function initSchedule() {
const ruleIdInput = document.getElementById('schedule-rule-id');
const ruleSubjectSelect = document.getElementById('schedule-rule-subject');
const ruleSemesterSelect = document.getElementById('schedule-rule-semester');
const scheduleVersionSelect = document.getElementById('schedule-version-select');
const scheduleVersionNote = document.getElementById('schedule-version-note');
const scheduleVersionManage = document.getElementById('schedule-version-manage');
const ruleTypeInputs = Object.fromEntries(LESSON_TYPE_LIMITS.map(config => [config.key, {
hours: document.getElementById(config.hoursInputId),
startWeek: document.getElementById(config.startWeekInputId)
@@ -125,6 +128,7 @@ export async function initSchedule() {
let academicYears = [];
let semesters = [];
let approvedTeacherPreferences = [];
let scheduleVersions = [];
let selectedVisualYearId = '';
let selectedVisualSemesterId = '';
let selectedVisualGroupIds = new Set();
@@ -139,8 +143,11 @@ export async function initSchedule() {
try {
await Promise.all([loadBaseLists(), loadTimeSlots(), loadYears(), loadApprovedTeacherPreferences()]);
await loadScheduleVersions();
renderRuleGroups([]);
renderRuleSlots([{}]);
syncRuleSemesterWithVersion();
setRuleEditorEnabled(Boolean(selectedScheduleVersion()));
await loadRules();
} catch (error) {
showAlert('schedule-rule-alert', error.message || 'Ошибка загрузки данных расписания', 'error');
@@ -148,6 +155,17 @@ export async function initSchedule() {
function bindEvents() {
refreshRulesButton?.addEventListener('click', loadRules);
scheduleVersionSelect?.addEventListener('change', async () => {
if (scheduleVersionSelect.value) {
localStorage.setItem('magistr.schedule.versionId', scheduleVersionSelect.value);
}
resetRuleForm();
syncRuleSemesterWithVersion();
await loadRules();
});
scheduleVersionManage?.addEventListener('click', () => {
document.querySelector('.nav-item[data-tab="schedule-versions"]')?.click();
});
ruleResetButton?.addEventListener('click', resetRuleForm);
slotAddButton?.addEventListener('click', () => renderRuleSlots([...readRuleSlots(false), {}]));
visualToggleButton?.addEventListener('click', toggleVisualDrawer);
@@ -284,10 +302,62 @@ export async function initSchedule() {
updatePreferenceHints();
}
async function loadScheduleVersions() {
const responses = await Promise.all(semesters.map(semester =>
api.get(`/api/edu-office/schedule/versions?semesterId=${encodeURIComponent(semester.id)}`)
));
scheduleVersions = responses.flat().filter(version => version.status === 'DRAFT');
const storedId = localStorage.getItem('magistr.schedule.versionId');
const stored = scheduleVersions.find(version => String(version.id) === storedId);
const selected = stored || scheduleVersions[0] || null;
scheduleVersionSelect.innerHTML = scheduleVersions.length
? scheduleVersions.map(version => `
<option value="${version.id}">
${escapeHtml(version.semesterLabel)} · v${escapeHtml(String(version.versionNumber))} · ${escapeHtml(version.name)}
</option>
`).join('')
: '<option value="">Черновики не созданы</option>';
scheduleVersionSelect.value = selected ? String(selected.id) : '';
if (selected) {
localStorage.setItem('magistr.schedule.versionId', String(selected.id));
scheduleVersionNote.textContent = `Версия ${selected.versionNumber} · ${selected.name}. Изменения не видны пользователям до публикации.`;
} else {
scheduleVersionNote.textContent = 'Черновика нет. Создайте его в разделе «Версии расписания».';
}
setRuleEditorEnabled(Boolean(selected));
}
function selectedScheduleVersion() {
return scheduleVersions.find(version => String(version.id) === String(scheduleVersionSelect?.value)) || null;
}
function syncRuleSemesterWithVersion() {
const version = selectedScheduleVersion();
if (!version) return;
ruleSemesterSelect.value = String(version.semesterId);
ruleSemesterSelect.disabled = true;
syncSelects(ruleSemesterSelect);
}
function setRuleEditorEnabled(enabled) {
document.getElementById('schedule-rule-editor')?.classList.toggle('is-disabled', !enabled);
ruleForm.querySelectorAll('input, select, button').forEach(control => {
control.disabled = !enabled;
});
if (enabled) ruleSemesterSelect.disabled = true;
}
async function loadRules() {
rulesTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Загрузка...</td></tr>';
const version = selectedScheduleVersion();
if (!version) {
rules = [];
rulesTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Создайте черновик в разделе «Версии расписания»</td></tr>';
setVisualState('Нет выбранного черновика');
return;
}
try {
rules = await api.get('/api/admin/schedule-rules');
rules = await api.get(`/api/admin/schedule-rules?versionId=${encodeURIComponent(version.id)}`);
if (!visualPeriodTouched) {
selectDefaultVisualPeriod();
}
@@ -1244,6 +1314,7 @@ export async function initSchedule() {
id: Number(rule.id),
subjectId: Number(rule.subjectId),
semesterId: Number(rule.semesterId),
scheduleVersionId: Number(rule.scheduleVersionId || scheduleVersionSelect.value),
lectureAcademicHours: Number(rule.lectureAcademicHours || 0),
laboratoryAcademicHours: Number(rule.laboratoryAcademicHours || 0),
practiceAcademicHours: Number(rule.practiceAcademicHours || 0),
@@ -1319,6 +1390,7 @@ export async function initSchedule() {
const groupIds = selectedRuleGroupIds();
const slots = readRuleSlots(true);
if (!ruleSubjectSelect.value) throw new Error('Выберите дисциплину');
if (!scheduleVersionSelect.value) throw new Error('Выберите черновик расписания');
if (!ruleSemesterSelect.value) throw new Error('Выберите семестр');
const typeLimits = readTypeLimits();
validateTypeLimits(typeLimits, slots);
@@ -1328,6 +1400,7 @@ export async function initSchedule() {
id: ruleIdInput.value ? Number(ruleIdInput.value) : null,
subjectId: Number(ruleSubjectSelect.value),
semesterId: Number(ruleSemesterSelect.value),
scheduleVersionId: Number(scheduleVersionSelect.value),
...typeLimits,
groupIds,
slots
@@ -1343,6 +1416,7 @@ export async function initSchedule() {
syncSelects(ruleSubjectSelect, ruleSemesterSelect);
renderRuleGroups(rule.groupIds || []);
renderRuleSlots(rule.slots && rule.slots.length ? rule.slots : [{}]);
syncRuleSemesterWithVersion();
hideAlert('schedule-rule-alert');
ruleForm.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
@@ -1354,6 +1428,7 @@ export async function initSchedule() {
fillTypeLimits({});
renderRuleGroups([]);
renderRuleSlots([{}]);
syncRuleSemesterWithVersion();
syncSelects(ruleSubjectSelect, ruleSemesterSelect);
hideAlert('schedule-rule-alert');
}