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

@@ -1,3 +1,13 @@
/* ===== Корневой контейнер ===== */
/* body — flex-контейнер: без явного растяжения корневой div сжимается
до max-content, и узкие страницы не занимают всю ширину монитора.
Растягиваем корень ровно на ширину окна (flex-basis 0), а не под контент. */
#settings-root {
flex: 1 1 0%;
min-width: 0;
}
/* ===== Sidebar ===== */
.sidebar {
width: 260px;

View File

@@ -360,6 +360,46 @@ body {
padding: 1.25rem !important;
}
/* Карточка статуса текущего подключения к БД (замена inline-стилей legacy) */
.db-status-card {
display: flex;
align-items: flex-start;
}
.db-status-grid {
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
width: 100%;
}
.db-status-grid > div {
display: flex;
align-items: center;
gap: 0.4rem;
flex-wrap: wrap;
}
.db-status-grid span {
color: var(--text-secondary);
font-size: 0.85rem;
}
.db-status-grid code {
font-size: 0.85rem;
}
/* Широкое поле формы и выравнивание кнопок действий в строке формы */
.form-group-wide {
flex: 3;
}
.form-row-actions {
height: fit-content;
}
.scope-badge {
display: inline-flex;
align-items: center;

View File

@@ -12,95 +12,9 @@
</head>
<body>
<!-- Боковая панель -->
<aside class="sidebar">
<div class="sidebar-header">
<div class="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" stroke-width="3" stroke-linecap="round"
stroke-linejoin="round" />
<defs>
<linearGradient id="lg" x1="0" y1="0" x2="40" y2="40">
<stop stop-color="#6366f1" />
<stop offset="1" stop-color="#8b5cf6" />
</linearGradient>
</defs>
</svg>
<span>Настройки</span>
</div>
<button class="btn btn-icon-sm btn-ghost sidebar-close-btn" id="sidebar-close-btn" aria-label="Скрыть панель">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="15 18 9 12 15 6" />
</svg>
</button>
</div>
<nav class="sidebar-nav">
<a href="#" class="nav-item" data-tab="general">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="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>
<span>Общие настройки</span>
</a>
<a href="#" class="nav-item" data-tab="time-slots">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="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>
<span>Временные слоты</span>
</a>
<a href="#" class="nav-item" data-tab="edu-forms">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="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>
<span>Формы обучения</span>
</a>
<a href="#" class="nav-item" data-tab="database">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<ellipse cx="12" cy="5" rx="9" ry="3"></ellipse>
<path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"></path>
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"></path>
</svg>
<span>База данных</span>
</a>
</nav>
<div class="sidebar-footer">
<a href="/admin/" class="btn-back">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<line x1="19" y1="12" x2="5" y2="12" />
<polyline points="12 19 5 12 12 5" />
</svg>
<span>Назад в панель</span>
</a>
</div>
</aside>
<div id="settings-root"></div>
<!-- Затемнение боковой панели на мобильных -->
<div class="sidebar-overlay" id="sidebar-overlay"></div>
<!-- Основная область -->
<main class="main">
<header class="topbar">
<h1 id="page-title">Загрузка...</h1>
</header>
<section class="content" id="app-content">
<!-- Контент загружается через JS -->
</section>
</main>
<script src="/theme-toggle.js"></script>
<script type="module" src="js/main.js"></script>
<script type="module" src="/react/settings.js"></script>
</body>
</html>

View File

@@ -1,133 +0,0 @@
// Основной модуль страницы настроек
import { startDropdownAutoObserver, initAllCustomDropdowns } from '../../js/dropdown.js';
import { renderStatusMessage } from '../../js/utils.js';
import { SETTINGS_ROLES, capabilitiesForRole } from '../../js/role-capabilities.js';
import { initializeSession } from '../../js/api.js';
async function bootstrap() {
const session = await initializeSession();
if (!session || !SETTINGS_ROLES.includes(session.role)) {
window.location.replace('/');
return;
}
const role = session.role;
// Глобальная инициализация кастомных списков
document.addEventListener('DOMContentLoaded', () => {
initAllCustomDropdowns(document.body);
startDropdownAutoObserver();
});
// Настройка вкладок
const ROUTES = {
general: { title: 'Общие настройки', file: 'views/general.html' },
'time-slots': {
title: 'Временные слоты',
file: 'views/time-slots.html',
init: async () => {
const module = await import('./views/time-slots.js');
await module.initTimeSlotsSettings();
}
},
database: {
title: 'База данных',
file: 'views/database.html',
init: async () => {
const module = await import('./views/database.js');
await module.initDatabase();
}
},
'edu-forms': {
title: 'Формы обучения',
file: 'views/edu-forms.html',
init: async () => {
const module = await import('./views/edu-forms.js');
await module.initEduForms();
}
},
};
const roleCapabilities = capabilitiesForRole(role);
const allowedTabs = new Set(roleCapabilities.settingsTabs);
const defaultTab = roleCapabilities.defaultSettingsTab;
let currentTab = null;
// DOM-элементы
const appContent = document.getElementById('app-content');
const pageTitle = document.getElementById('page-title');
const navItems = document.querySelectorAll('.nav-item[data-tab]');
const sidebar = document.querySelector('.sidebar');
const sidebarOverlay = document.getElementById('sidebar-overlay');
const sidebarCloseBtn = document.getElementById('sidebar-close-btn');
const main = document.querySelector('.main');
// Состояние боковой панели из localStorage
if (window.innerWidth > 768 && localStorage.getItem('sidebar-collapsed') === 'true') {
sidebar.classList.add('collapsed');
main.classList.add('sidebar-collapsed');
}
// Сворачивание боковой панели
sidebarCloseBtn?.addEventListener('click', () => {
if (window.innerWidth <= 768) {
sidebar.classList.remove('open');
sidebarOverlay.classList.remove('open');
} else {
sidebar.classList.toggle('collapsed');
main.classList.toggle('sidebar-collapsed');
localStorage.setItem('sidebar-collapsed', sidebar.classList.contains('collapsed'));
}
});
sidebarOverlay.addEventListener('click', () => {
sidebar.classList.remove('open');
sidebarOverlay.classList.remove('open');
});
// Навигация
navItems.forEach(item => {
item.hidden = !allowedTabs.has(item.dataset.tab);
item.addEventListener('click', (e) => {
e.preventDefault();
const tab = item.dataset.tab;
switchTab(tab);
});
});
async function switchTab(tab) {
if (currentTab === tab || !ROUTES[tab] || !allowedTabs.has(tab)) return;
navItems.forEach(n => n.classList.remove('active'));
document.querySelector(`.nav-item[data-tab="${tab}"]`)?.classList.add('active');
pageTitle.textContent = ROUTES[tab].title;
try {
appContent.innerHTML = '<div style="text-align:center;color:var(--text-secondary);padding:2rem;">Загрузка...</div>';
const response = await fetch(ROUTES[tab].file);
if (!response.ok) throw new Error('Не удалось загрузить вкладку');
const html = await response.text();
appContent.innerHTML = html;
initAllCustomDropdowns(appContent);
if (ROUTES[tab].init) {
await ROUTES[tab].init();
}
currentTab = tab;
} catch (e) {
renderStatusMessage(appContent, 'Не удалось загрузить раздел настроек');
console.error('Не удалось загрузить раздел настроек');
}
// Закрываем мобильное меню после перехода
sidebar.classList.remove('open');
sidebarOverlay.classList.remove('open');
}
// Вкладка по умолчанию
switchTab(defaultTab);
}
await bootstrap();

View File

@@ -1,163 +0,0 @@
import { api } from '../../../js/api.js';
import { escapeHtml, showAlert, hideAlert, renderStatusMessage, renderTableMessage } from '../../../js/utils.js';
import { alertAction, confirmAction } from '../../../js/dialog.js';
export async function initDatabase() {
const tenantsTbody = document.getElementById('tenants-tbody');
const addTenantForm = document.getElementById('add-tenant-form');
const statusInfo = document.getElementById('db-status-info');
const btnTest = document.getElementById('btn-test-connection');
// === Загрузка статуса текущего подключения ===
async function loadStatus() {
try {
const data = await api.get('/api/database/status');
const statusBadge = data.connected
? '<span class="badge badge-available">Доступно</span>'
: '<span class="badge badge-unavailable">Недоступно</span>';
statusInfo.innerHTML = `
<div style="display: flex; align-items: center; gap: 1rem; flex-wrap: wrap;">
<div>
<span style="color: var(--text-secondary); font-size: 0.85rem;">Тенант:</span>
<strong>${escapeHtml(data.tenant || '—')}</strong>
</div>
<div>
<span style="color: var(--text-secondary); font-size: 0.85rem;">Название:</span>
<strong>${escapeHtml(data.name || '—')}</strong>
</div>
<div>
<span style="color: var(--text-secondary); font-size: 0.85rem;">Статус:</span>
${statusBadge}
</div>
${data.url ? `<div>
<span style="color: var(--text-secondary); font-size: 0.85rem;">URL:</span>
<code style="font-size: 0.85rem;">${escapeHtml(data.url)}</code>
</div>` : ''}
</div>
`;
} catch (e) {
renderStatusMessage(statusInfo, 'Не удалось загрузить статус подключения');
}
}
// === Загрузка списка тенантов ===
async function loadTenants() {
try {
const tenants = await api.get('/api/database/tenants');
renderTenantsTable(tenants);
} catch (e) {
renderTableMessage(tenantsTbody, 6, 'Не удалось загрузить список тенантов');
}
}
function renderTenantsTable(tenants) {
if (!tenants || !tenants.length) {
tenantsTbody.innerHTML = '<tr><td colspan="6" class="loading-row">Нет подключённых тенантов</td></tr>';
return;
}
tenantsTbody.innerHTML = tenants.map(t => {
const statusBadge = t.connected
? '<span class="badge badge-available">Доступно</span>'
: '<span class="badge badge-unavailable">Недоступно</span>';
return `
<tr>
<td>${escapeHtml(t.name || '—')}</td>
<td><code>${escapeHtml(t.domain)}</code></td>
<td><code style="font-size: 0.82rem;">${escapeHtml(t.url)}</code></td>
<td>${escapeHtml(t.username || '—')}</td>
<td>${statusBadge}</td>
<td><button class="btn btn-sm btn-danger btn-delete-tenant" data-domain="${escapeHtml(t.domain)}">Удалить</button></td>
</tr>`;
}).join('');
}
// === Тест подключения ===
btnTest.addEventListener('click', async () => {
hideAlert('add-tenant-alert');
const url = document.getElementById('tenant-url').value.trim();
const username = document.getElementById('tenant-username').value.trim();
const password = document.getElementById('tenant-password').value;
if (!url) {
showAlert('add-tenant-alert', 'Введите JDBC URL', 'error');
return;
}
btnTest.textContent = '...';
btnTest.disabled = true;
try {
const result = await api.post('/api/database/test', { url, username, password });
if (result.success) {
showAlert('add-tenant-alert', '✓ Подключение успешно!', 'success');
} else {
showAlert('add-tenant-alert', `✗ ${result.message}`, 'error');
}
} catch (e) {
showAlert('add-tenant-alert', 'Не удалось проверить подключение', 'error');
} finally {
btnTest.textContent = 'Тест';
btnTest.disabled = false;
}
});
// === Добавление тенанта ===
addTenantForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideAlert('add-tenant-alert');
const name = document.getElementById('tenant-name').value.trim();
const domain = document.getElementById('tenant-domain').value.trim().toLowerCase();
const url = document.getElementById('tenant-url').value.trim();
const username = document.getElementById('tenant-username').value.trim();
const password = document.getElementById('tenant-password').value;
if (!name || !domain || !url) {
showAlert('add-tenant-alert', 'Заполните все обязательные поля', 'error');
return;
}
try {
const result = await api.post('/api/database/tenants', { name, domain, url, username, password });
if (result.success) {
showAlert('add-tenant-alert', `Тенант «${domain}» добавлен`, 'success');
addTenantForm.reset();
loadTenants();
loadStatus();
} else {
showAlert('add-tenant-alert', result.message, 'error');
}
} catch (e) {
showAlert('add-tenant-alert', 'Не удалось добавить тенант', 'error');
}
});
// === Удаление тенанта ===
tenantsTbody.addEventListener('click', async (e) => {
const btn = e.target.closest('.btn-delete-tenant');
if (!btn) return;
const domain = btn.dataset.domain;
if (!await confirmAction({
title: 'Удаление тенанта',
message: `Удалить тенант «${domain}»? Пул соединений будет закрыт.`,
confirmLabel: 'Удалить тенант',
danger: true
})) return;
try {
await api.delete(`/api/database/tenants/${domain}`);
loadTenants();
loadStatus();
} catch (e) {
await alertAction({ title: 'Ошибка удаления', message: e.message || 'Не удалось удалить тенант' });
}
});
// === Init ===
loadStatus();
loadTenants();
}

