Files
magistr/frontend/react/admin/groups-service.js
dipatrik10 97bf3cc50a 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
2026-09-28 23:15:04 +03:00

355 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
? 'По заданным условиям группы не найдены'
: 'Групп пока нет';
}