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 => `
${escapeHtml(semesterLabel(semester))}
`).join('');
if (!versionState.semesters.length) {
select.innerHTML = 'Семестры не настроены ';
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 = `
ПУСТО
Опубликованной версии нет Создайте первый черновик и опубликуйте его после проверки.
`;
return;
}
container.innerHTML = `
LIVE
Сейчас видят пользователи
${escapeHtml(version.name)} · версия ${escapeHtml(String(version.versionNumber))}
${escapeHtml(version.changeReason || 'Причина первой публикации не указана')}
${escapeHtml(String(version.ruleCount))} правил
${escapeHtml(String(version.slotCount))} слотов
${escapeHtml(formatDateTime(version.publishedAt))}
Редактировать
Проверить качество
`;
}
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 = `
+ Нет активных черновиков
Создайте копию опубликованной версии — её изменения останутся невидимыми до публикации.
`;
return;
}
container.innerHTML = drafts.map(version => `
Черновик · v${escapeHtml(String(version.versionNumber))}
${escapeHtml(version.name)}
Основа: ${version.basedOnVersionId ? `версия #${escapeHtml(String(version.basedOnVersionId))}` : 'пустое расписание'}
${escapeHtml(String(version.ruleCount))} правил · ${escapeHtml(String(version.slotCount))} слотов · ${escapeHtml(formatDateTime(version.createdAt))}
Редактировать
Сравнить
Качество
Опубликовать
`).join('');
}
function renderArchive(versions) {
const container = versionState.root.querySelector('#version-archive');
if (!versions.length) {
container.innerHTML = 'Предыдущих публикаций пока нет.
';
return;
}
container.innerHTML = versions.map(version => `
Архив · v${escapeHtml(String(version.versionNumber))}
${escapeHtml(version.name)}
${escapeHtml(version.changeReason || 'Причина не указана')}
${escapeHtml(String(version.ruleCount))} правил · публикация ${escapeHtml(formatDateTime(version.publishedAt))}
Сравнить
Качество
Восстановить
`).join('');
}
function renderHistory(history) {
const container = versionState.root.querySelector('#version-history');
if (!history.length) {
container.innerHTML = 'Действия ещё не зафиксированы.
';
return;
}
container.innerHTML = history.map(item => `
${escapeHtml(formatDateTime(item.createdAt))}
${escapeHtml(historyActionLabel(item.action))}: v${escapeHtml(String(item.versionNumber))} · ${escapeHtml(item.versionName)}
${escapeHtml(item.reason || 'Без комментария')}
${escapeHtml(item.actorName || 'Система')}
`).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.openVersionId', 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 = '… Сравниваем расписания Генерируем занятия обеих версий по всему семестру.
';
try {
const diff = await api.get(`/api/edu-office/schedule/versions/${version.id}/diff`);
renderDiff(diff);
} catch (error) {
panel.innerHTML = `! Сравнение недоступно ${escapeHtml(error.message)}
`;
}
}
function renderDiff(diff) {
const panel = versionState.root.querySelector('#version-diff');
panel.innerHTML = `
${escapeHtml(diff.baseVersionLabel)} → ${escapeHtml(diff.targetVersionLabel)}
Что изменится после публикации
+${escapeHtml(String(diff.addedLessonCount))} занятий
${escapeHtml(String(diff.changedLessonCount))} изменено
−${escapeHtml(String(diff.removedLessonCount))} удалено
Правила: +${escapeHtml(String(diff.addedRuleCount))} · ~${escapeHtml(String(diff.changedRuleCount))} · −${escapeHtml(String(diff.removedRuleCount))} · без изменений ${escapeHtml(String(diff.unchangedRuleCount))}
${(diff.ruleChanges || []).length
? diff.ruleChanges.map(ruleChangeCard).join('')
: '
Правила совпадают Структурных изменений между версиями нет.
'}
`;
}
function ruleChangeCard(change) {
const label = { ADDED: 'Добавлено', REMOVED: 'Удалено', CHANGED: 'Изменено' }[change.type] || change.type;
return `
${escapeHtml(label)} · ${escapeHtml(change.subjectName)}
${escapeHtml((change.groupNames || []).join(', ') || 'Группы не указаны')}
${change.beforeSummary ? `Было: ${escapeHtml(change.beforeSummary)} ` : ''}
${change.afterSummary ? `Стало: ${escapeHtml(change.afterSummary)} ` : ''}
`;
}
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}`;
}