feat: завершить миграцию фронтенда на React (этапы 1-5)
Админка, настройки, логин, кабинеты преподавателя и студента полностью переведены на React 19 (модель strangler, docs/REACT_MIGRATION.md). - 15 вкладок админки: lazy-загрузка, общий error boundary, сервисный слой - Удалены legacy-views и переходный слой LegacyTabView (45 файлов) - Сборка esbuild (frontend/scripts/build-react.mjs) с бюджетами gzip на чанки - CSP-хэши, кэширование: chunk'и immutable, CSS/HTML no-cache (react-cache.conf) - Фиксы: lazy-импорт именованных экспортов (краш после авторизации), растяжение #admin-root/#settings-root на всю ширину, возврат subjects/lessonTypes из справочников (краш просмотра расписаний), отказ от заведомо широкого поиска расписания без выбранной цели, центрирование карточки логина - Тесты: 237/237 (npm run check), регрессионные на lazy-вкладки и ширину - Документация: REACT_MIGRATION.md, REACT_MIGRATION_RESULT.md, FRONTEND.md
This commit is contained in:
333
frontend/react/admin/schedule-override-service.js
Normal file
333
frontend/react/admin/schedule-override-service.js
Normal file
@@ -0,0 +1,333 @@
|
||||
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: 'Не удалось удалить разовое изменение'
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user