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,318 @@
import { useEffect, useState } from 'react';
import { ConfirmDialog } from '../shared/ui/ConfirmDialog.jsx';
import {
createTenant,
deleteTenant,
fetchDatabaseStatus,
fetchTenants,
normalizeDomain,
testTenantConnection,
validateTenantFields
} from './settings-service.js';
export function DatabaseTab() {
const [status, setStatus] = useState(null);
const [statusError, setStatusError] = useState(false);
const [tenants, setTenants] = useState(null);
const [tenantsError, setTenantsError] = useState(false);
const [form, setForm] = useState({ domain: '', name: '', password: '', url: '', username: '' });
const [alert, setAlert] = useState({ text: '', isError: false });
const [testing, setTesting] = useState(false);
const [deleting, setDeleting] = useState(null);
useEffect(() => {
let active = true;
(async () => {
const [statusResult, tenantsResult] = await Promise.allSettled([
fetchDatabaseStatus(),
fetchTenants()
]);
if (!active) return;
if (statusResult.status === 'fulfilled') {
setStatus(statusResult.value);
} else {
setStatusError(true);
}
if (tenantsResult.status === 'fulfilled') {
setTenants(tenantsResult.value);
} else {
setTenantsError(true);
}
})();
return () => {
active = false;
};
}, []);
async function reload() {
const [statusResult, tenantsResult] = await Promise.allSettled([
fetchDatabaseStatus(),
fetchTenants()
]);
if (statusResult.status === 'fulfilled') {
setStatus(statusResult.value);
setStatusError(false);
} else {
setStatusError(true);
}
if (tenantsResult.status === 'fulfilled') {
setTenants(tenantsResult.value);
setTenantsError(false);
} else {
setTenantsError(true);
}
}
function updateField(field, value) {
setForm(current => ({ ...current, [field]: value }));
}
async function testConnection() {
setAlert({ text: '', isError: false });
if (!form.url.trim()) {
setAlert({ text: 'Введите JDBC URL', isError: true });
return;
}
setTesting(true);
try {
const result = await testTenantConnection({
password: form.password,
url: form.url.trim(),
username: form.username.trim()
});
if (result && result.success) {
setAlert({ text: '✓ Подключение успешно!', isError: false });
} else {
setAlert({ text: `✗ ${(result && result.message) || 'Подключение не выполнено'}`, isError: true });
}
} catch {
setAlert({ text: 'Не удалось проверить подключение', isError: true });
} finally {
setTesting(false);
}
}
async function submit(event) {
event.preventDefault();
setAlert({ text: '', isError: false });
const payload = {
domain: normalizeDomain(form.domain),
name: form.name.trim(),
url: form.url.trim(),
username: form.username.trim(),
password: form.password
};
const validation = validateTenantFields(payload);
if (validation) {
setAlert({ text: validation, isError: true });
return;
}
try {
const result = await createTenant(payload);
if (result && result.success) {
setAlert({ text: `Тенант «${payload.domain}» добавлен`, isError: false });
setForm({ domain: '', name: '', password: '', url: '', username: '' });
await reload();
} else {
setAlert({ text: (result && result.message) || 'Не удалось добавить тенант', isError: true });
}
} catch {
setAlert({ text: 'Не удалось добавить тенант', isError: true });
}
}
async function confirmDelete() {
const tenant = deleting;
setDeleting(null);
try {
await deleteTenant(tenant.domain);
await reload();
} catch (error) {
setAlert({ text: error.message || 'Не удалось удалить тенант', isError: true });
}
}
return (
<>
<div className="card">
<h2>Текущее подключение</h2>
<div className="db-status-card">
{statusError ? (
<div className="loading-row">Не удалось загрузить статус подключения</div>
) : status === null ? (
<div className="loading-row">Загрузка...</div>
) : (
<div className="db-status-grid">
<div>
<span>Тенант:</span>
<strong>{status.tenant || '—'}</strong>
</div>
<div>
<span>Название:</span>
<strong>{status.name || '—'}</strong>
</div>
<div>
<span>Статус:</span>
<span className={`badge ${status.connected ? 'badge-available' : 'badge-unavailable'}`}>
{status.connected ? 'Доступно' : 'Недоступно'}
</span>
</div>
{status.url && (
<div>
<span>URL:</span>
<code>{status.url}</code>
</div>
)}
</div>
)}
</div>
</div>
<div className="card">
<h2>Подключённые университеты</h2>
<div className="table-wrap">
<table>
<thead>
<tr>
<th>Название</th>
<th>Домен</th>
<th>JDBC URL</th>
<th>Пользователь</th>
<th>Статус</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
{tenantsError ? (
<tr><td colSpan="6" className="loading-row">Не удалось загрузить список тенантов</td></tr>
) : tenants === null ? (
<tr><td colSpan="6" className="loading-row">Загрузка...</td></tr>
) : tenants.length === 0 ? (
<tr><td colSpan="6" className="loading-row">Нет подключённых тенантов</td></tr>
) : tenants.map(tenant => (
<tr key={tenant.domain}>
<td>{tenant.name || '—'}</td>
<td><code>{tenant.domain}</code></td>
<td><code>{tenant.url}</code></td>
<td>{tenant.username || '—'}</td>
<td>
<span className={`badge ${tenant.connected ? 'badge-available' : 'badge-unavailable'}`}>
{tenant.connected ? 'Доступно' : 'Недоступно'}
</span>
</td>
<td>
<button
type="button"
className="btn btn-sm btn-danger"
onClick={() => setDeleting(tenant)}
>
Удалить
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div className="card create-card">
<h2>Добавить подключение</h2>
<form onSubmit={submit}>
<div className="form-row">
<div className="form-group">
<label htmlFor="tenant-name">Название университета</label>
<input
type="text"
id="tenant-name"
placeholder="ЮЗГУ"
required
value={form.name}
onChange={event => updateField('name', event.target.value)}
/>
</div>
<div className="form-group">
<label htmlFor="tenant-domain">Поддомен</label>
<input
type="text"
id="tenant-domain"
placeholder="swsu"
required
value={form.domain}
onChange={event => updateField('domain', event.target.value)}
/>
</div>
</div>
<div className="form-row">
<div className="form-group form-group-wide">
<label htmlFor="tenant-url">JDBC URL</label>
<input
type="text"
id="tenant-url"
placeholder="jdbc:postgresql://192.168.1.50:5432/magistr_db"
required
value={form.url}
onChange={event => updateField('url', event.target.value)}
/>
</div>
</div>
<div className="form-row">
<div className="form-group">
<label htmlFor="tenant-username">Пользователь</label>
<input
type="text"
id="tenant-username"
placeholder="postgres"
autoComplete="username"
required
value={form.username}
onChange={event => updateField('username', event.target.value)}
/>
</div>
<div className="form-group">
<label htmlFor="tenant-password">Пароль</label>
<input
type="password"
id="tenant-password"
placeholder="••••••••"
autoComplete="new-password"
required
value={form.password}
onChange={event => updateField('password', event.target.value)}
/>
</div>
<button
type="button"
className="btn btn-md btn-secondary form-row-actions"
disabled={testing}
onClick={() => void testConnection()}
>
{testing ? '...' : 'Тест'}
</button>
<button type="submit" className="btn btn-md btn-primary form-row-actions">Добавить</button>
</div>
<div
className={`form-alert${alert.text ? (alert.isError ? ' error' : ' success') : ''}`}
role="alert"
>
{alert.text}
</div>
</form>
</div>
{deleting && (
<ConfirmDialog
title="Удаление тенанта"
message={`Удалить тенант «${deleting.domain}»? Пул соединений будет закрыт.`}
confirmLabel="Удалить тенант"
danger
onClose={() => setDeleting(null)}
onConfirm={() => void confirmDelete()}
/>
)}
</>
);
}

View File

@@ -0,0 +1,149 @@
import { useEffect, useState } from 'react';
import { ConfirmDialog } from '../shared/ui/ConfirmDialog.jsx';
import {
createEducationForm,
deleteEducationForm,
fetchEducationForms
} from './settings-service.js';
export function EduFormsTab() {
const [forms, setForms] = useState(null);
const [loadError, setLoadError] = useState(false);
const [name, setName] = useState('');
const [alert, setAlert] = useState({ text: '', isError: false });
const [deleting, setDeleting] = useState(null);
useEffect(() => {
let active = true;
(async () => {
try {
const loaded = await fetchEducationForms();
if (!active) return;
setForms(loaded);
setLoadError(false);
} catch {
if (active) setLoadError(true);
}
})();
return () => {
active = false;
};
}, []);
async function reload() {
try {
setForms(await fetchEducationForms());
setLoadError(false);
} catch {
setLoadError(true);
}
}
async function submit(event) {
event.preventDefault();
const trimmed = name.trim();
if (!trimmed) {
setAlert({ text: 'Введите название', isError: true });
return;
}
try {
const created = await createEducationForm(trimmed);
setAlert({ text: `Форма «${created.name}» создана`, isError: false });
setName('');
await reload();
} catch (error) {
setAlert({ text: error.message || 'Ошибка создания', isError: true });
}
}
async function confirmDelete() {
const form = deleting;
setDeleting(null);
try {
await deleteEducationForm(form.id);
await reload();
} catch (error) {
setAlert({ text: error.message || 'Не удалось удалить форму обучения', isError: true });
}
}
return (
<>
<div className="card create-card">
<h2>Новая форма обучения</h2>
<form onSubmit={submit}>
<div className="form-row">
<div className="form-group">
<label htmlFor="new-ef-name">Название</label>
<input
type="text"
id="new-ef-name"
placeholder="Бакалавриат"
required
value={name}
onChange={event => setName(event.target.value)}
/>
</div>
<button type="submit" className="btn btn-md btn-primary">Создать</button>
</div>
<div
className={`form-alert${alert.text ? (alert.isError ? ' error' : ' success') : ''}`}
role="alert"
>
{alert.text}
</div>
</form>
</div>
<div className="card">
<h2>Все формы обучения</h2>
<div className="table-wrap">
<table>
<thead>
<tr>
<th>ID</th>
<th>Название</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
{loadError ? (
<tr><td colSpan="3" className="loading-row">Ошибка загрузки</td></tr>
) : forms === null ? (
<tr><td colSpan="3" className="loading-row">Загрузка...</td></tr>
) : forms.length === 0 ? (
<tr><td colSpan="3" className="loading-row">Нет форм обучения</td></tr>
) : forms.map(form => (
<tr key={form.id}>
<td>{form.id}</td>
<td>{form.name}</td>
<td>
<button
type="button"
className="btn btn-sm btn-danger"
onClick={() => setDeleting(form)}
>
Удалить
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{deleting && (
<ConfirmDialog
title="Удаление формы обучения"
message="Удалить форму обучения?"
confirmLabel="Удалить"
danger
onClose={() => setDeleting(null)}
onConfirm={() => void confirmDelete()}
/>
)}
</>
);
}

View File

@@ -0,0 +1,15 @@
export function GeneralTab() {
return (
<div className="settings-placeholder">
<div className="icon-wrap">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
</div>
<h2>Общие настройки</h2>
<p>Этот раздел находится в разработке. Здесь будут доступны общие настройки системы.</p>
</div>
);
}

View File

@@ -0,0 +1,209 @@
import { useEffect, useState } from 'react';
import { SETTINGS_ROLES } from '../../admin/js/role-capabilities.js';
import { logoutAndRedirect } from '../shared/auth-client.js';
import { SESSION_PHASES } from '../shared/session-policy.js';
import { useAuthorizedSession } from '../shared/use-authorized-session.js';
import { AsyncState } from '../shared/ui/AsyncState.jsx';
import { DatabaseTab } from './DatabaseTab.jsx';
import { EduFormsTab } from './EduFormsTab.jsx';
import { GeneralTab } from './GeneralTab.jsx';
import { TimeSlotsTab } from './TimeSlotsTab.jsx';
import {
defaultSettingsTabForRole,
settingsTabsForRole
} from './settings-service.js';
const THEME_STORAGE_KEY = 'theme';
const SIDEBAR_STORAGE_KEY = 'sidebar-collapsed';
const TAB_ICONS = Object.freeze({
database: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
strokeLinecap="round" strokeLinejoin="round">
<ellipse cx="12" cy="5" rx="9" ry="3" />
<path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3" />
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5" />
</svg>
),
'edu-forms': (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
strokeLinecap="round" strokeLinejoin="round">
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
<line x1="9" y1="7" x2="17" y2="7" />
<line x1="9" y1="11" x2="15" y2="11" />
</svg>
),
general: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
),
'time-slots': (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="9" />
<polyline points="12 7 12 12 16 14" />
<path d="M4 4l3 3" />
<path d="M20 4l-3 3" />
</svg>
)
});
export function SettingsApp() {
const sessionState = useAuthorizedSession(SETTINGS_ROLES);
useEffect(() => {
document.documentElement.dataset.theme = localStorage.getItem(THEME_STORAGE_KEY) || 'light';
}, []);
if (sessionState.phase === SESSION_PHASES.loading) {
return <SettingsShell title="Настройки"><AsyncState kind="loading" title="Настройки" message="Проверяем сессию…" /></SettingsShell>;
}
if (sessionState.phase !== SESSION_PHASES.ready) {
return <SettingsShell title="Настройки"><AsyncState kind="error" title="Раздел недоступен" message={sessionState.message} /></SettingsShell>;
}
return <SettingsWorkspace role={sessionState.session.role} />;
}
function SettingsShell({ title, sidebar, children }) {
const [collapsed, setCollapsed] = useState(() => (
typeof window !== 'undefined'
&& window.innerWidth > 768
&& localStorage.getItem(SIDEBAR_STORAGE_KEY) === 'true'
));
function toggleSidebar() {
setCollapsed(current => {
const next = !current;
localStorage.setItem(SIDEBAR_STORAGE_KEY, next);
return next;
});
}
function toggleTheme() {
const next = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';
document.documentElement.dataset.theme = next;
localStorage.setItem(THEME_STORAGE_KEY, next);
}
function handleLogout(event) {
event.preventDefault();
void logoutAndRedirect();
}
return (
<>
<aside className={`sidebar${collapsed ? ' collapsed' : ''}`}>
<div className="sidebar-header">
<div className="logo">
<svg width="32" height="32" viewBox="0 0 40 40" fill="none">
<rect width="40" height="40" rx="12" fill="url(#lg)" />
<path d="M12 20L18 26L28 14" stroke="#fff" strokeWidth="3" strokeLinecap="round"
strokeLinejoin="round" />
<defs>
<linearGradient id="lg" x1="0" y1="0" x2="40" y2="40">
<stop stopColor="#6366f1" />
<stop offset="1" stopColor="#8b5cf6" />
</linearGradient>
</defs>
</svg>
<span>Настройки</span>
</div>
<button
type="button"
className="btn btn-icon-sm btn-ghost sidebar-close-btn"
aria-label="Скрыть панель"
onClick={toggleSidebar}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
strokeLinecap="round" strokeLinejoin="round">
<polyline points="15 18 9 12 15 6" />
</svg>
</button>
</div>
{sidebar}
<div className="sidebar-footer">
<a href="/admin/" className="btn-back">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
strokeLinecap="round" strokeLinejoin="round">
<line x1="19" y1="12" x2="5" y2="12" />
<polyline points="12 19 5 12 12 5" />
</svg>
<span>Назад в панель</span>
</a>
<a href="/" className="btn-back" onClick={handleLogout}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
strokeLinecap="round" strokeLinejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<polyline points="16 17 21 12 16 7" />
<line x1="21" y1="12" x2="9" y2="12" />
</svg>
<span>Выйти</span>
</a>
</div>
</aside>
<div className="sidebar-overlay" />
<main className={`main${collapsed ? ' sidebar-collapsed' : ''}`}>
<header className="topbar">
<h1>{title}</h1>
<button
type="button"
className="btn btn-icon-md btn-secondary theme-toggle"
aria-label="Переключить тему"
onClick={toggleTheme}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M21 12.79A9 9 0 1 1 11.21 3a7 7 0 0 0 9.79 9.79z" />
</svg>
</button>
</header>
<section className="content">
{children}
</section>
</main>
</>
);
}
function SettingsWorkspace({ role }) {
const tabs = settingsTabsForRole(role);
const [activeTab, setActiveTab] = useState(() => defaultSettingsTabForRole(role) || tabs[0]?.id);
const active = tabs.find(tab => tab.id === activeTab) || tabs[0];
const sidebar = (
<nav className="sidebar-nav">
{tabs.map(tab => (
<a
key={tab.id}
href="#"
className={`nav-item${tab.id === active?.id ? ' active' : ''}`}
onClick={event => {
event.preventDefault();
setActiveTab(tab.id);
}}
>
{TAB_ICONS[tab.id]}
<span>{tab.title}</span>
</a>
))}
</nav>
);
return (
<SettingsShell sidebar={sidebar} title={active ? active.title : 'Настройки'}>
{active?.id === 'general' && <GeneralTab />}
{active?.id === 'time-slots' && <TimeSlotsTab />}
{active?.id === 'edu-forms' && <EduFormsTab />}
{active?.id === 'database' && <DatabaseTab />}
</SettingsShell>
);
}

View File

@@ -0,0 +1,466 @@
import { useEffect, useRef, useState } from 'react';
import { ConfirmDialog } from '../shared/ui/ConfirmDialog.jsx';
import {
buildSlotPayload,
createScope,
deleteScope,
deleteTimeSlot,
fetchScopes,
fetchTimeSlots,
isSystemScope,
resolveActiveScopeId,
saveTimeSlot,
scopeBadgeClass,
scopeBadgeLabel,
scopeOptionLabel,
scopeSummaryText,
slotDurationMinutes,
slotsForScope,
validateSlotPayload
} from './settings-service.js';
const EMPTY_SLOT_FORM = { endTime: '', id: '', orderNumber: '', startTime: '' };
export function TimeSlotsTab() {
const [loadState, setLoadState] = useState({ kind: 'loading', message: '' });
const [scopes, setScopes] = useState([]);
const [slots, setSlots] = useState([]);
const [activeScopeId, setActiveScopeId] = useState(null);
const [slotForm, setSlotForm] = useState(EMPTY_SLOT_FORM);
const [alert, setAlert] = useState({ text: '', isError: false });
const [scopeModalOpen, setScopeModalOpen] = useState(false);
const [scopeName, setScopeName] = useState('');
const [scopeModalAlert, setScopeModalAlert] = useState({ text: '', isError: false });
const [scopeDeleting, setScopeDeleting] = useState(false);
const [slotDeleting, setSlotDeleting] = useState(null);
const formRef = useRef(null);
const scopeNameRef = useRef(null);
async function loadAll(preferredScopeId) {
setLoadState({ kind: 'loading', message: '' });
try {
const [loadedScopes, loadedSlots] = await Promise.all([
fetchScopes(),
fetchTimeSlots()
]);
setScopes(loadedScopes);
setSlots(loadedSlots);
setActiveScopeId(current => resolveActiveScopeId(
loadedScopes,
preferredScopeId !== undefined ? preferredScopeId : current
));
setLoadState({ kind: 'ready', message: '' });
} catch (error) {
setLoadState({ kind: 'error', message: error.message || 'Ошибка загрузки' });
}
}
useEffect(() => {
void loadAll();
}, []);
useEffect(() => {
if (scopeModalOpen) {
setScopeName('');
setScopeModalAlert({ text: '', isError: false });
window.setTimeout(() => scopeNameRef.current?.focus(), 0);
}
}, [scopeModalOpen]);
const activeScope = scopes.find(scope => scope.id === activeScopeId) || null;
const activeSlots = activeScopeId === null ? [] : slotsForScope(slots, activeScopeId);
const duration = slotDurationMinutes(slotForm.startTime, slotForm.endTime);
const editing = Boolean(slotForm.id);
function resetSlotForm() {
setSlotForm(EMPTY_SLOT_FORM);
setAlert({ text: '', isError: false });
}
function updateSlotField(field, value) {
setSlotForm(current => ({ ...current, [field]: value }));
}
async function submitSlot(event) {
event.preventDefault();
const payload = buildSlotPayload({
endTime: slotForm.endTime,
id: slotForm.id,
orderNumber: slotForm.orderNumber,
scopeId: activeScopeId,
startTime: slotForm.startTime
});
const validation = validateSlotPayload(payload);
if (validation) {
setAlert({ text: validation, isError: true });
return;
}
try {
await saveTimeSlot(payload);
setAlert({ text: 'Временной слот сохранён', isError: false });
resetSlotForm();
await loadAll();
} catch (error) {
setAlert({ text: error.message || 'Ошибка сохранения временного слота', isError: true });
}
}
function editSlot(slot) {
setActiveScopeId(slot.scopeId);
setSlotForm({
endTime: String(slot.endTime || '').slice(0, 5),
id: String(slot.id),
orderNumber: slot.orderNumber || '',
startTime: String(slot.startTime || '').slice(0, 5)
});
setAlert({ text: '', isError: false });
window.setTimeout(() => formRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 0);
}
async function confirmDeleteSlot() {
const slot = slotDeleting;
setSlotDeleting(null);
try {
await deleteTimeSlot(slot.id);
setAlert({ text: 'Временной слот удалён', isError: false });
resetSlotForm();
await loadAll();
} catch (error) {
setAlert({ text: error.message || 'Ошибка удаления временного слота', isError: true });
}
}
async function submitScope(event) {
event.preventDefault();
const name = scopeName.trim();
if (!name) {
setScopeModalAlert({ text: 'Введите название ручной сетки', isError: true });
return;
}
try {
const saved = await createScope(name);
setScopeModalOpen(false);
setAlert({ text: 'Сетка времени создана', isError: false });
await loadAll(saved.id);
} catch (error) {
setScopeModalAlert({ text: error.message || 'Ошибка создания сетки времени', isError: true });
}
}
async function confirmDeleteScope() {
const scope = activeScope;
setScopeDeleting(false);
if (!scope || isSystemScope(scope)) return;
try {
await deleteScope(scope.id);
resetSlotForm();
setAlert({ text: 'Сетка времени удалена', isError: false });
await loadAll();
} catch (error) {
setAlert({ text: error.message || 'Ошибка удаления сетки времени', isError: true });
}
}
return (
<section className="settings-section">
<div className="settings-header-row">
<div>
<h2>Временные слоты</h2>
<p>Настройка базовой, субботней и ручных сеток времени.</p>
</div>
<button
type="button"
className="btn btn-md btn-secondary"
onClick={() => void loadAll()}
>
Обновить
</button>
</div>
{loadState.kind === 'error' ? (
<article className="settings-card">
<div className="loading-row">{loadState.message}</div>
</article>
) : (
<>
<div className="settings-grid">
<article className="settings-card">
<div className="card-header-row">
<h3>Сетка времени</h3>
<div className="scope-header-actions">
<button
type="button"
className="btn btn-md btn-primary"
onClick={() => setScopeModalOpen(true)}
>
Новая ручная сетка
</button>
<button
type="button"
className="btn btn-md btn-danger-subtle"
disabled={!activeScope || isSystemScope(activeScope)}
onClick={() => setScopeDeleting(true)}
>
Удалить сетку
</button>
</div>
</div>
<div className="settings-form settings-form-single">
<label className="form-field" htmlFor="time-slot-scope-select">
<span>Область действия</span>
<select
id="time-slot-scope-select"
className="native-select-control"
value={activeScopeId === null ? '' : String(activeScopeId)}
onChange={event => {
setActiveScopeId(Number(event.target.value));
resetSlotForm();
}}
>
{scopes.map(scope => (
<option key={scope.id} value={scope.id}>
{scopeOptionLabel(scope)}
</option>
))}
</select>
</label>
</div>
<div className="scope-summary">
{activeScope ? (
<>
<span className={scopeBadgeClass(activeScope)}>
{scopeBadgeLabel(activeScope)}
</span>
<span>{scopeSummaryText(activeScope, slots)}</span>
</>
) : (
<span className="muted">Сетки времени не настроены</span>
)}
</div>
</article>
<article className="settings-card settings-card-compact">
<h3>Как применяется</h3>
<div className="scope-summary">
<span className="scope-badge scope-badge-base">Базовая</span>
<span>Используется по умолчанию для всех дат.</span>
</div>
<div className="scope-summary">
<span className="scope-badge">Сб</span>
<span>Субботняя сетка применяется автоматически по субботам.</span>
</div>
<div className="scope-summary">
<span className="scope-badge">Ручная</span>
<span>Ручные сетки назначаются в ячейке редактора календарного графика.</span>
</div>
</article>
</div>
<article className="settings-card" ref={formRef}>
<div className="card-header-row">
<h3>{editing ? 'Редактирование слота' : 'Новый слот'}</h3>
<button type="button" className="btn btn-md btn-ghost" onClick={resetSlotForm}>
Очистить
</button>
</div>
<form className="settings-form" noValidate onSubmit={submitSlot}>
<label className="form-field" htmlFor="time-slot-order">
<span>Пара</span>
<input
type="number"
id="time-slot-order"
min="1"
step="1"
required
value={slotForm.orderNumber}
onChange={event => updateSlotField('orderNumber', event.target.value)}
/>
</label>
<label className="form-field" htmlFor="time-slot-start">
<span>Начало</span>
<input
type="time"
id="time-slot-start"
required
value={slotForm.startTime}
onChange={event => updateSlotField('startTime', event.target.value)}
/>
</label>
<label className="form-field" htmlFor="time-slot-end">
<span>Окончание</span>
<input
type="time"
id="time-slot-end"
required
value={slotForm.endTime}
onChange={event => updateSlotField('endTime', event.target.value)}
/>
</label>
<label className="form-field" htmlFor="time-slot-duration">
<span>Минут</span>
<input
type="number"
id="time-slot-duration"
min="1"
step="1"
readOnly
aria-describedby="time-slot-duration-hint"
value={duration === null ? '' : String(duration)}
/>
<small id="time-slot-duration-hint">Рассчитывается по времени начала и окончания</small>
</label>
<button type="submit" className="btn btn-md btn-primary">Сохранить слот</button>
<div
className={`form-alert${alert.text ? (alert.isError ? ' error' : ' success') : ''}`}
role="alert"
>
{alert.text}
</div>
</form>
</article>
<article className="settings-card">
<div className="table-wrap">
<table className="settings-table">
<thead>
<tr>
<th>Пара</th>
<th>Начало</th>
<th>Окончание</th>
<th>Минут</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
{loadState.kind === 'loading' ? (
<tr><td colSpan="5" className="loading-row">Загрузка...</td></tr>
) : activeSlots.length === 0 ? (
<tr><td colSpan="5" className="loading-row">В выбранной сетке нет слотов</td></tr>
) : activeSlots.map(slot => (
<tr key={slot.id}>
<td>{slot.orderNumber ?? '-'}</td>
<td>{String(slot.startTime || '').slice(0, 5)}</td>
<td>{String(slot.endTime || '').slice(0, 5)}</td>
<td>{slot.durationMinutes ?? '-'}</td>
<td className="actions-cell">
<button
type="button"
className="btn btn-sm btn-secondary"
onClick={() => editSlot(slot)}
>
Изменить
</button>
<button
type="button"
className="btn btn-sm btn-danger"
onClick={() => setSlotDeleting(slot)}
>
Удалить
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</article>
</>
)}
{scopeModalOpen && (
<div className="settings-modal-overlay open" aria-hidden="false">
<div
className="settings-modal-card"
role="dialog"
aria-modal="true"
aria-labelledby="time-slot-scope-modal-title"
>
<div className="card-header-row">
<div>
<h3 id="time-slot-scope-modal-title">Новая ручная сетка</h3>
<p className="settings-modal-subtitle">
Создайте отдельную сетку времени, которую затем можно назначать датам в календарном графике.
</p>
</div>
<button
type="button"
className="btn btn-icon-md btn-ghost settings-modal-close"
aria-label="Закрыть окно"
onClick={() => setScopeModalOpen(false)}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
<form className="settings-modal-form" noValidate onSubmit={submitScope}>
<label className="form-field" htmlFor="time-slot-scope-name">
<span>Название сетки</span>
<input
ref={scopeNameRef}
type="text"
id="time-slot-scope-name"
placeholder="Например: праздничная"
required
value={scopeName}
onChange={event => setScopeName(event.target.value)}
/>
</label>
<div className="settings-modal-actions">
<button type="submit" className="btn btn-md btn-primary">Создать сетку</button>
<button
type="button"
className="btn btn-md btn-ghost"
onClick={() => setScopeModalOpen(false)}
>
Отмена
</button>
</div>
<div
className={`form-alert${scopeModalAlert.text ? (scopeModalAlert.isError ? ' error' : ' success') : ''}`}
role="alert"
>
{scopeModalAlert.text}
</div>
</form>
</div>
</div>
)}
{scopeDeleting && activeScope && (
<ConfirmDialog
title="Удаление сетки времени"
message={`Удалить сетку «${activeScope.name}» вместе с её слотами и ручными назначениями?`}
confirmLabel="Удалить сетку"
danger
onClose={() => setScopeDeleting(false)}
onConfirm={() => void confirmDeleteScope()}
/>
)}
{slotDeleting && (
<ConfirmDialog
title="Удаление временного слота"
message="Удалить временной слот?"
confirmLabel="Удалить"
danger
onClose={() => setSlotDeleting(null)}
onConfirm={() => void confirmDeleteSlot()}
/>
)}
</section>
);
}

View File

@@ -0,0 +1,22 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { initializeTelemetry } from '../../telemetry.js';
import { AppErrorBoundary } from '../shared/ui/AppErrorBoundary.jsx';
import { SettingsApp } from './SettingsApp.jsx';
void initializeTelemetry();
const rootElement = document.getElementById('settings-root');
if (!rootElement) {
throw new Error('Не найден корневой элемент страницы настроек');
}
createRoot(rootElement).render(
<StrictMode>
<AppErrorBoundary>
<SettingsApp />
</AppErrorBoundary>
</StrictMode>
);

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: 'Не удалось удалить тенант' }
);
}

View File

@@ -0,0 +1,28 @@
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { ConfirmDialog } from '../../shared/ui/ConfirmDialog.jsx';
import { DatabaseTab } from '../DatabaseTab.jsx';
import { EduFormsTab } from '../EduFormsTab.jsx';
import { GeneralTab } from '../GeneralTab.jsx';
import { TimeSlotsTab } from '../TimeSlotsTab.jsx';
export function renderGeneralTab() {
return renderToStaticMarkup(createElement(GeneralTab));
}
export function renderTimeSlotsTab() {
return renderToStaticMarkup(createElement(TimeSlotsTab));
}
export function renderEduFormsTab() {
return renderToStaticMarkup(createElement(EduFormsTab));
}
export function renderDatabaseTab() {
return renderToStaticMarkup(createElement(DatabaseTab));
}
export function renderConfirmDialog(props) {
return renderToStaticMarkup(createElement(ConfirmDialog, props));
}