import { api } from '../api.js'; import { escapeHtml, hideAlert, showAlert } from '../utils.js'; const EDIT_ROLES = new Set(['ADMIN', 'EDUCATION_OFFICE']); const ACTION_LABELS = { MOVE: 'Перенос', REPLACE: 'Замена', CANCEL: 'Отмена' }; const DAY_LABELS = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс']; const MONTH_LABELS = [ 'Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь', 'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь' ]; export function canEditScheduleOverrides(role) { return EDIT_ROLES.has(role); } export function overrideActionForChanges(changes) { if (changes.dateChanged || changes.timeChanged) return 'MOVE'; if (changes.teacherChanged || changes.classroomChanged || changes.formatChanged) return 'REPLACE'; return null; } export function weekDates(value) { const selected = parseIsoDate(value); const day = selected.getDay() || 7; const monday = addDays(selected, 1 - day); return Array.from({ length: 7 }, (_, index) => toIsoDate(addDays(monday, index))); } export function pickMatchingTimeSlot(slots, preferredId, startTime, endTime) { const safeSlots = Array.isArray(slots) ? slots : []; const byId = preferredId == null ? null : safeSlots.find(slot => String(slot.id) === String(preferredId)); if (byId) return byId; const normalizedStart = trimTime(startTime); const normalizedEnd = trimTime(endTime); if (!normalizedStart || !normalizedEnd) return null; return safeSlots.find(slot => trimTime(slot.startTime) === normalizedStart && trimTime(slot.endTime) === normalizedEnd) || null; } export function createScheduleOverridePanel({ role, dictionaries, getRange, onChanged }) { if (!canEditScheduleOverrides(role)) { return { setRegistry() {}, openLesson() {}, openRegistry() {} }; } const drawer = document.getElementById('schedule-override-drawer'); const form = document.getElementById('schedule-override-form'); const empty = document.getElementById('schedule-override-empty'); const registryButton = document.getElementById('schedule-view-overrides-open'); const registryCount = document.getElementById('schedule-view-overrides-count'); const registry = document.getElementById('schedule-override-registry'); const registryPeriod = document.getElementById('schedule-override-registry-period'); const calendar = document.getElementById('schedule-override-calendar'); const calendarToggle = document.getElementById('schedule-override-calendar-toggle'); const week = document.getElementById('schedule-override-week'); const timeSelect = document.getElementById('schedule-override-time'); const teacherSelect = document.getElementById('schedule-override-teacher'); const classroomSelect = document.getElementById('schedule-override-classroom'); const formatSelect = document.getElementById('schedule-override-format'); const commentInput = document.getElementById('schedule-override-comment'); const restoreButton = document.getElementById('schedule-override-restore'); const cancelButton = document.getElementById('schedule-override-cancel'); const saveButton = document.getElementById('schedule-override-save'); const state = { overrides: [], selectedLesson: null, selectedOverride: null, source: null, availability: null, targetDate: null, timeSlots: [], busy: false }; registryButton.hidden = false; fillReferenceSelects(); bindEvents(); renderRegistry(); return { setRegistry, openLesson, openRegistry }; function bindEvents() { drawer.querySelectorAll('[data-override-close]').forEach(button => { button.addEventListener('click', close); }); drawer.querySelectorAll('[data-override-tab]').forEach(button => { button.addEventListener('click', () => activateTab(button.dataset.overrideTab)); }); registryButton.addEventListener('click', openRegistry); calendarToggle.addEventListener('click', () => { calendar.hidden = !calendar.hidden; calendarToggle.textContent = calendar.hidden ? 'Выбрать другую дату' : 'Скрыть календарь семестра'; }); week.addEventListener('click', selectDateFromButton); calendar.addEventListener('click', selectDateFromButton); registry.addEventListener('click', event => { const button = event.target.closest('[data-override-registry-id]'); if (!button) return; const item = state.overrides.find(override => String(override.id) === button.dataset.overrideRegistryId); if (item) openOverride(item); }); [timeSelect, teacherSelect, classroomSelect, formatSelect].forEach(select => { select.addEventListener('change', updateComparison); }); form.addEventListener('submit', save); cancelButton.addEventListener('click', cancelLesson); restoreButton.addEventListener('click', restoreRule); document.addEventListener('keydown', event => { if (event.key === 'Escape' && !drawer.hidden) close(); }); } function setRegistry(overrides, range) { state.overrides = Array.isArray(overrides) ? overrides : []; registryCount.textContent = String(state.overrides.length); const activeRange = range || getRange?.(); registryPeriod.textContent = activeRange ? `${formatDate(activeRange.startDate)} — ${formatDate(activeRange.endDate)}` : 'Период не выбран'; renderRegistry(); } async function openLesson(lesson) { if (!lesson?.scheduleRuleSlotId) return; const originalDate = lesson.originalLessonDate || lesson.date; const existing = state.overrides.find(override => String(override.baseRuleSlotId) === String(lesson.scheduleRuleSlotId) && override.lessonDate === originalDate ) || null; await prepareEditor(lesson, existing); } async function openOverride(override) { const syntheticLesson = { scheduleRuleSlotId: override.baseRuleSlotId, date: override.targetLessonDate || override.lessonDate, originalLessonDate: override.lessonDate, scheduleOverrideId: override.id, subjectName: override.subjectName, lessonTypeName: override.lessonTypeName, groupNames: override.groupNames, timeSlotId: override.newTimeSlotId || override.sourceTimeSlotId, timeSlotOrder: override.sourceTimeSlotOrder, startTime: override.sourceStartTime, endTime: override.sourceEndTime, teacherId: override.newTeacherId || override.sourceTeacherId, teacherName: teacherName(override.newTeacherId) || override.sourceTeacherName, classroomId: override.newClassroomId || override.sourceClassroomId, classroomName: classroomName(override.newClassroomId) || override.sourceClassroomName, lessonFormat: override.newLessonFormat || override.sourceLessonFormat }; await prepareEditor(syntheticLesson, override); } function openRegistry() { open(); activateTab('registry'); renderRegistry(); } async function prepareEditor(lesson, existing) { open(); activateTab('edit'); form.hidden = true; empty.hidden = false; empty.textContent = 'Загрузка доступных дат и времени...'; hideAlert('schedule-override-alert'); state.selectedLesson = lesson; state.selectedOverride = existing; state.source = sourceFrom(lesson, existing); state.targetDate = existing?.targetLessonDate || state.source.lessonDate; try { state.availability = await api.get( `/api/edu-office/schedule/overrides/availability?baseRuleSlotId=${encodeURIComponent(state.source.baseRuleSlotId)}` + `&lessonDate=${encodeURIComponent(state.source.lessonDate)}` ); renderDateChoices(); await loadTimeSlots(initialTimePreference(existing)); setEditorValues(existing); renderSource(); updateComparison(); restoreButton.hidden = !existing; form.hidden = false; empty.hidden = true; } catch (error) { empty.textContent = error.message || 'Не удалось загрузить данные занятия'; } } function sourceFrom(lesson, existing) { return { baseRuleSlotId: lesson.scheduleRuleSlotId, lessonDate: lesson.originalLessonDate || existing?.lessonDate || lesson.date, subjectName: existing?.subjectName || lesson.subjectName || 'Без дисциплины', lessonTypeName: existing?.lessonTypeName || lesson.lessonTypeName || 'Тип не указан', groupNames: existing?.groupNames?.length ? existing.groupNames : (lesson.groupNames || []), timeSlotId: existing?.sourceTimeSlotId || lesson.timeSlotId, timeSlotOrder: existing?.sourceTimeSlotOrder || lesson.timeSlotOrder, startTime: existing?.sourceStartTime || lesson.startTime, endTime: existing?.sourceEndTime || lesson.endTime, teacherId: existing?.sourceTeacherId || lesson.teacherId, teacherName: existing?.sourceTeacherName || lesson.teacherName, classroomId: existing?.sourceClassroomId || lesson.classroomId, classroomName: existing?.sourceClassroomName || lesson.classroomName, lessonFormat: existing?.sourceLessonFormat || lesson.lessonFormat || 'Очно' }; } function initialTimePreference(existing) { return { id: existing?.newTimeSlotId || state.source.timeSlotId, startTime: existing?.newTimeSlotId ? null : state.source.startTime, endTime: existing?.newTimeSlotId ? null : state.source.endTime }; } function fillReferenceSelects() { teacherSelect.innerHTML = (dictionaries.teachers || []).map(teacher => `` ).join(''); classroomSelect.innerHTML = (dictionaries.classrooms || []).map(classroom => `` ).join(''); } function setEditorValues(existing) { const effectiveTeacherId = existing?.newTeacherId || state.source.teacherId || ''; const effectiveClassroomId = existing?.newClassroomId || state.source.classroomId || ''; ensureSelectOption( teacherSelect, effectiveTeacherId, teacherName(effectiveTeacherId) || state.source.teacherName || `Преподаватель ${effectiveTeacherId}` ); ensureSelectOption( classroomSelect, effectiveClassroomId, classroomName(effectiveClassroomId) || state.source.classroomName || `Аудитория ${effectiveClassroomId}` ); teacherSelect.value = String(effectiveTeacherId); classroomSelect.value = String(effectiveClassroomId); formatSelect.value = existing?.newLessonFormat || state.source.lessonFormat || 'Очно'; commentInput.value = existing?.comment || ''; } async function selectDateFromButton(event) { const button = event.target.closest('[data-override-date]'); if (!button || button.disabled || button.dataset.overrideDate === state.targetDate) return; state.targetDate = button.dataset.overrideDate; renderDateChoices(); await loadTimeSlots({ id: state.source.timeSlotId, startTime: state.source.startTime, endTime: state.source.endTime }); updateComparison(); } function renderDateChoices() { const available = new Set(state.availability?.availableDates || []); week.innerHTML = weekDates(state.source.lessonDate).map((date, index) => dateButton(date, DAY_LABELS[index], available)).join(''); calendar.innerHTML = renderSemesterCalendar( state.availability.semesterStartDate, state.availability.semesterEndDate, available, state.targetDate ); } function dateButton(date, label, available) { const selected = date === state.targetDate ? ' active' : ''; const disabled = available.has(date) ? '' : ' disabled'; return ` `; } async function loadTimeSlots(preference) { timeSelect.disabled = true; timeSelect.innerHTML = ''; state.timeSlots = await api.get( `/api/admin/time-slots/effective?date=${encodeURIComponent(state.targetDate)}` ); const selected = pickMatchingTimeSlot( state.timeSlots, preference?.id, preference?.startTime, preference?.endTime ); timeSelect.innerHTML = '' + state.timeSlots.map(slot => `` ).join(''); timeSelect.value = selected ? String(selected.id) : ''; timeSelect.disabled = false; } function renderSource() { document.getElementById('schedule-override-before-title').textContent = `${state.source.subjectName} · ${state.source.lessonTypeName}`; document.getElementById('schedule-override-before-meta').textContent = [ formatDate(state.source.lessonDate), intervalLabel(state.source.startTime, state.source.endTime), state.source.teacherName, state.source.classroomName, state.source.lessonFormat, state.source.groupNames.join(', ') ].filter(Boolean).join(' · '); } function updateComparison() { if (!state.source) return; const selectedTime = state.timeSlots.find(slot => String(slot.id) === timeSelect.value); document.getElementById('schedule-override-after-title').textContent = `${state.source.subjectName} · ${state.source.lessonTypeName}`; document.getElementById('schedule-override-after-meta').textContent = [ formatDate(state.targetDate), selectedTime ? intervalLabel(selectedTime.startTime, selectedTime.endTime) : 'Время не выбрано', teacherName(teacherSelect.value), classroomName(classroomSelect.value), formatSelect.value, state.source.groupNames.join(', ') ].filter(Boolean).join(' · '); } async function save(event) { event.preventDefault(); if (state.busy) return; hideAlert('schedule-override-alert'); const payload = buildPayload(); if (!payload) return; await mutate(async () => { if (state.selectedOverride) { await api.put(`/api/edu-office/schedule/overrides/${state.selectedOverride.id}`, payload); } else { await api.post('/api/edu-office/schedule/overrides', payload); } }); } function buildPayload() { if (!timeSelect.value) { showAlert('schedule-override-alert', 'Выберите время занятия для выбранной даты', 'error'); return null; } const dateChanged = state.targetDate !== state.source.lessonDate; const timeChanged = String(timeSelect.value) !== String(state.source.timeSlotId); const teacherChanged = String(teacherSelect.value) !== String(state.source.teacherId); const classroomChanged = String(classroomSelect.value) !== String(state.source.classroomId); const formatChanged = formatSelect.value !== state.source.lessonFormat; const action = overrideActionForChanges({ dateChanged, timeChanged, teacherChanged, classroomChanged, formatChanged }); if (!action) { showAlert( 'schedule-override-alert', state.selectedOverride ? 'Параметры совпадают с правилом. Используйте «Вернуть по правилу»' : 'Измените дату, время, преподавателя, аудиторию или формат', 'error' ); return null; } return { baseRuleSlotId: state.source.baseRuleSlotId, lessonDate: state.source.lessonDate, targetLessonDate: dateChanged ? state.targetDate : null, action, newTimeSlotId: (dateChanged || timeChanged) ? Number(timeSelect.value) : null, newTeacherId: teacherChanged ? Number(teacherSelect.value) : null, newClassroomId: classroomChanged ? Number(classroomSelect.value) : null, newLessonFormat: formatChanged ? formatSelect.value : null, comment: commentInput.value.trim() || null }; } async function cancelLesson() { if (state.busy || !state.source) return; const payload = { baseRuleSlotId: state.source.baseRuleSlotId, lessonDate: state.source.lessonDate, targetLessonDate: null, action: 'CANCEL', newTimeSlotId: null, newTeacherId: null, newClassroomId: null, newLessonFormat: null, comment: commentInput.value.trim() || null }; await mutate(async () => { if (state.selectedOverride) { await api.put(`/api/edu-office/schedule/overrides/${state.selectedOverride.id}`, payload); } else { await api.post('/api/edu-office/schedule/overrides', payload); } }); } async function restoreRule() { if (state.busy || !state.selectedOverride) return; await mutate(() => api.delete(`/api/edu-office/schedule/overrides/${state.selectedOverride.id}`)); } async function mutate(operation) { setBusy(true); hideAlert('schedule-override-alert'); try { await operation(); await onChanged?.(); state.selectedLesson = null; state.selectedOverride = null; state.source = null; form.hidden = true; empty.hidden = false; empty.textContent = 'Выберите занятие в расписании или откройте его из реестра изменений.'; openRegistry(); } catch (error) { showAlert('schedule-override-alert', error.message || 'Не удалось сохранить разовое изменение', 'error'); } finally { setBusy(false); } } function setBusy(busy) { state.busy = busy; saveButton.disabled = busy; cancelButton.disabled = busy; restoreButton.disabled = busy; saveButton.textContent = busy ? 'Сохранение...' : 'Сохранить'; } function renderRegistry() { if (!registry) return; if (!state.overrides.length) { registry.innerHTML = '
За выбранный период разовых изменений нет.
'; return; } registry.innerHTML = state.overrides.map(override => { const target = override.targetLessonDate ? ` → ${formatDate(override.targetLessonDate)}` : ''; const result = override.action === 'CANCEL' ? 'Занятие отменено' : [ override.newTimeSlotId ? `слот ${override.newTimeSlotId}` : null, teacherName(override.newTeacherId), classroomName(override.newClassroomId), override.newLessonFormat ].filter(Boolean).join(' · '); return `
${escapeHtml(ACTION_LABELS[override.action] || override.action)}

