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:
213
frontend/react/settings/settings-service.js
Normal file
213
frontend/react/settings/settings-service.js
Normal 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: 'Не удалось удалить тенант' }
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user