Админка, настройки, логин, кабинеты преподавателя и студента полностью переведены на 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
293 lines
10 KiB
JavaScript
293 lines
10 KiB
JavaScript
import { apiClient } from '../shared/api-client.js';
|
||
import {
|
||
formatDate,
|
||
formatIsoDate,
|
||
toIsoDate,
|
||
trimTime,
|
||
weekDates
|
||
} from '../shared/dates.js';
|
||
|
||
export const WORKSPACE_TABS = Object.freeze(['schedule', 'preferences', 'requests']);
|
||
export const PREFERENCE_BRUSHES = Object.freeze(['SOFT_PREFERRED', 'SOFT_UNWANTED', 'HARD_UNAVAILABLE']);
|
||
export const CHANGE_REQUEST_TYPES = Object.freeze(['MOVE', 'CHANGE_CLASSROOM', 'CANCEL']);
|
||
|
||
export function lessonKey(lesson) {
|
||
return `${lesson.scheduleRuleSlotId}:${lesson.date}`;
|
||
}
|
||
|
||
export function collectSlots(lessons) {
|
||
const slots = new Map();
|
||
(lessons || []).forEach(lesson => {
|
||
slots.set(lesson.timeSlotOrder, {
|
||
order: lesson.timeSlotOrder,
|
||
label: `${trimTime(lesson.startTime)} - ${trimTime(lesson.endTime)}`
|
||
});
|
||
});
|
||
if (!slots.size) {
|
||
slots.set(1, { order: 1, label: 'Расписание' });
|
||
}
|
||
return [...slots.values()].sort((left, right) => left.order - right.order);
|
||
}
|
||
|
||
export function buildDesktopWeekModel(weekStart, lessons) {
|
||
const slots = collectSlots(lessons);
|
||
const lessonsByKey = new Map();
|
||
(lessons || []).forEach(lesson => {
|
||
lessonsByKey.set(`${lesson.date}:${lesson.timeSlotOrder}`, lesson);
|
||
});
|
||
const days = weekDates(weekStart).map(date => ({
|
||
date,
|
||
isoDate: toIsoDate(date),
|
||
label: formatDate(date)
|
||
}));
|
||
return { days, slots, lessonsByKey };
|
||
}
|
||
|
||
export function lessonsByDate(lessons) {
|
||
const byDate = new Map();
|
||
(lessons || []).forEach(lesson => {
|
||
if (!byDate.has(lesson.date)) byDate.set(lesson.date, []);
|
||
byDate.get(lesson.date).push(lesson);
|
||
});
|
||
return byDate;
|
||
}
|
||
|
||
export function buildTeacherLessonChips(lesson) {
|
||
const groups = Array.isArray(lesson.groupNames) ? lesson.groupNames.join(', ') : '';
|
||
const subgroups = Array.isArray(lesson.subgroupNames) && lesson.subgroupNames.length
|
||
? lesson.subgroupNames.join(', ')
|
||
: lesson.subgroupName;
|
||
return [
|
||
`${trimTime(lesson.startTime)} - ${trimTime(lesson.endTime)}`,
|
||
lesson.lessonTypeName,
|
||
subgroups,
|
||
lesson.classroomName,
|
||
groups
|
||
].filter(Boolean);
|
||
}
|
||
|
||
export function canRequestChange(lesson) {
|
||
return Boolean(lesson.scheduleRuleSlotId && !lesson.scheduleOverrideId);
|
||
}
|
||
|
||
export function absenceStatusLabel(status) {
|
||
return {
|
||
PENDING: 'На подтверждении',
|
||
APPROVED: 'Согласовано',
|
||
REJECTED: 'Отклонено',
|
||
RESOLVED: 'Замены выполнены',
|
||
CANCELLED: 'Отменено'
|
||
}[status] || status || '—';
|
||
}
|
||
|
||
export function absenceStatusClass(status) {
|
||
return `is-${String(status || '').toLowerCase()}`;
|
||
}
|
||
|
||
export function preferenceTypeLabel(type) {
|
||
return {
|
||
HARD_UNAVAILABLE: 'Строго недоступен',
|
||
SOFT_PREFERRED: 'Предпочтительно',
|
||
SOFT_UNWANTED: 'Нежелательно',
|
||
CONSECUTIVE: 'Пары подряд',
|
||
NO_GAPS: 'Без окон'
|
||
}[type] || type || '—';
|
||
}
|
||
|
||
export function preferenceTypeClass(type) {
|
||
return {
|
||
HARD_UNAVAILABLE: 'is-hard',
|
||
SOFT_PREFERRED: 'is-preferred',
|
||
SOFT_UNWANTED: 'is-unwanted'
|
||
}[type] || '';
|
||
}
|
||
|
||
export function preferenceTypeSymbol(type) {
|
||
return { HARD_UNAVAILABLE: '×', SOFT_PREFERRED: '✓', SOFT_UNWANTED: '!' }[type] || '•';
|
||
}
|
||
|
||
export function preferenceStatusLabel(status) {
|
||
return {
|
||
PENDING: 'На рассмотрении',
|
||
APPROVED: 'Согласовано',
|
||
REJECTED: 'Отклонено',
|
||
CANCELLED: 'Отменено'
|
||
}[status] || status || '—';
|
||
}
|
||
|
||
export function changeTypeLabel(type) {
|
||
return {
|
||
MOVE: 'Перенос',
|
||
CHANGE_CLASSROOM: 'Смена аудитории',
|
||
CANCEL: 'Отмена'
|
||
}[type] || type || '—';
|
||
}
|
||
|
||
export function changeRequestTarget(item) {
|
||
if (item.requestType === 'MOVE') {
|
||
return `${formatIsoDate(item.targetLessonDate || item.lessonDate)}, ${trimTime(item.requestedStartTime)}–${trimTime(item.requestedEndTime)}`;
|
||
}
|
||
if (item.requestType === 'CHANGE_CLASSROOM') {
|
||
return item.requestedClassroomName;
|
||
}
|
||
return 'Отмена занятия';
|
||
}
|
||
|
||
export function semesterLabel(semester) {
|
||
const type = semester.semesterType === 'AUTUMN'
|
||
? 'осенний'
|
||
: semester.semesterType === 'SPRING'
|
||
? 'весенний'
|
||
: semester.semesterType;
|
||
return `${semester.academicYearTitle} · ${type} · ${formatIsoDate(semester.startDate)}–${formatIsoDate(semester.endDate)}`;
|
||
}
|
||
|
||
export function resolveCurrentSemester(semesters, todayIso) {
|
||
return (semesters || []).find(semester => semester.startDate <= todayIso && semester.endDate >= todayIso) || null;
|
||
}
|
||
|
||
export function resolveSelectedSemester(semesters, semesterId) {
|
||
return (semesters || []).find(semester => String(semester.id) === String(semesterId));
|
||
}
|
||
|
||
export function activeWeekPreferences(preferences) {
|
||
return (preferences || []).filter(item => ['PENDING', 'APPROVED'].includes(item.status) && item.dayOfWeek);
|
||
}
|
||
|
||
export function activeDatePreferences(preferences) {
|
||
return (preferences || []).filter(item => item.preferenceDate && item.status !== 'CANCELLED');
|
||
}
|
||
|
||
export function findCompactPreference(preferences, type) {
|
||
return (preferences || []).find(
|
||
candidate => candidate.preferenceType === type && ['PENDING', 'APPROVED'].includes(candidate.status)
|
||
) || null;
|
||
}
|
||
|
||
export function findWeekPreference(preferences, dayOfWeek, timeSlotId) {
|
||
return (preferences || []).find(
|
||
candidate => ['PENDING', 'APPROVED'].includes(candidate.status)
|
||
&& candidate.dayOfWeek === dayOfWeek
|
||
&& candidate.timeSlotId === timeSlotId
|
||
) || null;
|
||
}
|
||
|
||
export function pendingCount(items) {
|
||
return (items || []).filter(item => item.status === 'PENDING').length;
|
||
}
|
||
|
||
export function formatInstant(value) {
|
||
if (!value) return '—';
|
||
return new Date(value).toLocaleString('ru-RU', { dateStyle: 'short', timeStyle: 'short' });
|
||
}
|
||
|
||
export function validateAbsence({ startDate = '', endDate = '', reason = '' } = {}) {
|
||
if (!startDate || !endDate || !reason.trim()) {
|
||
return 'Заполните период и причину отсутствия';
|
||
}
|
||
if (endDate < startDate) {
|
||
return 'Дата окончания не может быть раньше даты начала';
|
||
}
|
||
return '';
|
||
}
|
||
|
||
export function buildChangeRequestPayload({ lesson, requestType, targetDate = '', timeSlotId = '', classroomId = '', reason = '' }) {
|
||
return {
|
||
baseRuleSlotId: lesson.scheduleRuleSlotId,
|
||
lessonDate: lesson.date,
|
||
requestType,
|
||
targetLessonDate: requestType === 'MOVE' ? targetDate : null,
|
||
requestedTimeSlotId: requestType === 'MOVE' ? Number(timeSlotId) || null : null,
|
||
requestedClassroomId: requestType === 'CHANGE_CLASSROOM' ? Number(classroomId) || null : null,
|
||
reason: reason.trim()
|
||
};
|
||
}
|
||
|
||
export function candidateOptions(items, emptyLabel) {
|
||
const available = (items || []).filter(item => item.available);
|
||
return available.length > 0 ? available : [{ id: '', label: emptyLabel }];
|
||
}
|
||
|
||
export async function fetchTeacherSchedule({ teacherId, startDate, endDate }, client = apiClient) {
|
||
return client.get(
|
||
`/api/schedule?teacherId=${encodeURIComponent(teacherId)}&startDate=${startDate}&endDate=${endDate}`,
|
||
{ errorMessage: 'Не удалось загрузить расписание' }
|
||
);
|
||
}
|
||
|
||
export async function fetchPreferenceMeta(client = apiClient) {
|
||
return client.get('/api/teacher-preferences/meta', { errorMessage: 'Не удалось загрузить параметры семестра' });
|
||
}
|
||
|
||
export async function fetchPreferences(semesterId, client = apiClient) {
|
||
return client.get(
|
||
`/api/teacher-preferences?semesterId=${encodeURIComponent(semesterId)}`,
|
||
{ errorMessage: 'Не удалось загрузить пожелания' }
|
||
);
|
||
}
|
||
|
||
export async function createPreference(payload, client = apiClient) {
|
||
return client.post(
|
||
'/api/teacher-preferences',
|
||
payload,
|
||
{ errorMessage: 'Не удалось сохранить пожелание' }
|
||
);
|
||
}
|
||
|
||
export async function deletePreference(id, client = apiClient) {
|
||
return client.delete(
|
||
`/api/teacher-preferences/${encodeURIComponent(id)}`,
|
||
{ errorMessage: 'Не удалось отменить пожелание' }
|
||
);
|
||
}
|
||
|
||
export async function fetchChangeRequests(client = apiClient) {
|
||
return client.get('/api/teacher-change-requests', { errorMessage: 'Не удалось загрузить заявки' });
|
||
}
|
||
|
||
export async function createChangeRequest(payload, client = apiClient) {
|
||
return client.post(
|
||
'/api/teacher-change-requests',
|
||
payload,
|
||
{ errorMessage: 'Не удалось отправить заявку' }
|
||
);
|
||
}
|
||
|
||
export async function cancelChangeRequest(id, client = apiClient) {
|
||
return client.delete(
|
||
`/api/teacher-change-requests/${encodeURIComponent(id)}`,
|
||
{ errorMessage: 'Не удалось отозвать заявку' }
|
||
);
|
||
}
|
||
|
||
export async function fetchChangeRequestCandidates({ baseRuleSlotId, lessonDate, targetDate = '' }, client = apiClient) {
|
||
const params = new URLSearchParams({
|
||
baseRuleSlotId: String(baseRuleSlotId),
|
||
lessonDate
|
||
});
|
||
if (targetDate) params.set('targetDate', targetDate);
|
||
return client.get(
|
||
`/api/teacher-change-requests/candidates?${params}`,
|
||
{ errorMessage: 'Не удалось проверить допустимые варианты' }
|
||
);
|
||
}
|
||
|
||
export async function fetchAbsences(client = apiClient) {
|
||
return client.get('/api/teacher-absences', { errorMessage: 'Не удалось загрузить заявки об отсутствии' });
|
||
}
|
||
|
||
export async function createAbsence({ startDate, endDate, reason }, client = apiClient) {
|
||
return client.post(
|
||
'/api/teacher-absences',
|
||
{ startDate, endDate, reason },
|
||
{ errorMessage: 'Не удалось отправить заявку об отсутствии' }
|
||
);
|
||
}
|
||
|
||
export async function cancelAbsence(id, client = apiClient) {
|
||
return client.delete(
|
||
`/api/teacher-absences/${encodeURIComponent(id)}`,
|
||
{ errorMessage: 'Не удалось отменить заявку' }
|
||
);
|
||
}
|