View File

@@ -1,72 +0,0 @@
import { api } from '../../../js/api.js';
import { escapeHtml, showAlert, hideAlert } from '../../../js/utils.js';
import { alertAction, confirmAction } from '../../../js/dialog.js';
export let allEducationForms = [];
export async function fetchEducationForms() {
try {
allEducationForms = await api.get('/api/education-forms');
return allEducationForms;
} catch (e) {
console.error('Не удалось загрузить формы обучения');
return [];
}
}
export async function initEduForms() {
const efTbody = document.getElementById('ef-tbody');
const createEfForm = document.getElementById('create-ef-form');
async function loadEF() {
try {
const forms = await fetchEducationForms();
renderEfTable(forms);
} catch (e) {
efTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Ошибка загрузки</td></tr>';
}
}
function renderEfTable(forms) {
if (!forms || !forms.length) {
efTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Нет форм обучения</td></tr>';
return;
}
efTbody.innerHTML = forms.map(ef => `
<tr>
<td>${ef.id}</td>
<td>${escapeHtml(ef.name)}</td>
<td><button class="btn btn-sm btn-danger btn-delete-edu-form" data-id="${ef.id}">Удалить</button></td>
</tr>`).join('');
}
createEfForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideAlert('create-ef-alert');
const name = document.getElementById('new-ef-name').value.trim();
if (!name) { showAlert('create-ef-alert', 'Введите название', 'error'); return; }
try {
const data = await api.post('/api/education-forms', { name });
showAlert('create-ef-alert', `Форма "${escapeHtml(data.name)}" создана`, 'success');
createEfForm.reset();
loadEF();
} catch (e) {
showAlert('create-ef-alert', e.message || 'Ошибка создания', 'error');
}
});
efTbody.addEventListener('click', async (e) => {
const btn = e.target.closest('.btn-delete-edu-form');
if (!btn) return;
if (!await confirmAction({ title: 'Удаление формы обучения', message: 'Удалить форму обучения?', confirmLabel: 'Удалить', danger: true })) return;
try {
await api.delete('/api/education-forms/' + btn.dataset.id);
loadEF();
} catch (e) {
await alertAction({ title: 'Ошибка удаления', message: e.message || 'Не удалось удалить форму обучения' });
}
});
loadEF();
}

