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:
641
frontend/react/admin/academic-calendar-service.js
Normal file
641
frontend/react/admin/academic-calendar-service.js
Normal file
@@ -0,0 +1,641 @@
|
||||
import { apiClient } from '../shared/api-client.js';
|
||||
import { dateInputValueToIso, isoDateToInputValue } from '../../admin/js/date-input.js';
|
||||
import { academicCalendarWeekNumber } from '../../admin/js/views/academic-calendar-grid.js';
|
||||
import { buildAcademicCalendarTitle } from '../../admin/js/views/academic-calendar-title.js';
|
||||
|
||||
export const DAY_OPTIONS = Object.freeze([
|
||||
Object.freeze({ value: 1, label: 'Понедельник', shortLabel: 'Пн' }),
|
||||
Object.freeze({ value: 2, label: 'Вторник', shortLabel: 'Вт' }),
|
||||
Object.freeze({ value: 3, label: 'Среда', shortLabel: 'Ср' }),
|
||||
Object.freeze({ value: 4, label: 'Четверг', shortLabel: 'Чт' }),
|
||||
Object.freeze({ value: 5, label: 'Пятница', shortLabel: 'Пт' }),
|
||||
Object.freeze({ value: 6, label: 'Суббота', shortLabel: 'Сб' }),
|
||||
Object.freeze({ value: 7, label: 'Воскресенье', shortLabel: 'Вс' })
|
||||
]);
|
||||
|
||||
export const SEMESTER_LABELS = Object.freeze({
|
||||
autumn: 'осенний',
|
||||
spring: 'весенний'
|
||||
});
|
||||
|
||||
const CALENDAR_TOOLTIP_OFFSET = 14;
|
||||
const CALENDAR_TOOLTIP_VIEWPORT_PADDING = 12;
|
||||
|
||||
// --- Позиция подсказки ячейки ---
|
||||
|
||||
export function calculateCalendarTooltipPosition({
|
||||
pointerX,
|
||||
pointerY,
|
||||
tooltipWidth,
|
||||
tooltipHeight,
|
||||
viewportWidth,
|
||||
viewportHeight
|
||||
}) {
|
||||
const maxLeft = Math.max(
|
||||
CALENDAR_TOOLTIP_VIEWPORT_PADDING,
|
||||
viewportWidth - tooltipWidth - CALENDAR_TOOLTIP_VIEWPORT_PADDING
|
||||
);
|
||||
const maxTop = Math.max(
|
||||
CALENDAR_TOOLTIP_VIEWPORT_PADDING,
|
||||
viewportHeight - tooltipHeight - CALENDAR_TOOLTIP_VIEWPORT_PADDING
|
||||
);
|
||||
let left = pointerX + CALENDAR_TOOLTIP_OFFSET;
|
||||
let top = pointerY + CALENDAR_TOOLTIP_OFFSET;
|
||||
|
||||
if (left > maxLeft) {
|
||||
left = pointerX - tooltipWidth - CALENDAR_TOOLTIP_OFFSET;
|
||||
}
|
||||
if (top > maxTop) {
|
||||
top = pointerY - tooltipHeight - CALENDAR_TOOLTIP_OFFSET;
|
||||
}
|
||||
|
||||
return {
|
||||
left: Math.round(Math.max(CALENDAR_TOOLTIP_VIEWPORT_PADDING, Math.min(left, maxLeft))),
|
||||
top: Math.round(Math.max(CALENDAR_TOOLTIP_VIEWPORT_PADDING, Math.min(top, maxTop)))
|
||||
};
|
||||
}
|
||||
|
||||
// --- Учебный год ---
|
||||
|
||||
export function academicYearDefaults(startYearValue) {
|
||||
const value = String(startYearValue || '').trim();
|
||||
if (!/^\d{4}$/.test(value)) {
|
||||
throw new TypeError('Год начала учебного года должен состоять из четырёх цифр');
|
||||
}
|
||||
const startYear = Number(value);
|
||||
if (startYear < 1000 || startYear > 9998) {
|
||||
throw new TypeError('Год начала учебного года должен быть в диапазоне от 1000 до 9998');
|
||||
}
|
||||
const endYear = startYear + 1;
|
||||
|
||||
const start = new Date(Date.UTC(startYear, 8, 1));
|
||||
const autumnEnd = new Date(Date.UTC(endYear, 0, 31));
|
||||
const springStart = new Date(Date.UTC(endYear, 1, 1));
|
||||
const end = new Date(Date.UTC(endYear, 5, 30));
|
||||
|
||||
return {
|
||||
startYear,
|
||||
endYear,
|
||||
title: `${startYear}-${endYear}`,
|
||||
startDate: toIsoDateUtc(start),
|
||||
endDate: toIsoDateUtc(end),
|
||||
semesters: [
|
||||
{
|
||||
semesterType: 'autumn',
|
||||
startDate: toIsoDateUtc(start),
|
||||
endDate: toIsoDateUtc(autumnEnd)
|
||||
},
|
||||
{
|
||||
semesterType: 'spring',
|
||||
startDate: toIsoDateUtc(springStart),
|
||||
endDate: toIsoDateUtc(end)
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
export function validateAcademicYearSemesters(semesters, yearStart, yearEnd) {
|
||||
if (!Array.isArray(semesters) || semesters.length === 0) {
|
||||
throw new Error('Учебный год нельзя создать без семестров');
|
||||
}
|
||||
const sorted = [...semesters].sort((a, b) => String(a.startDate).localeCompare(String(b.startDate)));
|
||||
const types = new Set();
|
||||
sorted.forEach((semester, index) => {
|
||||
if (!semester?.semesterType || !semester.startDate || !semester.endDate) {
|
||||
throw new Error('Заполните тип и даты каждого семестра');
|
||||
}
|
||||
if (types.has(semester.semesterType)) {
|
||||
throw new Error('Типы семестров не должны повторяться');
|
||||
}
|
||||
types.add(semester.semesterType);
|
||||
if (semester.endDate < semester.startDate) {
|
||||
throw new Error('Дата окончания семестра не может быть раньше даты начала');
|
||||
}
|
||||
if (semester.startDate < yearStart || semester.endDate > yearEnd) {
|
||||
throw new Error('Семестры должны находиться в границах учебного года');
|
||||
}
|
||||
if (index > 0 && sorted[index - 1].endDate >= semester.startDate) {
|
||||
throw new Error('Периоды семестров не должны пересекаться');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function toIsoDateUtc(date) {
|
||||
return [
|
||||
date.getUTCFullYear(),
|
||||
String(date.getUTCMonth() + 1).padStart(2, '0'),
|
||||
String(date.getUTCDate()).padStart(2, '0')
|
||||
].join('-');
|
||||
}
|
||||
|
||||
// Ввод года начала: нормализует значение и пересчитывает год окончания
|
||||
// вместе с датами семестров. Как в legacy, ручной ввод года сбрасывает даты
|
||||
// семестров к рассчитанным значениям (semesterDates === null — не трогать).
|
||||
export function startYearInputResult(rawValue) {
|
||||
const startYear = String(rawValue || '').replace(/\D/g, '').slice(0, 4);
|
||||
if (startYear.length !== 4) {
|
||||
return { startYear, endYear: '', semesterDates: null };
|
||||
}
|
||||
try {
|
||||
const defaults = academicYearDefaults(startYear);
|
||||
return {
|
||||
startYear,
|
||||
endYear: String(defaults.endYear),
|
||||
semesterDates: {
|
||||
autumn: {
|
||||
start: isoDateToInputValue(defaults.semesters[0].startDate),
|
||||
end: isoDateToInputValue(defaults.semesters[0].endDate)
|
||||
},
|
||||
spring: {
|
||||
start: isoDateToInputValue(defaults.semesters[1].startDate),
|
||||
end: isoDateToInputValue(defaults.semesters[1].endDate)
|
||||
}
|
||||
}
|
||||
};
|
||||
} catch {
|
||||
return { startYear, endYear: '', semesterDates: null };
|
||||
}
|
||||
}
|
||||
|
||||
// Чтение даты из поля ДД.ММ.ГГГГ с подписью поля в сообщении об ошибке
|
||||
// (реплика legacy readDateInputIso).
|
||||
export function readDisplayDateIso(displayValue, label = 'Дата') {
|
||||
try {
|
||||
return dateInputValueToIso(displayValue);
|
||||
} catch (error) {
|
||||
throw new Error(`${label}: ${lowercaseFirst(error.message)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Типы активности ---
|
||||
|
||||
export function activityColorCode(activity) {
|
||||
const color = String(activity?.colorCode || '').trim();
|
||||
return /^#[0-9a-f]{6}$/i.test(color) ? color : '#64748b';
|
||||
}
|
||||
|
||||
export function activityColorClass(activity) {
|
||||
return `calendar-activity-color-${activityColorCode(activity).slice(1).toLowerCase()}`;
|
||||
}
|
||||
|
||||
export function activityById(id, activityTypes) {
|
||||
return (activityTypes || []).find(type => String(type.id) === String(id));
|
||||
}
|
||||
|
||||
export function activityIdByCode(code, activityTypes) {
|
||||
return (activityTypes || []).find(type => type.code === code)?.id || null;
|
||||
}
|
||||
|
||||
export function theoryActivityId(activityTypes) {
|
||||
return (activityTypes || []).find(type => type.code === 'Т')?.id || activityTypes?.[0]?.id || '';
|
||||
}
|
||||
|
||||
// Эффективный код активности ячейки: сохранённое значение, затем код по
|
||||
// справочнику, затем теория (реплика legacy renderCalendarDay).
|
||||
export function effectiveActivityTypeId(row, activityTypes) {
|
||||
return row.activityTypeId || activityIdByCode(row.activityCode, activityTypes) || theoryActivityId(activityTypes);
|
||||
}
|
||||
|
||||
// --- Контракты API ---
|
||||
|
||||
export function fetchSpecialties(client = apiClient) {
|
||||
return client.get('/api/specialties', { errorMessage: 'Ошибка загрузки специальностей' });
|
||||
}
|
||||
|
||||
export function specialtyProfilesEndpoint(specialtyId) {
|
||||
return `/api/specialties/${specialtyId}/profiles`;
|
||||
}
|
||||
|
||||
export function fetchSpecialtyProfiles(specialtyId, client = apiClient) {
|
||||
return client.get(specialtyProfilesEndpoint(specialtyId), {
|
||||
errorMessage: 'Ошибка загрузки профилей специальности'
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchAcademicYearsList(client = apiClient) {
|
||||
return client.get('/api/admin/calendar/years', { errorMessage: 'Ошибка загрузки учебных годов' });
|
||||
}
|
||||
|
||||
export function fetchSubjectsList(client = apiClient) {
|
||||
return client.get('/api/subjects', { errorMessage: 'Ошибка загрузки дисциплин' });
|
||||
}
|
||||
|
||||
export function fetchEducationForms(client = apiClient) {
|
||||
return client.get('/api/education-forms', { errorMessage: 'Ошибка загрузки форм обучения' });
|
||||
}
|
||||
|
||||
export function fetchActivityTypes(client = apiClient) {
|
||||
return client.get('/api/admin/calendar/activity-types', {
|
||||
errorMessage: 'Ошибка загрузки типов активности'
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchTimeSlotScopes(client = apiClient) {
|
||||
return client.get('/api/admin/time-slots/scopes', { errorMessage: 'Ошибка загрузки сеток времени' });
|
||||
}
|
||||
|
||||
export function fetchTimeSlotDateAssignments(client = apiClient) {
|
||||
return client.get('/api/admin/time-slots/date-assignments', {
|
||||
errorMessage: 'Ошибка загрузки ручных сеток'
|
||||
});
|
||||
}
|
||||
|
||||
export function calendarsPageEndpoint({ page, size, query, academicYearId } = {}) {
|
||||
const params = new URLSearchParams({
|
||||
page: String(page ?? 0),
|
||||
size: String(size ?? 25),
|
||||
query: String(query || '').trim()
|
||||
});
|
||||
if (academicYearId) params.set('academicYearId', String(academicYearId));
|
||||
return `/api/admin/academic-calendars/page?${params}`;
|
||||
}
|
||||
|
||||
export function fetchCalendarsPage({ page, size, query, academicYearId }, client = apiClient) {
|
||||
return client.get(calendarsPageEndpoint({ page, size, query, academicYearId }), {
|
||||
errorMessage: 'Ошибка загрузки календарных графиков'
|
||||
});
|
||||
}
|
||||
|
||||
export function academicCalendarsOptionsEndpoint(query, selectedId) {
|
||||
const params = new URLSearchParams({ query: String(query || ''), limit: '20' });
|
||||
if (selectedId) params.set('selectedId', String(selectedId));
|
||||
return `/api/admin/academic-calendars/options?${params}`;
|
||||
}
|
||||
|
||||
export function fetchCalendarOptions(query, selectedId, client = apiClient) {
|
||||
return client.get(academicCalendarsOptionsEndpoint(query, selectedId), {
|
||||
errorMessage: 'Ошибка поиска календарных графиков'
|
||||
});
|
||||
}
|
||||
|
||||
export function saveAcademicYearRequest(payload, id, client = apiClient) {
|
||||
return id
|
||||
? client.put(`/api/admin/calendar/years/${id}`, payload, { errorMessage: 'Ошибка сохранения учебного года' })
|
||||
: client.post('/api/admin/calendar/years', payload, { errorMessage: 'Ошибка сохранения учебного года' });
|
||||
}
|
||||
|
||||
export function deleteAcademicYearRequest(id, client = apiClient) {
|
||||
return client.delete(`/api/admin/calendar/years/${id}`, {
|
||||
errorMessage: 'Ошибка удаления учебного года'
|
||||
});
|
||||
}
|
||||
|
||||
export function saveCalendarRequest(payload, id, client = apiClient) {
|
||||
return id
|
||||
? client.put(`/api/admin/academic-calendars/${id}`, payload, { errorMessage: 'Ошибка сохранения календарного графика' })
|
||||
: client.post('/api/admin/academic-calendars', payload, { errorMessage: 'Ошибка сохранения календарного графика' });
|
||||
}
|
||||
|
||||
export function deleteCalendarRequest(id, client = apiClient) {
|
||||
return client.delete(`/api/admin/academic-calendars/${id}`, {
|
||||
errorMessage: 'Ошибка удаления календарного графика'
|
||||
});
|
||||
}
|
||||
|
||||
export function calendarSubjectsEndpoint(calendarId) {
|
||||
return `/api/admin/academic-calendars/${calendarId}/subjects`;
|
||||
}
|
||||
|
||||
export function fetchCalendarSubjects(calendarId, client = apiClient) {
|
||||
return client.get(calendarSubjectsEndpoint(calendarId), {
|
||||
errorMessage: 'Ошибка загрузки дисциплин графика'
|
||||
});
|
||||
}
|
||||
|
||||
export function saveCalendarSubjectsRequest(calendarId, payload, client = apiClient) {
|
||||
return client.put(calendarSubjectsEndpoint(calendarId), payload, {
|
||||
errorMessage: 'Ошибка сохранения дисциплин графика'
|
||||
});
|
||||
}
|
||||
|
||||
export function calendarGridEndpoint(calendarId) {
|
||||
return `/api/admin/academic-calendars/${calendarId}/grid`;
|
||||
}
|
||||
|
||||
export function fetchCalendarGrid(calendarId, client = apiClient) {
|
||||
return client.get(calendarGridEndpoint(calendarId), { errorMessage: 'Ошибка загрузки сетки' });
|
||||
}
|
||||
|
||||
export function saveCalendarGridRequest(calendarId, rows, client = apiClient) {
|
||||
return client.put(calendarGridEndpoint(calendarId), rows, {
|
||||
errorMessage: 'Ошибка сохранения сетки'
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteTimeSlotDateAssignmentRequest(id, client = apiClient) {
|
||||
return client.delete(`/api/admin/time-slots/date-assignments/${id}`, {
|
||||
errorMessage: 'Ошибка удаления ручной сетки'
|
||||
});
|
||||
}
|
||||
|
||||
// --- Подписи ---
|
||||
|
||||
export function calendarOptionLabel(calendar) {
|
||||
return buildAcademicCalendarTitle(calendar) || calendar.title;
|
||||
}
|
||||
|
||||
export function calendarOptionDescription(calendar) {
|
||||
return calendar.specialtyName || '';
|
||||
}
|
||||
|
||||
export function specialityLabel(speciality) {
|
||||
const code = specialityCode(speciality) || speciality.id;
|
||||
const name = speciality.specialityName || speciality.name || '';
|
||||
return name ? `${code} - ${name}` : String(code);
|
||||
}
|
||||
|
||||
export function specialityCode(speciality) {
|
||||
return speciality?.specialityCode
|
||||
|| speciality?.specialtyCode
|
||||
|| speciality?.speciality_code
|
||||
|| speciality?.specialty_code
|
||||
|| '';
|
||||
}
|
||||
|
||||
export function dayLabel(value) {
|
||||
return DAY_OPTIONS.find(day => String(day.value) === String(value))?.label || '-';
|
||||
}
|
||||
|
||||
export function shortDayLabel(value) {
|
||||
return DAY_OPTIONS.find(day => String(day.value) === String(value))?.shortLabel || '-';
|
||||
}
|
||||
|
||||
export function formatShortDate(value) {
|
||||
const [, month, day] = String(value).split('-');
|
||||
return `${day}.${month}`;
|
||||
}
|
||||
|
||||
export function formatLongDate(value) {
|
||||
const [year, month, day] = String(value).split('-');
|
||||
return `${day}.${month}.${year}`;
|
||||
}
|
||||
|
||||
export function formatDisplayDate(value) {
|
||||
const [year, month, day] = String(value || '').split('-');
|
||||
if (!year || !month || !day) return String(value || '');
|
||||
return `${day}-${month}-${year}`;
|
||||
}
|
||||
|
||||
export function formatDisplayDateRange(startDate, endDate) {
|
||||
return `${formatDisplayDate(startDate)} — ${formatDisplayDate(endDate)}`;
|
||||
}
|
||||
|
||||
export function dateRangeCaption(rows) {
|
||||
if (!rows.length) return '';
|
||||
const dates = rows.map(row => row.date).sort();
|
||||
return `${formatLongDate(dates[0])} - ${formatLongDate(dates[dates.length - 1])}`;
|
||||
}
|
||||
|
||||
export function semesterTypeTitle(semester) {
|
||||
const type = normalizeSemesterType(semester.semesterType);
|
||||
const label = SEMESTER_LABELS[type] || type || 'семестр';
|
||||
return label.charAt(0).toUpperCase() + label.slice(1);
|
||||
}
|
||||
|
||||
export function normalizeSemesterType(type) {
|
||||
return String(type || '').toLowerCase();
|
||||
}
|
||||
|
||||
export function semesterOptionLabel(semesterNumber) {
|
||||
const course = Math.ceil(Number(semesterNumber) / 2);
|
||||
const type = Number(semesterNumber) % 2 === 1 ? 'осенний' : 'весенний';
|
||||
return `${semesterNumber} семестр · ${course} курс, ${type}`;
|
||||
}
|
||||
|
||||
export function subjectLabel(subject) {
|
||||
const code = subject.code ? `${subject.code} · ` : '';
|
||||
return `${code}${subject.name || 'Без названия'}`;
|
||||
}
|
||||
|
||||
export function subjectRowLabel(row) {
|
||||
const code = row.subjectCode ? `${row.subjectCode} · ` : '';
|
||||
return `${code}${row.subjectName || row.subjectId || 'Без названия'}`;
|
||||
}
|
||||
|
||||
export function sortCalendarSubjectRows(rows) {
|
||||
return [...(rows || [])].sort((a, b) => {
|
||||
const semesterDiff = Number(a.semesterNumber) - Number(b.semesterNumber);
|
||||
if (semesterDiff !== 0) return semesterDiff;
|
||||
return subjectRowLabel(a).localeCompare(subjectRowLabel(b), 'ru');
|
||||
});
|
||||
}
|
||||
|
||||
export function calendarSemesterCount(calendar) {
|
||||
return Math.max(1, Number(calendar?.courseCount || 0) * 2);
|
||||
}
|
||||
|
||||
// --- Сетка годового графика ---
|
||||
|
||||
export function groupBy(items, keyFn) {
|
||||
const grouped = new Map();
|
||||
(items || []).forEach(item => {
|
||||
const key = keyFn(item);
|
||||
if (!grouped.has(key)) grouped.set(key, []);
|
||||
grouped.get(key).push(item);
|
||||
});
|
||||
return grouped;
|
||||
}
|
||||
|
||||
export function calendarRowKey(row) {
|
||||
return `${row.courseNumber}:${row.date}`;
|
||||
}
|
||||
|
||||
export function uniqueWeekNumbers(rows) {
|
||||
return Array.from(new Set((rows || []).map(row => row.weekNumber)));
|
||||
}
|
||||
|
||||
export function minIso(a, b) {
|
||||
return String(a) <= String(b) ? a : b;
|
||||
}
|
||||
|
||||
export function maxIso(a, b) {
|
||||
return String(a) >= String(b) ? a : b;
|
||||
}
|
||||
|
||||
export function previousIsoDate(value) {
|
||||
const date = parseLocalIsoDate(value);
|
||||
date.setDate(date.getDate() - 1);
|
||||
return formatLocalDate(date);
|
||||
}
|
||||
|
||||
export function enumerateDates(startIso, endIso) {
|
||||
const result = [];
|
||||
const current = parseLocalIsoDate(startIso);
|
||||
const end = parseLocalIsoDate(endIso);
|
||||
while (current <= end) {
|
||||
const iso = formatLocalDate(current);
|
||||
const jsDay = current.getDay();
|
||||
result.push({ iso, dayOfWeek: jsDay === 0 ? 7 : jsDay });
|
||||
current.setDate(current.getDate() + 1);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseLocalIsoDate(value) {
|
||||
const [year, month, day] = String(value).split('-').map(Number);
|
||||
const date = new Date(year, month - 1, day, 12);
|
||||
if (!year || !month || !day || Number.isNaN(date.getTime())
|
||||
|| date.getFullYear() !== year
|
||||
|| date.getMonth() !== month - 1
|
||||
|| date.getDate() !== day) {
|
||||
throw new TypeError('Ожидалась дата в формате ГГГГ-ММ-ДД');
|
||||
}
|
||||
return date;
|
||||
}
|
||||
|
||||
function formatLocalDate(date) {
|
||||
return [
|
||||
date.getFullYear(),
|
||||
String(date.getMonth() + 1).padStart(2, '0'),
|
||||
String(date.getDate()).padStart(2, '0')
|
||||
].join('-');
|
||||
}
|
||||
|
||||
export function buildDefaultGrid(calendar, activityTypes) {
|
||||
const rows = [];
|
||||
const dates = enumerateDates(calendar.academicYearStartDate, calendar.academicYearEndDate);
|
||||
for (let course = 1; course <= calendar.courseCount; course += 1) {
|
||||
dates.forEach(date => {
|
||||
rows.push({
|
||||
id: null,
|
||||
calendarId: calendar.id,
|
||||
courseNumber: course,
|
||||
date: date.iso,
|
||||
weekNumber: academicCalendarWeekNumber(calendar.academicYearStartDate, date.iso),
|
||||
dayOfWeek: date.dayOfWeek,
|
||||
activityTypeId: theoryActivityId(activityTypes),
|
||||
activityCode: 'Т'
|
||||
});
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function semesterBlocksForCourse(calendar, course, courseRows, academicYears) {
|
||||
const sortedRows = [...(courseRows || [])].sort((a, b) => String(a.date).localeCompare(String(b.date)));
|
||||
const year = (academicYears || []).find(item => String(item.id) === String(calendar?.academicYearId));
|
||||
const yearSemesters = [...(year?.semesters || [])]
|
||||
.sort((a, b) => String(a.startDate).localeCompare(String(b.startDate)));
|
||||
|
||||
if (!yearSemesters.length) {
|
||||
return [{
|
||||
semesterNumber: `${course * 2 - 1}-${course * 2}`,
|
||||
caption: dateRangeCaption(sortedRows),
|
||||
rows: sortedRows
|
||||
}];
|
||||
}
|
||||
|
||||
return yearSemesters.map((semester, index) => {
|
||||
const nextSemester = yearSemesters[index + 1];
|
||||
const startDate = index === 0
|
||||
? minIso(calendar.academicYearStartDate, semester.startDate)
|
||||
: semester.startDate;
|
||||
const endDate = nextSemester
|
||||
? previousIsoDate(nextSemester.startDate)
|
||||
: maxIso(calendar.academicYearEndDate, semester.endDate);
|
||||
const rows = sortedRows.filter(row => row.date >= startDate && row.date <= endDate);
|
||||
return {
|
||||
semesterNumber: course * 2 - 1 + index,
|
||||
caption: dateRangeCaption(rows) || `${formatLongDate(startDate)} - ${formatLongDate(endDate)}`,
|
||||
rows
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Итоговые чипы сетки: счётчики по кодам активности, сортировка по коду.
|
||||
export function gridTotalsEntries(rows, activityTypes) {
|
||||
const counts = new Map();
|
||||
(rows || []).forEach(row => {
|
||||
const activity = activityById(row.activityTypeId, activityTypes);
|
||||
const key = activity ? activity.code : '-';
|
||||
const current = counts.get(key) || { count: 0, colorClass: activityColorClass(activity) };
|
||||
current.count += 1;
|
||||
counts.set(key, current);
|
||||
});
|
||||
return Array.from(counts.entries())
|
||||
.sort(([a], [b]) => a.localeCompare(b, 'ru'))
|
||||
.map(([code, entry]) => ({ code, count: entry.count, colorClass: entry.colorClass }));
|
||||
}
|
||||
|
||||
// Общий код активности набора ячеек: одно значение — оно само, иначе пусто.
|
||||
export function commonActivityValue(rows) {
|
||||
const values = Array.from(new Set((rows || []).map(row => String(row.activityTypeId ?? ''))));
|
||||
return values.length === 1 ? values[0] : '';
|
||||
}
|
||||
|
||||
// --- Ручные сетки дат ---
|
||||
|
||||
export function timeAssignmentForDate(assignments, date) {
|
||||
return (assignments || []).find(item => item.date === date);
|
||||
}
|
||||
|
||||
export function automaticTimeScopeLabel(date, scopes) {
|
||||
const jsDate = new Date(`${date}T00:00:00`);
|
||||
const jsDay = jsDate.getDay();
|
||||
const isoDay = jsDay === 0 ? 7 : jsDay;
|
||||
const weekdayScope = (scopes || []).find(scope => scope.applyMode === 'WEEKDAY' && Number(scope.dayOfWeek) === isoDay);
|
||||
const defaultScope = (scopes || []).find(scope => scope.applyMode === 'DEFAULT');
|
||||
const scopeName = weekdayScope?.name || defaultScope?.name || 'базовая сетка';
|
||||
return `По правилу даты (${scopeName})`;
|
||||
}
|
||||
|
||||
// --- Тексты диалога ячейки ---
|
||||
|
||||
export function dayDialogTexts(rows) {
|
||||
const dates = rows.map(row => row.date).sort();
|
||||
const courses = new Set(rows.map(row => String(row.courseNumber)));
|
||||
const weeks = new Set(rows.map(row => String(row.weekNumber)));
|
||||
const weekDays = new Set(rows.map(row => String(row.dayOfWeek)));
|
||||
const isSingle = rows.length === 1;
|
||||
const first = rows[0];
|
||||
|
||||
return {
|
||||
date: isSingle
|
||||
? `${formatLongDate(first.date)} (${shortDayLabel(first.dayOfWeek)})`
|
||||
: `${formatLongDate(dates[0])} - ${formatLongDate(dates[dates.length - 1])}`,
|
||||
course: courses.size === 1 ? `${first.courseNumber} курс` : `${courses.size} курса`,
|
||||
week: weeks.size === 1 ? `${first.weekNumber} неделя` : `${weeks.size} недель`,
|
||||
day: weekDays.size === 1 ? dayLabel(first.dayOfWeek) : `${rows.length} ячеек`
|
||||
};
|
||||
}
|
||||
|
||||
export function dayDialogSummaryParts(rows, activityTypes) {
|
||||
if (rows.length <= 1) {
|
||||
const activity = rows[0] ? activityById(rows[0].activityTypeId, activityTypes) : null;
|
||||
return activity
|
||||
? { kind: 'single', activity }
|
||||
: { kind: 'empty' };
|
||||
}
|
||||
const common = commonActivityValue(rows);
|
||||
const activity = common ? activityById(common, activityTypes) : null;
|
||||
return activity
|
||||
? { kind: 'multi', activity, count: rows.length }
|
||||
: { kind: 'multi-empty', count: rows.length };
|
||||
}
|
||||
|
||||
export function dayTooltipParts(row, activityTypes, scopes, assignments) {
|
||||
const activity = activityById(row.activityTypeId, activityTypes);
|
||||
// Код ячейки берётся с учётом запасного типа активности (реплика legacy:
|
||||
// код ячейки — activityById(...) || activityTypes[0] || {}).
|
||||
const cellActivity = activity || activityTypes[0] || {};
|
||||
const code = activity?.code || cellActivity.code || row.activityCode || '-';
|
||||
const assignment = timeAssignmentForDate(assignments, row.date);
|
||||
const timeLabel = assignment
|
||||
? ((scopes || []).find(scope => String(scope.id) === String(assignment.scopeId))?.name || 'ручная сетка')
|
||||
: automaticTimeScopeLabel(row.date, scopes).replace('По правилу даты ', '');
|
||||
return {
|
||||
code,
|
||||
longDate: formatLongDate(row.date),
|
||||
shortDay: shortDayLabel(row.dayOfWeek),
|
||||
week: row.weekNumber,
|
||||
course: row.courseNumber,
|
||||
activityName: activity?.name || 'Не задана',
|
||||
timeLabel,
|
||||
colorClass: activity ? activityColorClass(activity) : ''
|
||||
};
|
||||
}
|
||||
|
||||
function lowercaseFirst(value) {
|
||||
const text = String(value || '');
|
||||
return text ? text[0].toLowerCase() + text.slice(1) : text;
|
||||
}
|
||||
Reference in New Issue
Block a user