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 => ` `).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(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.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 = '
…

Сравниваем расписания

Генерируем занятия обеих версий по всему семестру.

'; 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}`; }