View File

@@ -1,323 +0,0 @@
import { api } from '../../../js/api.js';
import { escapeHtml, hideAlert, showAlert } from '../../../js/utils.js';
import { confirmAction } from '../../../js/dialog.js';
const APPLY_MODE_LABELS = {
DEFAULT: 'применяется по умолчанию',
WEEKDAY: 'применяется автоматически',
MANUAL: 'применяется вручную'
};
const DAY_SHORT_LABELS = {
1: 'Пн',
2: 'Вт',
3: 'Ср',
4: 'Чт',
5: 'Пт',
6: 'Сб',
7: 'Вс'
};
export async function initTimeSlotsSettings() {
const scopeSelect = document.getElementById('time-slot-scope-select');
const scopeSummary = document.getElementById('time-slot-scope-summary');
const scopeOpenCreateButton = document.getElementById('time-slot-scope-open-create');
const scopeModal = document.getElementById('time-slot-scope-modal');
const scopeModalCloseButton = document.getElementById('time-slot-scope-modal-close');
const scopeModalCancelButton = document.getElementById('time-slot-scope-modal-cancel');
const scopeForm = document.getElementById('time-slot-scope-form');
const scopeNameInput = document.getElementById('time-slot-scope-name');
const scopeDeleteButton = document.getElementById('time-slot-scope-delete');
const form = document.getElementById('time-slot-form');
const title = document.getElementById('time-slot-form-title');
const idInput = document.getElementById('time-slot-id');
const orderInput = document.getElementById('time-slot-order');
const startInput = document.getElementById('time-slot-start');
const endInput = document.getElementById('time-slot-end');
const durationInput = document.getElementById('time-slot-duration');
const resetButton = document.getElementById('time-slot-reset');
const refreshButton = document.getElementById('time-slots-refresh');
const tbody = document.getElementById('time-slots-tbody');
let scopes = [];
let slots = [];
let activeScopeId = null;
scopeSelect?.addEventListener('change', () => {
activeScopeId = Number(scopeSelect.value);
resetForm();
renderActiveScope();
});
scopeOpenCreateButton?.addEventListener('click', openScopeModal);
scopeModalCloseButton?.addEventListener('click', closeScopeModal);
scopeModalCancelButton?.addEventListener('click', closeScopeModal);
scopeModal?.addEventListener('click', (event) => {
if (event.target === scopeModal) closeScopeModal();
});
scopeModal?.addEventListener('keydown', (event) => {
if (event.key === 'Escape') closeScopeModal();
});
scopeForm?.addEventListener('submit', createScope);
scopeDeleteButton?.addEventListener('click', deleteActiveScope);
form?.addEventListener('submit', saveSlot);
resetButton?.addEventListener('click', resetForm);
refreshButton?.addEventListener('click', loadAll);
startInput?.addEventListener('change', calculateDuration);
endInput?.addEventListener('change', calculateDuration);
tbody?.addEventListener('click', handleSlotTableClick);
await loadAll();
async function loadAll() {
tbody.innerHTML = '<tr><td colspan="5" class="loading-row">Загрузка...</td></tr>';
hideAlert('time-slot-alert');
try {
[scopes, slots] = await Promise.all([
api.get('/api/admin/time-slots/scopes'),
api.get('/api/admin/time-slots')
]);
if (!activeScopeId || !scopes.some(scope => scope.id === activeScopeId)) {
activeScopeId = defaultScope()?.id || scopes[0]?.id || null;
}
render();
} catch (error) {
tbody.innerHTML = `<tr><td colspan="5" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
}
}
function render() {
renderScopeSelects();
renderActiveScope();
}
function renderActiveScope() {
updateScopeActions();
renderScopeSummary();
renderSlots();
}
function renderScopeSelects() {
scopeSelect.innerHTML = scopes.map(scope => `
<option value="${scope.id}">${escapeHtml(scopeOptionLabel(scope))}</option>
`).join('');
scopeSelect.value = activeScopeId ? String(activeScopeId) : '';
updateScopeActions();
}
function updateScopeActions() {
const activeScope = selectedScope();
scopeDeleteButton.disabled = !activeScope || activeScope.systemScope;
}
function renderScopeSummary() {
const scope = selectedScope();
if (!scope) {
scopeSummary.innerHTML = '<span class="muted">Сетки времени не настроены</span>';
return;
}
const mode = APPLY_MODE_LABELS[scope.applyMode] || scope.applyMode;
const day = scope.applyMode === 'WEEKDAY' ? `, ${dayShort(scope.dayOfWeek)}` : '';
const slotCount = slots.filter(slot => slot.scopeId === scope.id).length;
scopeSummary.innerHTML = `
<span class="scope-badge ${scope.applyMode === 'DEFAULT' ? 'scope-badge-base' : ''}">${escapeHtml(scopeBadgeLabel(scope))}</span>
<span>${escapeHtml(mode + day)}. Слотов: ${slotCount}.</span>
`;
}
function renderSlots() {
const scopeSlots = slots
.filter(slot => slot.scopeId === activeScopeId)
.sort((a, b) => (a.orderNumber || 0) - (b.orderNumber || 0));
if (!scopeSlots.length) {
tbody.innerHTML = '<tr><td colspan="5" class="loading-row">В выбранной сетке нет слотов</td></tr>';
return;
}
tbody.innerHTML = scopeSlots.map(slot => `
<tr>
<td>${escapeHtml(String(slot.orderNumber ?? '-'))}</td>
<td>${escapeHtml(trimTime(slot.startTime))}</td>
<td>${escapeHtml(trimTime(slot.endTime))}</td>
<td>${escapeHtml(String(slot.durationMinutes ?? '-'))}</td>
<td class="actions-cell">
<button type="button" class="btn btn-sm btn-secondary btn-edit-time-slot" data-id="${slot.id}">Изменить</button>
<button type="button" class="btn btn-sm btn-danger btn-delete-time-slot" data-id="${slot.id}">Удалить</button>
</td>
</tr>
`).join('');
}
function openScopeModal() {
scopeForm.reset();
hideAlert('time-slot-scope-alert');
scopeModal.classList.add('open');
scopeModal.setAttribute('aria-hidden', 'false');
window.setTimeout(() => scopeNameInput?.focus(), 0);
}
function closeScopeModal() {
scopeModal.classList.remove('open');
scopeModal.setAttribute('aria-hidden', 'true');
hideAlert('time-slot-scope-alert');
}
async function createScope(event) {
event.preventDefault();
const name = scopeNameInput.value.trim();
if (!name) {
showAlert('time-slot-scope-alert', 'Введите название ручной сетки', 'error');
return;
}
try {
const saved = await api.post('/api/admin/time-slots/scopes', { name });
activeScopeId = saved.id;
closeScopeModal();
showAlert('time-slot-alert', 'Сетка времени создана', 'success');
await loadAll();
} catch (error) {
showAlert('time-slot-scope-alert', error.message || 'Ошибка создания сетки времени', 'error');
}
}
async function deleteActiveScope() {
const scope = selectedScope();
if (!scope || scope.systemScope) return;
if (!await confirmAction({
title: 'Удаление сетки времени',
message: `Удалить сетку «${scope.name}» вместе с её слотами и ручными назначениями?`,
confirmLabel: 'Удалить сетку',
danger: true
})) return;
try {
await api.delete('/api/admin/time-slots/scopes/' + scope.id);
activeScopeId = defaultScope()?.id || null;
resetForm();
showAlert('time-slot-alert', 'Сетка времени удалена', 'success');
await loadAll();
} catch (error) {
showAlert('time-slot-alert', error.message || 'Ошибка удаления сетки времени', 'error');
}
}
async function saveSlot(event) {
event.preventDefault();
hideAlert('time-slot-alert');
const payload = {
id: idInput.value ? Number(idInput.value) : null,
orderNumber: Number(orderInput.value),
scopeId: activeScopeId,
startTime: startInput.value,
endTime: endInput.value
};
try {
validateSlotPayload(payload);
const id = idInput.value;
await (id
? api.put('/api/admin/time-slots/' + id, payload)
: api.post('/api/admin/time-slots', payload));
showAlert('time-slot-alert', 'Временной слот сохранён', 'success');
resetForm();
await loadAll();
} catch (error) {
showAlert('time-slot-alert', error.message || 'Ошибка сохранения временного слота', 'error');
}
}
async function handleSlotTableClick(event) {
const editButton = event.target.closest('.btn-edit-time-slot');
const deleteButton = event.target.closest('.btn-delete-time-slot');
if (editButton) {
const slot = slots.find(item => String(item.id) === String(editButton.dataset.id));
if (!slot) return;
activeScopeId = slot.scopeId;
title.textContent = 'Редактирование слота';
idInput.value = slot.id;
orderInput.value = slot.orderNumber || '';
startInput.value = trimTime(slot.startTime);
endInput.value = trimTime(slot.endTime);
durationInput.value = slot.durationMinutes || '';
hideAlert('time-slot-alert');
render();
form.scrollIntoView({ behavior: 'smooth', block: 'start' });
return;
}
if (deleteButton) {
if (!await confirmAction({ title: 'Удаление временного слота', message: 'Удалить временной слот?', confirmLabel: 'Удалить', danger: true })) return;
try {
await api.delete('/api/admin/time-slots/' + deleteButton.dataset.id);
showAlert('time-slot-alert', 'Временной слот удалён', 'success');
resetForm();
await loadAll();
} catch (error) {
showAlert('time-slot-alert', error.message || 'Ошибка удаления временного слота', 'error');
}
}
}
function validateSlotPayload(payload) {
if (!payload.scopeId) {
throw new Error('Выберите сетку времени');
}
if (!payload.orderNumber || payload.orderNumber <= 0) {
throw new Error('Укажите номер пары');
}
if (!payload.startTime || !payload.endTime) {
throw new Error('Укажите время начала и окончания');
}
if (payload.startTime >= payload.endTime) {
throw new Error('Время начала должно быть раньше времени окончания');
}
}
function calculateDuration() {
durationInput.value = '';
if (!startInput.value || !endInput.value || startInput.value >= endInput.value) return;
const [startHour, startMinute] = startInput.value.split(':').map(Number);
const [endHour, endMinute] = endInput.value.split(':').map(Number);
durationInput.value = String((endHour * 60 + endMinute) - (startHour * 60 + startMinute));
}
function resetForm() {
form.reset();
idInput.value = '';
title.textContent = 'Новый слот';
hideAlert('time-slot-alert');
}
function selectedScope() {
return scopes.find(scope => scope.id === activeScopeId) || null;
}
function defaultScope() {
return scopes.find(scope => scope.applyMode === 'DEFAULT') || null;
}
}
function scopeOptionLabel(scope) {
const suffix = scope.applyMode === 'WEEKDAY'
? `, ${dayShort(scope.dayOfWeek)}`
: '';
return `${scope.name} - ${APPLY_MODE_LABELS[scope.applyMode] || scope.applyMode}${suffix}`;
}
function scopeBadgeLabel(scope) {
if (scope.applyMode === 'DEFAULT') return 'Базовая';
if (scope.applyMode === 'WEEKDAY') return dayShort(scope.dayOfWeek);
return 'Ручная';
}
function dayShort(dayOfWeek) {
return DAY_SHORT_LABELS[Number(dayOfWeek)] || '-';
}
function trimTime(value) {
return value ? String(value).slice(0, 5) : '';
}

View File

@@ -1,73 +0,0 @@
<!-- ===== Database / Tenants Tab ===== -->
<!-- Текущее подключение -->
<div class="card">
<h2>Текущее подключение</h2>
<div id="db-status-info" class="db-status-card">
<div class="loading-row">Загрузка...</div>
</div>
</div>
<!-- Таблица тенантов -->
<div class="card">
<h2>Подключённые университеты</h2>
<div class="table-wrap">
<table id="tenants-table">
<thead>
<tr>
<th>Название</th>
<th>Домен</th>
<th>JDBC URL</th>
<th>Пользователь</th>
<th>Статус</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="tenants-tbody">
<tr>
<td colspan="6" class="loading-row">Загрузка...</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Добавить тенант -->
<div class="card create-card">
<h2>Добавить подключение</h2>
<form id="add-tenant-form">
<div class="form-row">
<div class="form-group">
<label for="tenant-name">Название университета</label>
<input type="text" id="tenant-name" placeholder="ЮЗГУ" required>
</div>
<div class="form-group">
<label for="tenant-domain">Поддомен</label>
<input type="text" id="tenant-domain" placeholder="swsu" required>
</div>
</div>
<div class="form-row" style="margin-top: 0.75rem;">
<div class="form-group" style="flex: 3;">
<label for="tenant-url">JDBC URL</label>
<input type="text" id="tenant-url" placeholder="jdbc:postgresql://192.168.1.50:5432/magistr_db" required>
</div>
</div>
<div class="form-row" style="margin-top: 0.75rem;">
<div class="form-group">
<label for="tenant-username">Пользователь</label>
<input type="text" id="tenant-username" placeholder="postgres" autocomplete="username" required>
</div>
<div class="form-group">
<label for="tenant-password">Пароль</label>
<input type="password" id="tenant-password" placeholder="••••••••" autocomplete="new-password" required>
</div>
<button type="button" class="btn btn-md btn-secondary" id="btn-test-connection" style="height: fit-content;">
Тест
</button>
<button type="submit" class="btn btn-md btn-primary" style="height: fit-content;">
Добавить
</button>
</div>
<div class="form-alert" id="add-tenant-alert" role="alert"></div>
</form>
</div>

View File

@@ -1,34 +0,0 @@
<!-- ===== Education Forms Tab ===== -->
<div class="card create-card">
<h2>Новая форма обучения</h2>
<form id="create-ef-form">
<div class="form-row">
<div class="form-group">
<label for="new-ef-name">Название</label>
<input type="text" id="new-ef-name" placeholder="Бакалавриат" required>
</div>
<button type="submit" class="btn btn-md btn-primary">Создать</button>
</div>
<div class="form-alert" id="create-ef-alert" role="alert"></div>
</form>
</div>
<div class="card">
<h2>Все формы обучения</h2>
<div class="table-wrap">
<table id="ef-table">
<thead>
<tr>
<th>ID</th>
<th>Название</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="ef-tbody">
<tr>
<td colspan="3" class="loading-row">Загрузка...</td>
</tr>
</tbody>
</table>
</div>
</div>

View File

@@ -1,11 +0,0 @@
<div class="settings-placeholder">
<div class="icon-wrap">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="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

@@ -1,134 +0,0 @@
<section class="settings-section">
<div class="settings-header-row">
<div>
<h2>Временные слоты</h2>
<p>Настройка базовой, субботней и ручных сеток времени.</p>
</div>
<button type="button" class="btn btn-md btn-secondary" id="time-slots-refresh">Обновить</button>
</div>
<div class="settings-grid">
<article class="settings-card">
<div class="card-header-row">
<h3>Сетка времени</h3>
<div class="scope-header-actions">
<button type="button" class="btn btn-md btn-primary" id="time-slot-scope-open-create">Новая ручная сетка</button>
<button type="button" class="btn btn-md btn-danger-subtle" id="time-slot-scope-delete">Удалить сетку</button>
</div>
</div>
<div class="settings-form settings-form-single">
<label class="form-field" for="time-slot-scope-select">
<span>Область действия</span>
<select id="time-slot-scope-select" class="native-select-control" data-native-select="true"></select>
</label>
</div>
<div class="scope-summary" id="time-slot-scope-summary"></div>
</article>
<article class="settings-card settings-card-compact">
<h3>Как применяется</h3>
<div class="scope-summary">
<span class="scope-badge scope-badge-base">Базовая</span>
<span>Используется по умолчанию для всех дат.</span>
</div>
<div class="scope-summary">
<span class="scope-badge">Сб</span>
<span>Субботняя сетка применяется автоматически по субботам.</span>
</div>
<div class="scope-summary">
<span class="scope-badge">Ручная</span>
<span>Ручные сетки назначаются в ячейке редактора календарного графика.</span>
</div>
</article>
</div>
<article class="settings-card">
<div class="card-header-row">
<h3 id="time-slot-form-title">Новый слот</h3>
<button type="button" class="btn btn-md btn-ghost" id="time-slot-reset">Очистить</button>
</div>
<form id="time-slot-form" class="settings-form" novalidate>
<input type="hidden" id="time-slot-id">
<label class="form-field" for="time-slot-order">
<span>Пара</span>
<input type="number" id="time-slot-order" min="1" step="1" required>
</label>
<label class="form-field" for="time-slot-start">
<span>Начало</span>
<input type="time" id="time-slot-start" required>
</label>
<label class="form-field" for="time-slot-end">
<span>Окончание</span>
<input type="time" id="time-slot-end" required>
</label>
<label class="form-field" for="time-slot-duration">
<span>Минут</span>
<input type="number" id="time-slot-duration" min="1" step="1" readonly
aria-describedby="time-slot-duration-hint">
<small id="time-slot-duration-hint">Рассчитывается по времени начала и окончания</small>
</label>
<button type="submit" class="btn btn-md btn-primary">Сохранить слот</button>
<div class="form-alert" id="time-slot-alert" role="alert"></div>
</form>
</article>
<article class="settings-card">
<div class="table-wrap">
<table class="settings-table">
<thead>
<tr>
<th>Пара</th>
<th>Начало</th>
<th>Окончание</th>
<th>Минут</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="time-slots-tbody">
<tr>
<td colspan="5" class="loading-row">Загрузка...</td>
</tr>
</tbody>
</table>
</div>
</article>
<div class="settings-modal-overlay" id="time-slot-scope-modal" aria-hidden="true">
<div class="settings-modal-card" role="dialog" aria-modal="true" aria-labelledby="time-slot-scope-modal-title">
<div class="card-header-row">
<div>
<h3 id="time-slot-scope-modal-title">Новая ручная сетка</h3>
<p class="settings-modal-subtitle">Создайте отдельную сетку времени, которую затем можно назначать датам в календарном графике.</p>
</div>
<button type="button" class="btn btn-icon-md btn-ghost settings-modal-close" id="time-slot-scope-modal-close" aria-label="Закрыть окно">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<form id="time-slot-scope-form" class="settings-modal-form" novalidate>
<label class="form-field" for="time-slot-scope-name">
<span>Название сетки</span>
<input type="text" id="time-slot-scope-name" placeholder="Например: праздничная" required>
</label>
<div class="settings-modal-actions">
<button type="submit" class="btn btn-md btn-primary">Создать сетку</button>
<button type="button" class="btn btn-md btn-ghost" id="time-slot-scope-modal-cancel">Отмена</button>
</div>
<div class="form-alert" id="time-slot-scope-alert" role="alert"></div>
</form>
</div>
</div>
</section>