Админка, настройки, логин, кабинеты преподавателя и студента полностью переведены на 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
459 lines
21 KiB
JavaScript
459 lines
21 KiB
JavaScript
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>
|
||
);
|
||
}
|