${escapeHtml(override.subjectName || 'Занятие')}

${escapeHtml([ override.lessonTypeName, (override.groupNames || []).join(', '), result || 'Параметры изменены' ].filter(Boolean).join(' · '))}

${override.comment ? `
${escapeHtml(override.comment)}
` : ''}
`; }).join(''); } function activateTab(tab) { drawer.querySelectorAll('[data-override-tab]').forEach(button => { button.classList.toggle('active', button.dataset.overrideTab === tab); }); drawer.querySelectorAll('[data-override-section]').forEach(section => { section.hidden = section.dataset.overrideSection !== tab; }); } function open() { drawer.hidden = false; drawer.setAttribute('aria-hidden', 'false'); document.body.classList.add('schedule-override-open'); } function close() { drawer.hidden = true; drawer.setAttribute('aria-hidden', 'true'); document.body.classList.remove('schedule-override-open'); } function teacherName(id) { if (id == null || id === '') return ''; const teacher = (dictionaries.teachers || []).find(item => String(item.id) === String(id)); return teacher?.fullName || teacher?.username || ''; } function classroomName(id) { if (id == null || id === '') return ''; const classroom = (dictionaries.classrooms || []).find(item => String(item.id) === String(id)); return classroom ? classroomOptionLabel(classroom) : ''; } } function renderSemesterCalendar(startDate, endDate, available, selectedDate) { if (!startDate || !endDate) return ''; const start = parseIsoDate(startDate); const end = parseIsoDate(endDate); const months = []; for (let cursor = new Date(start.getFullYear(), start.getMonth(), 1); cursor <= end; cursor = new Date(cursor.getFullYear(), cursor.getMonth() + 1, 1)) { months.push(renderMonth(cursor, startDate, endDate, available, selectedDate)); } return months.join(''); } function renderMonth(month, semesterStart, semesterEnd, available, selectedDate) { const year = month.getFullYear(); const monthIndex = month.getMonth(); const firstDay = new Date(year, monthIndex, 1).getDay() || 7; const dayCount = new Date(year, monthIndex + 1, 0).getDate(); const blanks = Array.from({ length: firstDay - 1 }, () => '').join(''); const days = Array.from({ length: dayCount }, (_, index) => { const date = toIsoDate(new Date(year, monthIndex, index + 1)); const enabled = date >= semesterStart && date <= semesterEnd && available.has(date); const active = date === selectedDate ? ' active' : ''; return ``; }).join(''); return `

