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:
dipatrik10
2026-09-28 23:15:04 +03:00
parent c6083b35e0
commit 97bf3cc50a
167 changed files with 31283 additions and 19033 deletions

View File

@@ -0,0 +1,213 @@
import { apiClient } from '../shared/api-client.js';
import { capabilitiesForRole } from '../../admin/js/role-capabilities.js';
export const SETTINGS_TABS = Object.freeze([
{ id: 'general', title: 'Общие настройки' },
{ id: 'time-slots', title: 'Временные слоты' },
{ id: 'edu-forms', title: 'Формы обучения' },
{ id: 'database', title: 'База данных' }
]);
const APPLY_MODE_LABELS = Object.freeze({
DEFAULT: 'применяется по умолчанию',
WEEKDAY: 'применяется автоматически',
MANUAL: 'применяется вручную'
});
const DAY_SHORT_LABELS = Object.freeze({
1: 'Пн',
2: 'Вт',
3: 'Ср',
4: 'Чт',
5: 'Пт',
6: 'Сб',
7: 'Вс'
});
export function settingsTabsForRole(role) {
const capabilities = capabilitiesForRole(role);
if (!capabilities || capabilities.settingsTabs.length === 0) return [];
const allowed = new Set(capabilities.settingsTabs);
return SETTINGS_TABS.filter(tab => allowed.has(tab.id));
}
export function defaultSettingsTabForRole(role) {
const capabilities = capabilitiesForRole(role);
return capabilities ? capabilities.defaultSettingsTab : null;
}
export function applyModeLabel(mode) {
return APPLY_MODE_LABELS[mode] || mode || '—';
}
export function dayShort(dayOfWeek) {
return DAY_SHORT_LABELS[Number(dayOfWeek)] || '-';
}
export function scopeOptionLabel(scope) {
const suffix = scope.applyMode === 'WEEKDAY'
? `, ${dayShort(scope.dayOfWeek)}`
: '';
return `${scope.name} - ${applyModeLabel(scope.applyMode)}${suffix}`;
}
export function scopeBadgeLabel(scope) {
if (scope.applyMode === 'DEFAULT') return 'Базовая';
if (scope.applyMode === 'WEEKDAY') return dayShort(scope.dayOfWeek);
return 'Ручная';
}
export function scopeBadgeClass(scope) {
return scope.applyMode === 'DEFAULT' ? 'scope-badge scope-badge-base' : 'scope-badge';
}
export function scopeSummaryText(scope, slots) {
const mode = applyModeLabel(scope.applyMode);
const day = scope.applyMode === 'WEEKDAY' ? `, ${dayShort(scope.dayOfWeek)}` : '';
const slotCount = slotsForScope(slots, scope.id).length;
return `${mode}${day}. Слотов: ${slotCount}.`;
}
export function isSystemScope(scope) {
return Boolean(scope && scope.systemScope);
}
export function defaultScopeId(scopes) {
const found = (scopes || []).find(scope => scope.applyMode === 'DEFAULT');
return found ? found.id : (scopes || [])[0]?.id || null;
}
export function resolveActiveScopeId(scopes, preferredId) {
const list = scopes || [];
if (preferredId && list.some(scope => scope.id === preferredId)) return preferredId;
return defaultScopeId(list);
}
export function slotsForScope(slots, scopeId) {
return (slots || [])
.filter(slot => slot.scopeId === scopeId)
.sort((left, right) => (left.orderNumber || 0) - (right.orderNumber || 0));
}
export function validateSlotPayload({ scopeId, orderNumber, startTime = '', endTime = '' } = {}) {
if (!scopeId) return 'Выберите сетку времени';
if (!orderNumber || orderNumber <= 0) return 'Укажите номер пары';
if (!startTime || !endTime) return 'Укажите время начала и окончания';
if (startTime >= endTime) return 'Время начала должно быть раньше времени окончания';
return '';
}
export function slotDurationMinutes(startTime = '', endTime = '') {
if (!startTime || !endTime || startTime >= endTime) return null;
const [startHour, startMinute] = startTime.split(':').map(Number);
const [endHour, endMinute] = endTime.split(':').map(Number);
return (endHour * 60 + endMinute) - (startHour * 60 + startMinute);
}
export function buildSlotPayload({ id = '', orderNumber, scopeId, startTime, endTime }) {
return {
id: id ? Number(id) : null,
orderNumber: Number(orderNumber),
scopeId,
startTime,
endTime
};
}
export function validateTenantFields({ name = '', domain = '', url = '' } = {}) {
if (!name.trim() || !domain.trim() || !url.trim()) {
return 'Заполните все обязательные поля';
}
return '';
}
export function normalizeDomain(domain = '') {
return domain.trim().toLowerCase();
}
export async function fetchScopes(client = apiClient) {
return client.get('/api/admin/time-slots/scopes', { errorMessage: 'Не удалось загрузить сетки времени' });
}
export async function createScope(name, client = apiClient) {
return client.post(
'/api/admin/time-slots/scopes',
{ name },
{ errorMessage: 'Не удалось создать сетку времени' }
);
}
export async function deleteScope(id, client = apiClient) {
return client.delete(
`/api/admin/time-slots/scopes/${encodeURIComponent(id)}`,
{ errorMessage: 'Не удалось удалить сетку времени' }
);
}
export async function fetchTimeSlots(client = apiClient) {
return client.get('/api/admin/time-slots', { errorMessage: 'Не удалось загрузить временные слоты' });
}
export async function saveTimeSlot(payload, client = apiClient) {
const options = { errorMessage: 'Не удалось сохранить временной слот' };
return payload.id
? client.put(`/api/admin/time-slots/${encodeURIComponent(payload.id)}`, payload, options)
: client.post('/api/admin/time-slots', payload, options);
}
export async function deleteTimeSlot(id, client = apiClient) {
return client.delete(
`/api/admin/time-slots/${encodeURIComponent(id)}`,
{ errorMessage: 'Не удалось удалить временной слот' }
);
}
export async function fetchEducationForms(client = apiClient) {
return client.get('/api/education-forms', { errorMessage: 'Не удалось загрузить формы обучения' });
}
export async function createEducationForm(name, client = apiClient) {
return client.post(
'/api/education-forms',
{ name },
{ errorMessage: 'Не удалось создать форму обучения' }
);
}
export async function deleteEducationForm(id, client = apiClient) {
return client.delete(
`/api/education-forms/${encodeURIComponent(id)}`,
{ errorMessage: 'Не удалось удалить форму обучения' }
);
}
export async function fetchDatabaseStatus(client = apiClient) {
return client.get('/api/database/status', { errorMessage: 'Не удалось загрузить статус подключения' });
}
export async function fetchTenants(client = apiClient) {
return client.get('/api/database/tenants', { errorMessage: 'Не удалось загрузить список тенантов' });
}
export async function testTenantConnection({ url, username, password }, client = apiClient) {
return client.post(
'/api/database/test',
{ password, url, username },
{ errorMessage: 'Не удалось проверить подключение' }
);
}
export async function createTenant({ name, domain, url, username, password }, client = apiClient) {
return client.post(
'/api/database/tenants',
{ domain, name, password, url, username },
{ errorMessage: 'Не удалось добавить тенант' }
);
}
export async function deleteTenant(domain, client = apiClient) {
return client.delete(
`/api/database/tenants/${encodeURIComponent(domain)}`,
{ errorMessage: 'Не удалось удалить тенант' }
);
}