Админка, настройки, логин, кабинеты преподавателя и студента полностью переведены на 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
355 lines
14 KiB
JavaScript
355 lines
14 KiB
JavaScript
import { apiClient } from '../shared/api-client.js';
|
||
import { buildAcademicCalendarTitle } from '../../admin/js/views/academic-calendar-title.js';
|
||
|
||
export const GROUP_TABLE_COLUMNS = 10;
|
||
export const GROUP_PAGE_SIZES = [25, 50, 100];
|
||
|
||
export const GROUP_SORT_OPTIONS = Object.freeze([
|
||
{ value: 'name,asc', label: 'По названию А–Я' },
|
||
{ value: 'name,desc', label: 'По названию Я–А' },
|
||
{ value: 'yearStartStudy,desc', label: 'Сначала новые наборы' },
|
||
{ value: 'groupSize,desc', label: 'Сначала крупные' },
|
||
{ value: 'status,asc', label: 'По статусу' }
|
||
]);
|
||
|
||
export const SUBGROUP_COUNT_OPTIONS = Object.freeze([
|
||
{ value: '0', label: 'Без разделения на подгруппы' },
|
||
{ value: '2', label: 'Две подгруппы' },
|
||
{ value: '3', label: 'Три подгруппы' }
|
||
]);
|
||
|
||
export const FILTER_EF_EMPTY_TEXT = 'Выберите форму обучения';
|
||
|
||
export function normalizeGroupsState(restored = {}) {
|
||
const size = Number(restored.groupSize);
|
||
return {
|
||
query: restored.groupQuery || '',
|
||
page: Math.max(Number(restored.groupPage) || 0, 0),
|
||
size: GROUP_PAGE_SIZES.includes(size) ? size : 25,
|
||
sort: GROUP_SORT_OPTIONS.some(option => option.value === restored.groupSort)
|
||
? restored.groupSort
|
||
: 'name,asc',
|
||
educationFormIds: Array.isArray(restored.groupEducationForm)
|
||
? restored.groupEducationForm.map(String)
|
||
: []
|
||
};
|
||
}
|
||
|
||
export function initialGroupsState(restored = {}) {
|
||
return normalizeGroupsState(restored);
|
||
}
|
||
|
||
export function groupsQueryParams(state) {
|
||
const [sort, direction] = String(state.sort || 'name,asc').split(',');
|
||
const params = new URLSearchParams({
|
||
query: state.query || '',
|
||
page: String(state.page || 0),
|
||
size: String(state.size || 25),
|
||
sort,
|
||
direction,
|
||
includeArchived: 'true'
|
||
});
|
||
(state.educationFormIds || []).forEach(id => params.append('educationFormId', id));
|
||
return `/api/groups/page?${params}`;
|
||
}
|
||
|
||
export function groupsUrlState(state) {
|
||
return {
|
||
groupQuery: state.query || '',
|
||
groupPage: state.page || '',
|
||
groupSize: state.size === 25 ? '' : state.size,
|
||
groupSort: state.sort === 'name,asc' ? '' : state.sort,
|
||
groupEducationForm: state.educationFormIds || []
|
||
};
|
||
}
|
||
|
||
// --- Справочники ---
|
||
|
||
export function fetchEducationForms(client = apiClient) {
|
||
// legacy молча подставляет пустой список при сбое загрузки форм обучения
|
||
return client.get('/api/education-forms').catch(() => []);
|
||
}
|
||
|
||
export function fetchDepartments(client = apiClient) {
|
||
return client.get('/api/departments', { errorMessage: 'Не удалось загрузить список кафедр' });
|
||
}
|
||
|
||
export function fetchSpecialties(client = apiClient) {
|
||
return client.get('/api/specialties', { errorMessage: 'Не удалось загрузить список специальностей' });
|
||
}
|
||
|
||
export function fetchAcademicYears(client = apiClient) {
|
||
return client.get('/api/admin/calendar/years', { errorMessage: 'Не удалось загрузить учебные годы' });
|
||
}
|
||
|
||
export async function fetchProfilesForSpecialties(specialties, client = apiClient) {
|
||
const pairs = await Promise.all((specialties || []).map(async (speciality) => {
|
||
const profiles = await client.get(
|
||
`/api/specialties/${speciality.id}/profiles`,
|
||
{ errorMessage: 'Не удалось загрузить профили обучения' }
|
||
);
|
||
return [String(speciality.id), profiles || []];
|
||
}));
|
||
return new Map(pairs);
|
||
}
|
||
|
||
// --- Реестр групп ---
|
||
|
||
export function fetchGroupsPage(state, client = apiClient) {
|
||
return client.get(groupsQueryParams(state), { errorMessage: 'Не удалось загрузить группы' });
|
||
}
|
||
|
||
export function groupPayloadFromFields(fields) {
|
||
const checks = [
|
||
['name', 'Введите название группы'],
|
||
['groupSize', 'Введите размер группы'],
|
||
['educationFormId', 'Выберите форму обучения'],
|
||
['departmentId', 'Выберите кафедру'],
|
||
['yearStartStudy', 'Введите год начала обучения'],
|
||
['specialtyId', 'Выберите специальность'],
|
||
['specialtyProfileId', 'Выберите профиль обучения']
|
||
];
|
||
for (const [field, message] of checks) {
|
||
if (!fields[field]) return { error: message };
|
||
}
|
||
return {
|
||
payload: {
|
||
name: fields.name,
|
||
groupSize: Number(fields.groupSize),
|
||
educationFormId: Number(fields.educationFormId),
|
||
departmentId: Number(fields.departmentId),
|
||
yearStartStudy: Number(fields.yearStartStudy),
|
||
specialtyId: Number(fields.specialtyId),
|
||
specialtyProfileId: Number(fields.specialtyProfileId)
|
||
}
|
||
};
|
||
}
|
||
|
||
export function createGroup(payload, client = apiClient) {
|
||
return client.post('/api/groups', payload, { errorMessage: 'Ошибка создания' });
|
||
}
|
||
|
||
export function updateGroup(id, payload, client = apiClient) {
|
||
return client.put(`/api/groups/${id}`, payload, { errorMessage: 'Ошибка обновления' });
|
||
}
|
||
|
||
export function archiveGroup(id, client = apiClient) {
|
||
return client.delete(`/api/groups/${id}`, { errorMessage: 'Ошибка архивирования' });
|
||
}
|
||
|
||
export function restoreGroup(id, client = apiClient) {
|
||
return client.post(`/api/groups/${id}/restore`, {}, { errorMessage: 'Ошибка восстановления' });
|
||
}
|
||
|
||
// --- Подгруппы ---
|
||
|
||
export function fetchSubgroups(groupId, client = apiClient) {
|
||
return client.get(`/api/groups/${groupId}/subgroups`, { errorMessage: 'Не удалось загрузить подгруппы' });
|
||
}
|
||
|
||
export function createSubgroup(groupId, payload, client = apiClient) {
|
||
return client.post(`/api/groups/${groupId}/subgroups`, payload, { errorMessage: 'Ошибка сохранения подгруппы' });
|
||
}
|
||
|
||
export function updateSubgroup(groupId, id, payload, client = apiClient) {
|
||
return client.put(`/api/groups/${groupId}/subgroups/${id}`, payload, { errorMessage: 'Ошибка сохранения подгруппы' });
|
||
}
|
||
|
||
export function deleteSubgroup(groupId, id, client = apiClient) {
|
||
return client.delete(`/api/groups/${groupId}/subgroups/${id}`, { errorMessage: 'Ошибка удаления подгруппы' });
|
||
}
|
||
|
||
export function naturalSubgroupOrder(name) {
|
||
const number = String(name || '').match(/\d+/)?.[0];
|
||
return number ? String(number).padStart(3, '0') : String(name || '');
|
||
}
|
||
|
||
export function sortSubgroups(subgroups) {
|
||
return [...(subgroups || [])].sort((left, right) =>
|
||
naturalSubgroupOrder(left.name).localeCompare(naturalSubgroupOrder(right.name), 'ru'));
|
||
}
|
||
|
||
export function suggestedSubgroupCapacities(groupSize, count) {
|
||
if (!groupSize || !count) return [];
|
||
const base = Math.floor(groupSize / count);
|
||
const remainder = groupSize % count;
|
||
return Array.from({ length: count }, (_, index) => base + (index < remainder ? 1 : 0));
|
||
}
|
||
|
||
export function validateSubgroupCapacities(capacities, groupSize, count) {
|
||
if (capacities.length !== count || capacities.some(value => !Number.isInteger(value) || value <= 0)) {
|
||
return 'Укажите положительную численность для каждой подгруппы';
|
||
}
|
||
|
||
const total = capacities.reduce((sum, value) => sum + value, 0);
|
||
if (total !== groupSize) {
|
||
return `Сумма численностей подгрупп должна быть равна численности группы (${groupSize} чел.)`;
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
// Синхронизация подгрупп воспроизводит legacy-порядок: сначала удаляем лишние,
|
||
// затем обновляем существующие (сначала уменьшающие вместимость), затем создаём новые.
|
||
export async function syncSubgroups(groupId, existingSubgroups, capacities, client = apiClient) {
|
||
const targetCount = capacities.length;
|
||
const extraSubgroups = existingSubgroups.slice(targetCount);
|
||
for (const subgroup of extraSubgroups) {
|
||
await deleteSubgroup(groupId, subgroup.id, client);
|
||
}
|
||
|
||
const updates = [];
|
||
const creates = [];
|
||
for (let index = 0; index < targetCount; index += 1) {
|
||
const payload = {
|
||
name: `Подгруппа ${index + 1}`,
|
||
studentCapacity: capacities[index]
|
||
};
|
||
const existing = existingSubgroups[index];
|
||
if (existing) {
|
||
updates.push({ existing, payload });
|
||
} else {
|
||
creates.push(payload);
|
||
}
|
||
}
|
||
|
||
updates.sort((left, right) => {
|
||
const leftIncreases = left.payload.studentCapacity > Number(left.existing.studentCapacity || 0);
|
||
const rightIncreases = right.payload.studentCapacity > Number(right.existing.studentCapacity || 0);
|
||
return Number(leftIncreases) - Number(rightIncreases);
|
||
});
|
||
|
||
for (const update of updates) {
|
||
await updateSubgroup(groupId, update.existing.id, update.payload, client);
|
||
}
|
||
|
||
for (const payload of creates) {
|
||
await createSubgroup(groupId, payload, client);
|
||
}
|
||
}
|
||
|
||
// --- Календарные графики ---
|
||
|
||
export function calendarsOptionsEndpoint({ query, academicYearId, specialtyId, profileId, studyFormId, selectedId }) {
|
||
const params = new URLSearchParams({
|
||
query,
|
||
academicYearId,
|
||
specialtyId,
|
||
profileId,
|
||
studyFormId,
|
||
limit: '30'
|
||
});
|
||
if (selectedId) params.set('selectedId', selectedId);
|
||
return `/api/admin/academic-calendars/options?${params}`;
|
||
}
|
||
|
||
export function fetchCalendarsOptions(params, client = apiClient) {
|
||
return client.get(calendarsOptionsEndpoint(params), { errorMessage: 'Не удалось загрузить календарные графики' });
|
||
}
|
||
|
||
export function fetchCalendarAssignments(groupId, client = apiClient) {
|
||
return client.get(`/api/groups/${groupId}/calendar-assignments`, { errorMessage: 'Не удалось загрузить назначения графиков' });
|
||
}
|
||
|
||
export function saveCalendarAssignment(groupId, { academicYearId, calendarId }, client = apiClient) {
|
||
return client.put(
|
||
`/api/groups/${groupId}/calendar-assignments`,
|
||
{ academicYearId: Number(academicYearId), calendarId: Number(calendarId) },
|
||
{ errorMessage: 'Ошибка назначения графика' }
|
||
);
|
||
}
|
||
|
||
export function deleteCalendarAssignment(groupId, id, client = apiClient) {
|
||
return client.delete(`/api/groups/${groupId}/calendar-assignments/${id}`, { errorMessage: 'Ошибка удаления назначения' });
|
||
}
|
||
|
||
// --- Фильтр форм обучения ---
|
||
|
||
export function efFilterLabel(educationForms, selectedIds) {
|
||
const ids = selectedIds || [];
|
||
if (!ids.length) return 'Все';
|
||
if (ids.length === 1) {
|
||
const form = (educationForms || []).find(item => String(item.id) === ids[0]);
|
||
return form?.name || FILTER_EF_EMPTY_TEXT;
|
||
}
|
||
return `Выбрано: ${ids.length}`;
|
||
}
|
||
|
||
// --- Метки ---
|
||
|
||
export function departmentLabel(departments, departmentId) {
|
||
const department = (departments || []).find(item => String(item.id) === String(departmentId));
|
||
if (!department) return departmentId || '-';
|
||
return department.departmentName || department.name || departmentId;
|
||
}
|
||
|
||
export function departmentOptionLabel(department) {
|
||
const name = department.departmentName || department.name || 'Без названия';
|
||
const code = department.departmentCode || department.code || department.id;
|
||
return `${name} (${code})`;
|
||
}
|
||
|
||
export function specialityLabel(specialties, specialityId) {
|
||
const speciality = (specialties || []).find(item => String(item.id) === String(specialityId));
|
||
if (!speciality) return specialityId || '-';
|
||
const code = speciality.specialityCode || speciality.specialtyCode || speciality.specialty_code || speciality.id;
|
||
const name = speciality.specialityName || speciality.name || '';
|
||
return name ? `${code} — ${name}` : code;
|
||
}
|
||
|
||
export function specialityOptionLabel(speciality) {
|
||
const code = speciality.specialityCode || speciality.specialtyCode || speciality.specialty_code;
|
||
const name = speciality.specialityName || speciality.name;
|
||
return `${code} · ${name}`;
|
||
}
|
||
|
||
export function subjectLabel(subject) {
|
||
const code = subject.subjectCode ? `${subject.subjectCode} · ` : '';
|
||
return `${code}${subject.subjectName || subject.subjectId || 'Без названия'}`;
|
||
}
|
||
|
||
export function statusLabel(group) {
|
||
if (group.status === 'ARCHIVED') return 'Архив';
|
||
return group.studyStateName || (group.active ? 'Активна' : 'Неактивна');
|
||
}
|
||
|
||
export function statusBadgeClass(group) {
|
||
if (group.status === 'ARCHIVED' || group.studyState === 'GRADUATED' || group.studyState === 'INACTIVE') {
|
||
return 'badge-unavailable';
|
||
}
|
||
if (group.studyState === 'NOT_STARTED') {
|
||
return 'badge-ef';
|
||
}
|
||
return 'badge-available';
|
||
}
|
||
|
||
export function calendarOptionLabel(calendar) {
|
||
return buildAcademicCalendarTitle(calendar) || calendar.title || `График ${calendar.id}`;
|
||
}
|
||
|
||
export function calendarOptionDescription(calendar) {
|
||
return `${calendar.academicYearTitle || ''} · ${calendar.studyFormName || ''}`;
|
||
}
|
||
|
||
export function assignmentCalendarTitle(assignment) {
|
||
return assignment.calendarTitle || '-';
|
||
}
|
||
|
||
export function assignmentSubjectsGroups(subjects) {
|
||
const grouped = new Map();
|
||
(subjects || []).forEach(subject => {
|
||
const key = String(subject.semesterNumber || '-');
|
||
if (!grouped.has(key)) grouped.set(key, []);
|
||
grouped.get(key).push(subject);
|
||
});
|
||
return Array.from(grouped.entries())
|
||
.sort(([a], [b]) => Number(a) - Number(b))
|
||
.map(([semesterNumber, items]) => ({ semesterNumber, items }));
|
||
}
|
||
|
||
export function groupsEmptyMessage(state) {
|
||
return state.query || (state.educationFormIds || []).length
|
||
? 'По заданным условиям группы не найдены'
|
||
: 'Групп пока нет';
|
||
}
|