${MONTH_LABELS[monthIndex]} ${year}

${DAY_LABELS.map(label => `${label}`).join('')} ${blanks}${days}
`; } function timeSlotLabel(slot) { const order = slot.orderNumber ? `${slot.orderNumber} пара · ` : ''; return `${order}${intervalLabel(slot.startTime, slot.endTime)}`; } function intervalLabel(start, end) { return `${trimTime(start)}–${trimTime(end)}`; } export function classroomOptionLabel(room) { return room?.name || ''; } function ensureSelectOption(select, value, label) { if (value == null || value === '') return; const exists = Array.from(select.options).some(option => String(option.value) === String(value)); if (exists) return; const option = document.createElement('option'); option.value = String(value); option.textContent = label; select.append(option); } function formatDate(value) { if (!value) return '—'; const [year, month, day] = String(value).split('-'); return `${day}.${month}.${year}`; } function formatDateShort(value) { const [, month, day] = String(value).split('-'); return `${day}.${month}`; } function trimTime(value) { return String(value || '').slice(0, 5); } function parseIsoDate(value) { const [year, month, day] = String(value).split('-').map(Number); return new Date(year, month - 1, day); } function addDays(date, days) { const next = new Date(date); next.setDate(next.getDate() + days); return next; } function toIsoDate(date) { return [ date.getFullYear(), String(date.getMonth() + 1).padStart(2, '0'), String(date.getDate()).padStart(2, '0') ].join('-'); }