463 lines
22 KiB
JavaScript
463 lines
22 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 versionState = null;
|
||
|
||
export async function initScheduleVersions() {
|
||
const root = document.querySelector('.version-studio');
|
||
if (!root) return;
|
||
versionState = {
|
||
root,
|
||
semesters: [],
|
||
academicYearId: '',
|
||
versions: [],
|
||
history: [],
|
||
modal: null,
|
||
loading: false
|
||
};
|
||
bindEvents();
|
||
await loadSemesters();
|
||
}
|
||
|
||
function bindEvents() {
|
||
const root = versionState.root;
|
||
root.querySelector('#version-year')?.addEventListener('change', handleYearChange);
|
||
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() {
|
||
try {
|
||
versionState.semesters = await api.get('/api/schedule/semesters');
|
||
if (!versionState.semesters.length) {
|
||
renderPeriodSelects('', '');
|
||
setStatus('Сначала создайте учебный год и семестр.', 'error');
|
||
return;
|
||
}
|
||
const selected = resolveAcademicPeriod(versionState.semesters, {
|
||
academicYearId: localStorage.getItem('magistr.schedule.yearId'),
|
||
semesterId: localStorage.getItem('magistr.schedule.semesterId'),
|
||
date: localIsoDate(new Date())
|
||
});
|
||
renderPeriodSelects(selected.academicYearId, selected.semesterId);
|
||
await loadWorkspace();
|
||
} catch (error) {
|
||
setStatus(error.message || 'Не удалось загрузить семестры.', 'error');
|
||
}
|
||
}
|
||
|
||
async function handleYearChange() {
|
||
const yearId = versionState.root.querySelector('#version-year')?.value || '';
|
||
const selected = resolveAcademicPeriod(versionState.semesters, {
|
||
academicYearId: yearId,
|
||
date: localIsoDate(new Date())
|
||
});
|
||
renderPeriodSelects(yearId, selected.semesterId);
|
||
await loadWorkspace();
|
||
}
|
||
|
||
function renderPeriodSelects(academicYearId, semesterId) {
|
||
const yearSelect = versionState.root.querySelector('#version-year');
|
||
const semesterSelect = versionState.root.querySelector('#version-semester');
|
||
const years = academicYearsFromSemesters(versionState.semesters);
|
||
versionState.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 = versionState.academicYearId;
|
||
|
||
const yearSemesters = semestersForAcademicYear(versionState.semesters, versionState.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.schedule.yearId', versionState.academicYearId);
|
||
}
|
||
|
||
async function loadWorkspace() {
|
||
const semesterId = selectedSemesterId();
|
||
if (!semesterId || versionState.loading) return;
|
||
versionState.loading = true;
|
||
localStorage.setItem('magistr.schedule.yearId', versionState.academicYearId);
|
||
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();
|
||
trackUxEvent('версии_расписания_загружены', { semesterId, total: versionState.versions.length });
|
||
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-primary" data-action="edit" data-id="${version.id}">Редактировать</button>
|
||
<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.semesterId', String(version.semesterId));
|
||
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 = '<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}`;
|
||
}
|