Files
magistr/frontend/react/admin/AdminApp.jsx
dipatrik10 97bf3cc50a 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
2026-09-28 23:15:04 +03:00

459 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Suspense, useEffect, useRef, useState } from 'react';
import { ADMIN_APP_ROLES, SETTINGS_ROLES } from '../../admin/js/role-capabilities.js';
import { applyRippleEffect, closeAllDropdownsOnOutsideClick } from '../../admin/js/utils.js';
import { initAllCustomDropdowns, startDropdownAutoObserver } from '../../admin/js/dropdown.js';
import { confirmAbandonDirtyChanges } from '../../admin/js/dirty-state.js';
import { trackUxEvent } from '../../telemetry.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 { REACT_TABS } from './react-tabs.js';
import {
ADMIN_ROUTES,
allowedAdminTabs,
badgeVisibility,
defaultAdminTabForRole,
fetchPendingRequestsCount,
logoTitleForRole,
navBadgeLabel,
navSectionsForRole,
requestCountLabel,
tabFromHash
} from './admin-service.js';
const THEME_STORAGE_KEY = 'theme';
const SIDEBAR_STORAGE_KEY = 'sidebar-collapsed';
const ICONS = Object.freeze({
dashboard: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="7" height="9" />
<rect x="14" y="3" width="7" height="5" />
<rect x="14" y="12" width="7" height="9" />
<rect x="3" y="16" width="7" height="5" />
</svg>
),
'teacher-requests': (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" />
<rect x="8" y="2" width="8" height="4" rx="1" />
<path d="M8 11h8" />
<path d="M8 16h5" />
</svg>
),
'department-workspace': (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 21h18" />
<path d="M5 21V7l7-4 7 4v14" />
<path d="M9 21v-6h6v6" />
<path d="M9 9h.01" />
<path d="M15 9h.01" />
</svg>
),
schedule: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
),
'schedule-versions': (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 7h12a4 4 0 0 1 4 4v7" />
<path d="M16 3l4 4-4 4" />
<path d="M20 17H8a4 4 0 0 1-4-4V6" />
<path d="M8 21l-4-4 4-4" />
</svg>
),
'schedule-quality': (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 19V9" />
<path d="M10 19V5" />
<path d="M16 19v-7" />
<path d="M22 19V3" />
<path d="M2 19h20" />
</svg>
),
'academic-calendar': (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 3h18v18H3z" />
<path d="M3 9h18" />
<path d="M9 3v18" />
<path d="M15 3v18" />
<path d="M3 15h18" />
</svg>
),
'auditorium-workload': (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<line x1="3" y1="9" x2="21" y2="9" />
<line x1="9" y1="21" x2="9" y2="9" />
</svg>
),
'schedule-view': (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z" />
<circle cx="12" cy="12" r="3" />
</svg>
),
'teacher-absences': (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 3a4 4 0 1 0 0 8 4 4 0 0 0 0-8z" />
<path d="M5 21v-2a7 7 0 0 1 14 0v2" />
<path d="M18 8l3 3" />
<path d="M21 8l-3 3" />
</svg>
),
classrooms: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 3h18v18H3zM9 3v18M15 3v18M3 9h18M3 15h18" />
</svg>
),
groups: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" />
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" />
</svg>
),
'university-structure': (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" />
<rect x="8" y="2" width="8" height="4" rx="1" ry="1" />
</svg>
),
subjects: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 20h9" />
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" />
</svg>
),
users: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
)
});
export function AdminApp() {
const sessionState = useAuthorizedSession(ADMIN_APP_ROLES);
if (sessionState.phase === SESSION_PHASES.loading) {
return (
<AdminFrame>
<AsyncState kind="loading" title="Админ-панель" message="Проверяем сессию…" />
</AdminFrame>
);
}
if (sessionState.phase !== SESSION_PHASES.ready) {
return (
<AdminFrame>
<AsyncState kind="error" title="Раздел недоступен" message={sessionState.message} />
</AdminFrame>
);
}
return <AdminWorkspace role={sessionState.session.role} />;
}
function AdminFrame({ children }) {
return (
<main className="main">
<header className="topbar">
<div className="topbar-title-group">
<h1>Админ-панель</h1>
</div>
</header>
<section className="content">
{children}
</section>
</main>
);
}
export function AdminWorkspace({ role }) {
const allowedTabs = allowedAdminTabs(role);
const [activeTab, setActiveTab] = useState(() => (
typeof window === 'undefined'
? defaultAdminTabForRole(role)
: tabFromHash(window.location.hash, role)
));
const activeTabRef = useRef(activeTab);
const [collapsed, setCollapsed] = useState(() => (
typeof window !== 'undefined'
&& window.innerWidth > 768
&& localStorage.getItem(SIDEBAR_STORAGE_KEY) === 'true'
));
const [settingsOpen, setSettingsOpen] = useState(false);
const [pendingCount, setPendingCount] = useState(null);
const settingsRef = useRef(null);
useEffect(() => {
applyRippleEffect();
closeAllDropdownsOnOutsideClick();
initAllCustomDropdowns(document.body);
startDropdownAutoObserver();
}, []);
useEffect(() => {
document.documentElement.dataset.theme = localStorage.getItem(THEME_STORAGE_KEY) || 'light';
}, []);
useEffect(() => {
function handleHashChange() {
void requestTabSwitch(tabFromHash(window.location.hash, role));
}
window.addEventListener('hashchange', handleHashChange);
return () => {
window.removeEventListener('hashchange', handleHashChange);
};
});
useEffect(() => {
function handleClickOutside(event) {
if (settingsRef.current && !settingsRef.current.contains(event.target)) {
setSettingsOpen(false);
}
}
document.addEventListener('click', handleClickOutside);
return () => {
document.removeEventListener('click', handleClickOutside);
};
}, []);
useEffect(() => {
if (role !== 'ADMIN') return;
let active = true;
async function refresh(countOverride = null) {
try {
const count = countOverride === null
? await fetchPendingRequestsCount()
: countOverride;
if (active) setPendingCount(count);
} catch {
if (active) setPendingCount(null);
console.warn('Не удалось загрузить счётчик заявок');
}
}
void refresh();
function handleRequestsChanged(event) {
void refresh(Number.isFinite(event.detail?.count) ? event.detail.count : null);
}
document.addEventListener('teacherRequestsChanged', handleRequestsChanged);
return () => {
active = false;
document.removeEventListener('teacherRequestsChanged', handleRequestsChanged);
};
}, [role]);
async function requestTabSwitch(tab) {
if (tab === activeTabRef.current || !allowedTabs.has(tab)) return;
if (activeTabRef.current && !(await confirmAbandonDirtyChanges())) return;
activeTabRef.current = tab;
setActiveTab(tab);
trackUxEvent('раздел_открыт', { tab, role });
if (window.location.hash !== `#${tab}`) {
window.location.hash = tab;
}
}
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();
}
const sections = navSectionsForRole(role);
const title = activeTab ? ADMIN_ROUTES[activeTab].title : 'Админ-панель';
const badge = pendingCount === null ? null : badgeVisibility(pendingCount);
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>{logoTitleForRole(role)}</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>
<nav className="sidebar-nav">
{sections.map(section => (
<div key={section.title || 'main'}>
{section.title && (
<>
<div className="sidebar-section-divider" />
<div className="sidebar-section-title">{section.title}</div>
</>
)}
{section.items.map(item => (
<a
key={item.id}
href={`#${item.id}`}
className={`nav-item${item.id === activeTab ? ' active' : ''}`}
data-tab={item.id}
onClick={event => {
event.preventDefault();
void requestTabSwitch(item.id);
}}
>
{ICONS[item.id]}
<span>{item.label}</span>
{item.id === 'teacher-requests' && badge && !badge.hidden && (
<span
className="nav-badge"
id="teacher-requests-nav-badge"
aria-label={badge.ariaLabel}
>
{navBadgeLabel(pendingCount)}
</span>
)}
</a>
))}
</div>
))}
</nav>
<div className="sidebar-footer">
<div className={`settings-dropdown${settingsOpen ? ' open' : ''}`} ref={settingsRef}>
<button
type="button"
className="btn-settings"
onClick={event => {
event.stopPropagation();
setSettingsOpen(current => !current);
}}
>
<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>
<span>Настройки</span>
<svg className="settings-chevron" width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
<div className="settings-menu">
{SETTINGS_ROLES.includes(role) && (
<a href="/admin/settings/" className="settings-menu-item">
<svg width="18" height="18" 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>
Общие настройки
</a>
)}
<div className="settings-menu-divider" />
<button
type="button"
className="btn btn-md btn-danger-subtle btn-logout settings-menu-item settings-menu-item--danger"
onClick={handleLogout}
>
<svg width="18" height="18" 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>
Выйти
</button>
</div>
</div>
</div>
</aside>
<div className="sidebar-overlay" />
<main className={`main${collapsed ? ' sidebar-collapsed' : ''}`}>
<header className="topbar">
<div className="topbar-title-group">
<h1 id="page-title">{title}</h1>
{badge && !badge.hidden && (
<button
type="button"
className="topbar-request-badge"
id="teacher-requests-title-badge"
aria-label={badge.ariaLabel}
onClick={() => void requestTabSwitch('teacher-requests')}
>
{requestCountLabel(pendingCount)}
</button>
)}
</div>
<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" id="app-content">
{activeTab && <TabContent tab={activeTab} />}
</section>
</main>
</>
);
}
function TabContent({ tab }) {
const reactTab = REACT_TABS[tab];
const TabComponent = reactTab?.Component;
if (!TabComponent) {
return null;
}
return (
<Suspense fallback={<div className="tab-loading"><span className="state-spinner" aria-hidden="true" />Загрузка...</div>}>
<TabComponent />
</Suspense>
);
}