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:
354
frontend/react/admin/groups-service.js
Normal file
354
frontend/react/admin/groups-service.js
Normal file
@@ -0,0 +1,354 @@
|
||||
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
|
||||
? 'По заданным условиям группы не найдены'
|
||||
: 'Групп пока нет';
|
||||
}
|
||||
Reference in New Issue
Block a user