import { apiClient } from '../shared/api-client.js'; import { addDays, formatDate, formatDateShort, parseIsoDate, toIsoDate, trimTime } from './schedule-view-service.js'; // Волна 6 (этап 5): чистая логика и контракты API разовых правок расписания, // перенесённые из legacy admin/js/views/schedule-override-panel.js без изменения // поведения. Императивный модуль панели заменён React-состоянием во вкладке // просмотра расписаний. export const OVERRIDE_EDIT_ROLES = new Set(['ADMIN', 'EDUCATION_OFFICE']); export const OVERRIDE_ACTION_LABELS = { MOVE: 'Перенос', REPLACE: 'Замена', CANCEL: 'Отмена' }; export const OVERRIDE_DAY_LABELS = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс']; export const OVERRIDE_MONTH_LABELS = [ 'Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь', 'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь' ]; // --- Права и решения --- export function canEditScheduleOverrides(role) { return OVERRIDE_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 overrideIntervalLabel(start, end) { return `${trimTime(start)}–${trimTime(end)}`; } export function overrideTimeSlotLabel(slot) { const order = slot.orderNumber ? `${slot.orderNumber} пара · ` : ''; return `${order}${overrideIntervalLabel(slot.startTime, slot.endTime)}`; } // --- Источник правки --- export function overrideSourceFrom(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 || 'Очно' }; } export function initialTimePreference(existing, source) { return { id: existing?.newTimeSlotId || source.timeSlotId, startTime: existing?.newTimeSlotId ? null : source.startTime, endTime: existing?.newTimeSlotId ? null : source.endTime }; } // Синтетическое занятие для открытия существующей правки из реестра. export function lessonFromOverride(override, dictionaries) { return { 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: overrideTeacherName(dictionaries, override.newTeacherId) || override.sourceTeacherName, classroomId: override.newClassroomId || override.sourceClassroomId, classroomName: overrideClassroomName(dictionaries, override.newClassroomId) || override.sourceClassroomName, lessonFormat: override.newLessonFormat || override.sourceLessonFormat }; } export function overrideTeacherName(dictionaries, id) { if (id == null || id === '') return ''; const teacher = (dictionaries.teachers || []).find(item => String(item.id) === String(id)); return teacher?.fullName || teacher?.username || ''; } export function overrideClassroomName(dictionaries, id) { if (id == null || id === '') return ''; const classroom = (dictionaries.classrooms || []).find(item => String(item.id) === String(id)); return classroom ? classroomOptionLabel(classroom) : ''; } export function classroomOptionLabel(room) { return room?.name || ''; } // --- Сравнение «было / станет» --- export function overrideBeforeMeta(source) { return [ formatDate(source.lessonDate), overrideIntervalLabel(source.startTime, source.endTime), source.teacherName, source.classroomName, source.lessonFormat, source.groupNames.join(', ') ].filter(Boolean).join(' · '); } export function overrideAfterMeta({ source, targetDate, selectedTime, teacherName, classroomName, lessonFormat }) { return [ formatDate(targetDate), selectedTime ? overrideIntervalLabel(selectedTime.startTime, selectedTime.endTime) : 'Время не выбрано', teacherName, classroomName, lessonFormat, source.groupNames.join(', ') ].filter(Boolean).join(' · '); } // --- Календарь семестра --- export function semesterCalendarMonths(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(overrideMonth(cursor, startDate, endDate, available, selectedDate)); } return months; } function overrideMonth(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 }, (_, index) => ({ blank: true, key: `blank-${index}` })); 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); return { key: date, date, day: index + 1, enabled, active: date === selectedDate }; }); return { title: `${OVERRIDE_MONTH_LABELS[monthIndex]} ${year}`, blanks, days }; } // --- Payload правки --- export function buildOverridePayload({ source, selectedOverride, targetDate, timeSlotId, teacherId, classroomId, lessonFormat, comment }) { if (!timeSlotId) { return { error: 'Выберите время занятия для выбранной даты' }; } const dateChanged = targetDate !== source.lessonDate; const timeChanged = String(timeSlotId) !== String(source.timeSlotId); const teacherChanged = String(teacherId) !== String(source.teacherId); const classroomChanged = String(classroomId) !== String(source.classroomId); const formatChanged = lessonFormat !== source.lessonFormat; const action = overrideActionForChanges({ dateChanged, timeChanged, teacherChanged, classroomChanged, formatChanged }); if (!action) { return { error: selectedOverride ? 'Параметры совпадают с правилом. Используйте «Вернуть по правилу»' : 'Измените дату, время, преподавателя, аудиторию или формат' }; } return { payload: { baseRuleSlotId: source.baseRuleSlotId, lessonDate: source.lessonDate, targetLessonDate: dateChanged ? targetDate : null, action, newTimeSlotId: (dateChanged || timeChanged) ? Number(timeSlotId) : null, newTeacherId: teacherChanged ? Number(teacherId) : null, newClassroomId: classroomChanged ? Number(classroomId) : null, newLessonFormat: formatChanged ? lessonFormat : null, comment: comment.trim() || null } }; } export function buildCancelOverridePayload(source, comment) { return { baseRuleSlotId: source.baseRuleSlotId, lessonDate: source.lessonDate, targetLessonDate: null, action: 'CANCEL', newTimeSlotId: null, newTeacherId: null, newClassroomId: null, newLessonFormat: null, comment: comment.trim() || null }; } // --- Реестр правок --- export function overrideRegistryItem(override, dictionaries) { const target = override.targetLessonDate ? ` → ${formatDate(override.targetLessonDate)}` : ''; const result = override.action === 'CANCEL' ? 'Занятие отменено' : [ override.newTimeSlotId ? `слот ${override.newTimeSlotId}` : null, overrideTeacherName(dictionaries, override.newTeacherId), overrideClassroomName(dictionaries, override.newClassroomId), override.newLessonFormat ].filter(Boolean).join(' · '); return { id: override.id, actionLabel: OVERRIDE_ACTION_LABELS[override.action] || override.action, actionClass: `action-${String(override.action).toLowerCase()}`, timeText: `${formatDate(override.lessonDate)}${target}`, subject: override.subjectName || 'Занятие', description: [ override.lessonTypeName, (override.groupNames || []).join(', '), result || 'Параметры изменены' ].filter(Boolean).join(' · '), comment: override.comment || '' }; } // --- Контракты API --- export function overrideAvailabilityEndpoint(baseRuleSlotId, lessonDate) { return `/api/edu-office/schedule/overrides/availability?baseRuleSlotId=${encodeURIComponent(baseRuleSlotId)}` + `&lessonDate=${encodeURIComponent(lessonDate)}`; } export async function fetchOverrideAvailability(baseRuleSlotId, lessonDate, client = apiClient) { return client.get(overrideAvailabilityEndpoint(baseRuleSlotId, lessonDate), { errorMessage: 'Не удалось загрузить доступные даты занятия' }); } export function effectiveTimeSlotsEndpoint(date) { return `/api/admin/time-slots/effective?date=${encodeURIComponent(date)}`; } export async function fetchEffectiveTimeSlots(date, client = apiClient) { return client.get(effectiveTimeSlotsEndpoint(date), { errorMessage: 'Не удалось загрузить время занятий' }); } export async function saveScheduleOverride(payload, overrideId, client = apiClient) { return overrideId ? client.put(`/api/edu-office/schedule/overrides/${encodeURIComponent(overrideId)}`, payload, { errorMessage: 'Не удалось сохранить разовое изменение' }) : client.post('/api/edu-office/schedule/overrides', payload, { errorMessage: 'Не удалось сохранить разовое изменение' }); } export async function deleteScheduleOverride(overrideId, client = apiClient) { return client.delete(`/api/edu-office/schedule/overrides/${encodeURIComponent(overrideId)}`, { errorMessage: 'Не удалось удалить разовое изменение' }); }