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:
458
frontend/react/admin/AdminApp.jsx
Normal file
458
frontend/react/admin/AdminApp.jsx
Normal file
@@ -0,0 +1,458 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
641
frontend/react/admin/academic-calendar-service.js
Normal file
641
frontend/react/admin/academic-calendar-service.js
Normal file
@@ -0,0 +1,641 @@
|
||||
import { apiClient } from '../shared/api-client.js';
|
||||
import { dateInputValueToIso, isoDateToInputValue } from '../../admin/js/date-input.js';
|
||||
import { academicCalendarWeekNumber } from '../../admin/js/views/academic-calendar-grid.js';
|
||||
import { buildAcademicCalendarTitle } from '../../admin/js/views/academic-calendar-title.js';
|
||||
|
||||
export const DAY_OPTIONS = Object.freeze([
|
||||
Object.freeze({ value: 1, label: 'Понедельник', shortLabel: 'Пн' }),
|
||||
Object.freeze({ value: 2, label: 'Вторник', shortLabel: 'Вт' }),
|
||||
Object.freeze({ value: 3, label: 'Среда', shortLabel: 'Ср' }),
|
||||
Object.freeze({ value: 4, label: 'Четверг', shortLabel: 'Чт' }),
|
||||
Object.freeze({ value: 5, label: 'Пятница', shortLabel: 'Пт' }),
|
||||
Object.freeze({ value: 6, label: 'Суббота', shortLabel: 'Сб' }),
|
||||
Object.freeze({ value: 7, label: 'Воскресенье', shortLabel: 'Вс' })
|
||||
]);
|
||||
|
||||
export const SEMESTER_LABELS = Object.freeze({
|
||||
autumn: 'осенний',
|
||||
spring: 'весенний'
|
||||
});
|
||||
|
||||
const CALENDAR_TOOLTIP_OFFSET = 14;
|
||||
const CALENDAR_TOOLTIP_VIEWPORT_PADDING = 12;
|
||||
|
||||
// --- Позиция подсказки ячейки ---
|
||||
|
||||
export function calculateCalendarTooltipPosition({
|
||||
pointerX,
|
||||
pointerY,
|
||||
tooltipWidth,
|
||||
tooltipHeight,
|
||||
viewportWidth,
|
||||
viewportHeight
|
||||
}) {
|
||||
const maxLeft = Math.max(
|
||||
CALENDAR_TOOLTIP_VIEWPORT_PADDING,
|
||||
viewportWidth - tooltipWidth - CALENDAR_TOOLTIP_VIEWPORT_PADDING
|
||||
);
|
||||
const maxTop = Math.max(
|
||||
CALENDAR_TOOLTIP_VIEWPORT_PADDING,
|
||||
viewportHeight - tooltipHeight - CALENDAR_TOOLTIP_VIEWPORT_PADDING
|
||||
);
|
||||
let left = pointerX + CALENDAR_TOOLTIP_OFFSET;
|
||||
let top = pointerY + CALENDAR_TOOLTIP_OFFSET;
|
||||
|
||||
if (left > maxLeft) {
|
||||
left = pointerX - tooltipWidth - CALENDAR_TOOLTIP_OFFSET;
|
||||
}
|
||||
if (top > maxTop) {
|
||||
top = pointerY - tooltipHeight - CALENDAR_TOOLTIP_OFFSET;
|
||||
}
|
||||
|
||||
return {
|
||||
left: Math.round(Math.max(CALENDAR_TOOLTIP_VIEWPORT_PADDING, Math.min(left, maxLeft))),
|
||||
top: Math.round(Math.max(CALENDAR_TOOLTIP_VIEWPORT_PADDING, Math.min(top, maxTop)))
|
||||
};
|
||||
}
|
||||
|
||||
// --- Учебный год ---
|
||||
|
||||
export function academicYearDefaults(startYearValue) {
|
||||
const value = String(startYearValue || '').trim();
|
||||
if (!/^\d{4}$/.test(value)) {
|
||||
throw new TypeError('Год начала учебного года должен состоять из четырёх цифр');
|
||||
}
|
||||
const startYear = Number(value);
|
||||
if (startYear < 1000 || startYear > 9998) {
|
||||
throw new TypeError('Год начала учебного года должен быть в диапазоне от 1000 до 9998');
|
||||
}
|
||||
const endYear = startYear + 1;
|
||||
|
||||
const start = new Date(Date.UTC(startYear, 8, 1));
|
||||
const autumnEnd = new Date(Date.UTC(endYear, 0, 31));
|
||||
const springStart = new Date(Date.UTC(endYear, 1, 1));
|
||||
const end = new Date(Date.UTC(endYear, 5, 30));
|
||||
|
||||
return {
|
||||
startYear,
|
||||
endYear,
|
||||
title: `${startYear}-${endYear}`,
|
||||
startDate: toIsoDateUtc(start),
|
||||
endDate: toIsoDateUtc(end),
|
||||
semesters: [
|
||||
{
|
||||
semesterType: 'autumn',
|
||||
startDate: toIsoDateUtc(start),
|
||||
endDate: toIsoDateUtc(autumnEnd)
|
||||
},
|
||||
{
|
||||
semesterType: 'spring',
|
||||
startDate: toIsoDateUtc(springStart),
|
||||
endDate: toIsoDateUtc(end)
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
export function validateAcademicYearSemesters(semesters, yearStart, yearEnd) {
|
||||
if (!Array.isArray(semesters) || semesters.length === 0) {
|
||||
throw new Error('Учебный год нельзя создать без семестров');
|
||||
}
|
||||
const sorted = [...semesters].sort((a, b) => String(a.startDate).localeCompare(String(b.startDate)));
|
||||
const types = new Set();
|
||||
sorted.forEach((semester, index) => {
|
||||
if (!semester?.semesterType || !semester.startDate || !semester.endDate) {
|
||||
throw new Error('Заполните тип и даты каждого семестра');
|
||||
}
|
||||
if (types.has(semester.semesterType)) {
|
||||
throw new Error('Типы семестров не должны повторяться');
|
||||
}
|
||||
types.add(semester.semesterType);
|
||||
if (semester.endDate < semester.startDate) {
|
||||
throw new Error('Дата окончания семестра не может быть раньше даты начала');
|
||||
}
|
||||
if (semester.startDate < yearStart || semester.endDate > yearEnd) {
|
||||
throw new Error('Семестры должны находиться в границах учебного года');
|
||||
}
|
||||
if (index > 0 && sorted[index - 1].endDate >= semester.startDate) {
|
||||
throw new Error('Периоды семестров не должны пересекаться');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function toIsoDateUtc(date) {
|
||||
return [
|
||||
date.getUTCFullYear(),
|
||||
String(date.getUTCMonth() + 1).padStart(2, '0'),
|
||||
String(date.getUTCDate()).padStart(2, '0')
|
||||
].join('-');
|
||||
}
|
||||
|
||||
// Ввод года начала: нормализует значение и пересчитывает год окончания
|
||||
// вместе с датами семестров. Как в legacy, ручной ввод года сбрасывает даты
|
||||
// семестров к рассчитанным значениям (semesterDates === null — не трогать).
|
||||
export function startYearInputResult(rawValue) {
|
||||
const startYear = String(rawValue || '').replace(/\D/g, '').slice(0, 4);
|
||||
if (startYear.length !== 4) {
|
||||
return { startYear, endYear: '', semesterDates: null };
|
||||
}
|
||||
try {
|
||||
const defaults = academicYearDefaults(startYear);
|
||||
return {
|
||||
startYear,
|
||||
endYear: String(defaults.endYear),
|
||||
semesterDates: {
|
||||
autumn: {
|
||||
start: isoDateToInputValue(defaults.semesters[0].startDate),
|
||||
end: isoDateToInputValue(defaults.semesters[0].endDate)
|
||||
},
|
||||
spring: {
|
||||
start: isoDateToInputValue(defaults.semesters[1].startDate),
|
||||
end: isoDateToInputValue(defaults.semesters[1].endDate)
|
||||
}
|
||||
}
|
||||
};
|
||||
} catch {
|
||||
return { startYear, endYear: '', semesterDates: null };
|
||||
}
|
||||
}
|
||||
|
||||
// Чтение даты из поля ДД.ММ.ГГГГ с подписью поля в сообщении об ошибке
|
||||
// (реплика legacy readDateInputIso).
|
||||
export function readDisplayDateIso(displayValue, label = 'Дата') {
|
||||
try {
|
||||
return dateInputValueToIso(displayValue);
|
||||
} catch (error) {
|
||||
throw new Error(`${label}: ${lowercaseFirst(error.message)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Типы активности ---
|
||||
|
||||
export function activityColorCode(activity) {
|
||||
const color = String(activity?.colorCode || '').trim();
|
||||
return /^#[0-9a-f]{6}$/i.test(color) ? color : '#64748b';
|
||||
}
|
||||
|
||||
export function activityColorClass(activity) {
|
||||
return `calendar-activity-color-${activityColorCode(activity).slice(1).toLowerCase()}`;
|
||||
}
|
||||
|
||||
export function activityById(id, activityTypes) {
|
||||
return (activityTypes || []).find(type => String(type.id) === String(id));
|
||||
}
|
||||
|
||||
export function activityIdByCode(code, activityTypes) {
|
||||
return (activityTypes || []).find(type => type.code === code)?.id || null;
|
||||
}
|
||||
|
||||
export function theoryActivityId(activityTypes) {
|
||||
return (activityTypes || []).find(type => type.code === 'Т')?.id || activityTypes?.[0]?.id || '';
|
||||
}
|
||||
|
||||
// Эффективный код активности ячейки: сохранённое значение, затем код по
|
||||
// справочнику, затем теория (реплика legacy renderCalendarDay).
|
||||
export function effectiveActivityTypeId(row, activityTypes) {
|
||||
return row.activityTypeId || activityIdByCode(row.activityCode, activityTypes) || theoryActivityId(activityTypes);
|
||||
}
|
||||
|
||||
// --- Контракты API ---
|
||||
|
||||
export function fetchSpecialties(client = apiClient) {
|
||||
return client.get('/api/specialties', { errorMessage: 'Ошибка загрузки специальностей' });
|
||||
}
|
||||
|
||||
export function specialtyProfilesEndpoint(specialtyId) {
|
||||
return `/api/specialties/${specialtyId}/profiles`;
|
||||
}
|
||||
|
||||
export function fetchSpecialtyProfiles(specialtyId, client = apiClient) {
|
||||
return client.get(specialtyProfilesEndpoint(specialtyId), {
|
||||
errorMessage: 'Ошибка загрузки профилей специальности'
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchAcademicYearsList(client = apiClient) {
|
||||
return client.get('/api/admin/calendar/years', { errorMessage: 'Ошибка загрузки учебных годов' });
|
||||
}
|
||||
|
||||
export function fetchSubjectsList(client = apiClient) {
|
||||
return client.get('/api/subjects', { errorMessage: 'Ошибка загрузки дисциплин' });
|
||||
}
|
||||
|
||||
export function fetchEducationForms(client = apiClient) {
|
||||
return client.get('/api/education-forms', { errorMessage: 'Ошибка загрузки форм обучения' });
|
||||
}
|
||||
|
||||
export function fetchActivityTypes(client = apiClient) {
|
||||
return client.get('/api/admin/calendar/activity-types', {
|
||||
errorMessage: 'Ошибка загрузки типов активности'
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchTimeSlotScopes(client = apiClient) {
|
||||
return client.get('/api/admin/time-slots/scopes', { errorMessage: 'Ошибка загрузки сеток времени' });
|
||||
}
|
||||
|
||||
export function fetchTimeSlotDateAssignments(client = apiClient) {
|
||||
return client.get('/api/admin/time-slots/date-assignments', {
|
||||
errorMessage: 'Ошибка загрузки ручных сеток'
|
||||
});
|
||||
}
|
||||
|
||||
export function calendarsPageEndpoint({ page, size, query, academicYearId } = {}) {
|
||||
const params = new URLSearchParams({
|
||||
page: String(page ?? 0),
|
||||
size: String(size ?? 25),
|
||||
query: String(query || '').trim()
|
||||
});
|
||||
if (academicYearId) params.set('academicYearId', String(academicYearId));
|
||||
return `/api/admin/academic-calendars/page?${params}`;
|
||||
}
|
||||
|
||||
export function fetchCalendarsPage({ page, size, query, academicYearId }, client = apiClient) {
|
||||
return client.get(calendarsPageEndpoint({ page, size, query, academicYearId }), {
|
||||
errorMessage: 'Ошибка загрузки календарных графиков'
|
||||
});
|
||||
}
|
||||
|
||||
export function academicCalendarsOptionsEndpoint(query, selectedId) {
|
||||
const params = new URLSearchParams({ query: String(query || ''), limit: '20' });
|
||||
if (selectedId) params.set('selectedId', String(selectedId));
|
||||
return `/api/admin/academic-calendars/options?${params}`;
|
||||
}
|
||||
|
||||
export function fetchCalendarOptions(query, selectedId, client = apiClient) {
|
||||
return client.get(academicCalendarsOptionsEndpoint(query, selectedId), {
|
||||
errorMessage: 'Ошибка поиска календарных графиков'
|
||||
});
|
||||
}
|
||||
|
||||
export function saveAcademicYearRequest(payload, id, client = apiClient) {
|
||||
return id
|
||||
? client.put(`/api/admin/calendar/years/${id}`, payload, { errorMessage: 'Ошибка сохранения учебного года' })
|
||||
: client.post('/api/admin/calendar/years', payload, { errorMessage: 'Ошибка сохранения учебного года' });
|
||||
}
|
||||
|
||||
export function deleteAcademicYearRequest(id, client = apiClient) {
|
||||
return client.delete(`/api/admin/calendar/years/${id}`, {
|
||||
errorMessage: 'Ошибка удаления учебного года'
|
||||
});
|
||||
}
|
||||
|
||||
export function saveCalendarRequest(payload, id, client = apiClient) {
|
||||
return id
|
||||
? client.put(`/api/admin/academic-calendars/${id}`, payload, { errorMessage: 'Ошибка сохранения календарного графика' })
|
||||
: client.post('/api/admin/academic-calendars', payload, { errorMessage: 'Ошибка сохранения календарного графика' });
|
||||
}
|
||||
|
||||
export function deleteCalendarRequest(id, client = apiClient) {
|
||||
return client.delete(`/api/admin/academic-calendars/${id}`, {
|
||||
errorMessage: 'Ошибка удаления календарного графика'
|
||||
});
|
||||
}
|
||||
|
||||
export function calendarSubjectsEndpoint(calendarId) {
|
||||
return `/api/admin/academic-calendars/${calendarId}/subjects`;
|
||||
}
|
||||
|
||||
export function fetchCalendarSubjects(calendarId, client = apiClient) {
|
||||
return client.get(calendarSubjectsEndpoint(calendarId), {
|
||||
errorMessage: 'Ошибка загрузки дисциплин графика'
|
||||
});
|
||||
}
|
||||
|
||||
export function saveCalendarSubjectsRequest(calendarId, payload, client = apiClient) {
|
||||
return client.put(calendarSubjectsEndpoint(calendarId), payload, {
|
||||
errorMessage: 'Ошибка сохранения дисциплин графика'
|
||||
});
|
||||
}
|
||||
|
||||
export function calendarGridEndpoint(calendarId) {
|
||||
return `/api/admin/academic-calendars/${calendarId}/grid`;
|
||||
}
|
||||
|
||||
export function fetchCalendarGrid(calendarId, client = apiClient) {
|
||||
return client.get(calendarGridEndpoint(calendarId), { errorMessage: 'Ошибка загрузки сетки' });
|
||||
}
|
||||
|
||||
export function saveCalendarGridRequest(calendarId, rows, client = apiClient) {
|
||||
return client.put(calendarGridEndpoint(calendarId), rows, {
|
||||
errorMessage: 'Ошибка сохранения сетки'
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteTimeSlotDateAssignmentRequest(id, client = apiClient) {
|
||||
return client.delete(`/api/admin/time-slots/date-assignments/${id}`, {
|
||||
errorMessage: 'Ошибка удаления ручной сетки'
|
||||
});
|
||||
}
|
||||
|
||||
// --- Подписи ---
|
||||
|
||||
export function calendarOptionLabel(calendar) {
|
||||
return buildAcademicCalendarTitle(calendar) || calendar.title;
|
||||
}
|
||||
|
||||
export function calendarOptionDescription(calendar) {
|
||||
return calendar.specialtyName || '';
|
||||
}
|
||||
|
||||
export function specialityLabel(speciality) {
|
||||
const code = specialityCode(speciality) || speciality.id;
|
||||
const name = speciality.specialityName || speciality.name || '';
|
||||
return name ? `${code} - ${name}` : String(code);
|
||||
}
|
||||
|
||||
export function specialityCode(speciality) {
|
||||
return speciality?.specialityCode
|
||||
|| speciality?.specialtyCode
|
||||
|| speciality?.speciality_code
|
||||
|| speciality?.specialty_code
|
||||
|| '';
|
||||
}
|
||||
|
||||
export function dayLabel(value) {
|
||||
return DAY_OPTIONS.find(day => String(day.value) === String(value))?.label || '-';
|
||||
}
|
||||
|
||||
export function shortDayLabel(value) {
|
||||
return DAY_OPTIONS.find(day => String(day.value) === String(value))?.shortLabel || '-';
|
||||
}
|
||||
|
||||
export function formatShortDate(value) {
|
||||
const [, month, day] = String(value).split('-');
|
||||
return `${day}.${month}`;
|
||||
}
|
||||
|
||||
export function formatLongDate(value) {
|
||||
const [year, month, day] = String(value).split('-');
|
||||
return `${day}.${month}.${year}`;
|
||||
}
|
||||
|
||||
export function formatDisplayDate(value) {
|
||||
const [year, month, day] = String(value || '').split('-');
|
||||
if (!year || !month || !day) return String(value || '');
|
||||
return `${day}-${month}-${year}`;
|
||||
}
|
||||
|
||||
export function formatDisplayDateRange(startDate, endDate) {
|
||||
return `${formatDisplayDate(startDate)} — ${formatDisplayDate(endDate)}`;
|
||||
}
|
||||
|
||||
export function dateRangeCaption(rows) {
|
||||
if (!rows.length) return '';
|
||||
const dates = rows.map(row => row.date).sort();
|
||||
return `${formatLongDate(dates[0])} - ${formatLongDate(dates[dates.length - 1])}`;
|
||||
}
|
||||
|
||||
export function semesterTypeTitle(semester) {
|
||||
const type = normalizeSemesterType(semester.semesterType);
|
||||
const label = SEMESTER_LABELS[type] || type || 'семестр';
|
||||
return label.charAt(0).toUpperCase() + label.slice(1);
|
||||
}
|
||||
|
||||
export function normalizeSemesterType(type) {
|
||||
return String(type || '').toLowerCase();
|
||||
}
|
||||
|
||||
export function semesterOptionLabel(semesterNumber) {
|
||||
const course = Math.ceil(Number(semesterNumber) / 2);
|
||||
const type = Number(semesterNumber) % 2 === 1 ? 'осенний' : 'весенний';
|
||||
return `${semesterNumber} семестр · ${course} курс, ${type}`;
|
||||
}
|
||||
|
||||
export function subjectLabel(subject) {
|
||||
const code = subject.code ? `${subject.code} · ` : '';
|
||||
return `${code}${subject.name || 'Без названия'}`;
|
||||
}
|
||||
|
||||
export function subjectRowLabel(row) {
|
||||
const code = row.subjectCode ? `${row.subjectCode} · ` : '';
|
||||
return `${code}${row.subjectName || row.subjectId || 'Без названия'}`;
|
||||
}
|
||||
|
||||
export function sortCalendarSubjectRows(rows) {
|
||||
return [...(rows || [])].sort((a, b) => {
|
||||
const semesterDiff = Number(a.semesterNumber) - Number(b.semesterNumber);
|
||||
if (semesterDiff !== 0) return semesterDiff;
|
||||
return subjectRowLabel(a).localeCompare(subjectRowLabel(b), 'ru');
|
||||
});
|
||||
}
|
||||
|
||||
export function calendarSemesterCount(calendar) {
|
||||
return Math.max(1, Number(calendar?.courseCount || 0) * 2);
|
||||
}
|
||||
|
||||
// --- Сетка годового графика ---
|
||||
|
||||
export function groupBy(items, keyFn) {
|
||||
const grouped = new Map();
|
||||
(items || []).forEach(item => {
|
||||
const key = keyFn(item);
|
||||
if (!grouped.has(key)) grouped.set(key, []);
|
||||
grouped.get(key).push(item);
|
||||
});
|
||||
return grouped;
|
||||
}
|
||||
|
||||
export function calendarRowKey(row) {
|
||||
return `${row.courseNumber}:${row.date}`;
|
||||
}
|
||||
|
||||
export function uniqueWeekNumbers(rows) {
|
||||
return Array.from(new Set((rows || []).map(row => row.weekNumber)));
|
||||
}
|
||||
|
||||
export function minIso(a, b) {
|
||||
return String(a) <= String(b) ? a : b;
|
||||
}
|
||||
|
||||
export function maxIso(a, b) {
|
||||
return String(a) >= String(b) ? a : b;
|
||||
}
|
||||
|
||||
export function previousIsoDate(value) {
|
||||
const date = parseLocalIsoDate(value);
|
||||
date.setDate(date.getDate() - 1);
|
||||
return formatLocalDate(date);
|
||||
}
|
||||
|
||||
export function enumerateDates(startIso, endIso) {
|
||||
const result = [];
|
||||
const current = parseLocalIsoDate(startIso);
|
||||
const end = parseLocalIsoDate(endIso);
|
||||
while (current <= end) {
|
||||
const iso = formatLocalDate(current);
|
||||
const jsDay = current.getDay();
|
||||
result.push({ iso, dayOfWeek: jsDay === 0 ? 7 : jsDay });
|
||||
current.setDate(current.getDate() + 1);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseLocalIsoDate(value) {
|
||||
const [year, month, day] = String(value).split('-').map(Number);
|
||||
const date = new Date(year, month - 1, day, 12);
|
||||
if (!year || !month || !day || Number.isNaN(date.getTime())
|
||||
|| date.getFullYear() !== year
|
||||
|| date.getMonth() !== month - 1
|
||||
|| date.getDate() !== day) {
|
||||
throw new TypeError('Ожидалась дата в формате ГГГГ-ММ-ДД');
|
||||
}
|
||||
return date;
|
||||
}
|
||||
|
||||
function formatLocalDate(date) {
|
||||
return [
|
||||
date.getFullYear(),
|
||||
String(date.getMonth() + 1).padStart(2, '0'),
|
||||
String(date.getDate()).padStart(2, '0')
|
||||
].join('-');
|
||||
}
|
||||
|
||||
export function buildDefaultGrid(calendar, activityTypes) {
|
||||
const rows = [];
|
||||
const dates = enumerateDates(calendar.academicYearStartDate, calendar.academicYearEndDate);
|
||||
for (let course = 1; course <= calendar.courseCount; course += 1) {
|
||||
dates.forEach(date => {
|
||||
rows.push({
|
||||
id: null,
|
||||
calendarId: calendar.id,
|
||||
courseNumber: course,
|
||||
date: date.iso,
|
||||
weekNumber: academicCalendarWeekNumber(calendar.academicYearStartDate, date.iso),
|
||||
dayOfWeek: date.dayOfWeek,
|
||||
activityTypeId: theoryActivityId(activityTypes),
|
||||
activityCode: 'Т'
|
||||
});
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function semesterBlocksForCourse(calendar, course, courseRows, academicYears) {
|
||||
const sortedRows = [...(courseRows || [])].sort((a, b) => String(a.date).localeCompare(String(b.date)));
|
||||
const year = (academicYears || []).find(item => String(item.id) === String(calendar?.academicYearId));
|
||||
const yearSemesters = [...(year?.semesters || [])]
|
||||
.sort((a, b) => String(a.startDate).localeCompare(String(b.startDate)));
|
||||
|
||||
if (!yearSemesters.length) {
|
||||
return [{
|
||||
semesterNumber: `${course * 2 - 1}-${course * 2}`,
|
||||
caption: dateRangeCaption(sortedRows),
|
||||
rows: sortedRows
|
||||
}];
|
||||
}
|
||||
|
||||
return yearSemesters.map((semester, index) => {
|
||||
const nextSemester = yearSemesters[index + 1];
|
||||
const startDate = index === 0
|
||||
? minIso(calendar.academicYearStartDate, semester.startDate)
|
||||
: semester.startDate;
|
||||
const endDate = nextSemester
|
||||
? previousIsoDate(nextSemester.startDate)
|
||||
: maxIso(calendar.academicYearEndDate, semester.endDate);
|
||||
const rows = sortedRows.filter(row => row.date >= startDate && row.date <= endDate);
|
||||
return {
|
||||
semesterNumber: course * 2 - 1 + index,
|
||||
caption: dateRangeCaption(rows) || `${formatLongDate(startDate)} - ${formatLongDate(endDate)}`,
|
||||
rows
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Итоговые чипы сетки: счётчики по кодам активности, сортировка по коду.
|
||||
export function gridTotalsEntries(rows, activityTypes) {
|
||||
const counts = new Map();
|
||||
(rows || []).forEach(row => {
|
||||
const activity = activityById(row.activityTypeId, activityTypes);
|
||||
const key = activity ? activity.code : '-';
|
||||
const current = counts.get(key) || { count: 0, colorClass: activityColorClass(activity) };
|
||||
current.count += 1;
|
||||
counts.set(key, current);
|
||||
});
|
||||
return Array.from(counts.entries())
|
||||
.sort(([a], [b]) => a.localeCompare(b, 'ru'))
|
||||
.map(([code, entry]) => ({ code, count: entry.count, colorClass: entry.colorClass }));
|
||||
}
|
||||
|
||||
// Общий код активности набора ячеек: одно значение — оно само, иначе пусто.
|
||||
export function commonActivityValue(rows) {
|
||||
const values = Array.from(new Set((rows || []).map(row => String(row.activityTypeId ?? ''))));
|
||||
return values.length === 1 ? values[0] : '';
|
||||
}
|
||||
|
||||
// --- Ручные сетки дат ---
|
||||
|
||||
export function timeAssignmentForDate(assignments, date) {
|
||||
return (assignments || []).find(item => item.date === date);
|
||||
}
|
||||
|
||||
export function automaticTimeScopeLabel(date, scopes) {
|
||||
const jsDate = new Date(`${date}T00:00:00`);
|
||||
const jsDay = jsDate.getDay();
|
||||
const isoDay = jsDay === 0 ? 7 : jsDay;
|
||||
const weekdayScope = (scopes || []).find(scope => scope.applyMode === 'WEEKDAY' && Number(scope.dayOfWeek) === isoDay);
|
||||
const defaultScope = (scopes || []).find(scope => scope.applyMode === 'DEFAULT');
|
||||
const scopeName = weekdayScope?.name || defaultScope?.name || 'базовая сетка';
|
||||
return `По правилу даты (${scopeName})`;
|
||||
}
|
||||
|
||||
// --- Тексты диалога ячейки ---
|
||||
|
||||
export function dayDialogTexts(rows) {
|
||||
const dates = rows.map(row => row.date).sort();
|
||||
const courses = new Set(rows.map(row => String(row.courseNumber)));
|
||||
const weeks = new Set(rows.map(row => String(row.weekNumber)));
|
||||
const weekDays = new Set(rows.map(row => String(row.dayOfWeek)));
|
||||
const isSingle = rows.length === 1;
|
||||
const first = rows[0];
|
||||
|
||||
return {
|
||||
date: isSingle
|
||||
? `${formatLongDate(first.date)} (${shortDayLabel(first.dayOfWeek)})`
|
||||
: `${formatLongDate(dates[0])} - ${formatLongDate(dates[dates.length - 1])}`,
|
||||
course: courses.size === 1 ? `${first.courseNumber} курс` : `${courses.size} курса`,
|
||||
week: weeks.size === 1 ? `${first.weekNumber} неделя` : `${weeks.size} недель`,
|
||||
day: weekDays.size === 1 ? dayLabel(first.dayOfWeek) : `${rows.length} ячеек`
|
||||
};
|
||||
}
|
||||
|
||||
export function dayDialogSummaryParts(rows, activityTypes) {
|
||||
if (rows.length <= 1) {
|
||||
const activity = rows[0] ? activityById(rows[0].activityTypeId, activityTypes) : null;
|
||||
return activity
|
||||
? { kind: 'single', activity }
|
||||
: { kind: 'empty' };
|
||||
}
|
||||
const common = commonActivityValue(rows);
|
||||
const activity = common ? activityById(common, activityTypes) : null;
|
||||
return activity
|
||||
? { kind: 'multi', activity, count: rows.length }
|
||||
: { kind: 'multi-empty', count: rows.length };
|
||||
}
|
||||
|
||||
export function dayTooltipParts(row, activityTypes, scopes, assignments) {
|
||||
const activity = activityById(row.activityTypeId, activityTypes);
|
||||
// Код ячейки берётся с учётом запасного типа активности (реплика legacy:
|
||||
// код ячейки — activityById(...) || activityTypes[0] || {}).
|
||||
const cellActivity = activity || activityTypes[0] || {};
|
||||
const code = activity?.code || cellActivity.code || row.activityCode || '-';
|
||||
const assignment = timeAssignmentForDate(assignments, row.date);
|
||||
const timeLabel = assignment
|
||||
? ((scopes || []).find(scope => String(scope.id) === String(assignment.scopeId))?.name || 'ручная сетка')
|
||||
: automaticTimeScopeLabel(row.date, scopes).replace('По правилу даты ', '');
|
||||
return {
|
||||
code,
|
||||
longDate: formatLongDate(row.date),
|
||||
shortDay: shortDayLabel(row.dayOfWeek),
|
||||
week: row.weekNumber,
|
||||
course: row.courseNumber,
|
||||
activityName: activity?.name || 'Не задана',
|
||||
timeLabel,
|
||||
colorClass: activity ? activityColorClass(activity) : ''
|
||||
};
|
||||
}
|
||||
|
||||
function lowercaseFirst(value) {
|
||||
const text = String(value || '');
|
||||
return text ? text[0].toLowerCase() + text.slice(1) : text;
|
||||
}
|
||||
131
frontend/react/admin/admin-service.js
Normal file
131
frontend/react/admin/admin-service.js
Normal file
@@ -0,0 +1,131 @@
|
||||
import { apiClient } from '../shared/api-client.js';
|
||||
import { capabilitiesForRole } from '../../admin/js/role-capabilities.js';
|
||||
|
||||
// Вкладки, перенесённые на React (см. react-tabs.js), хранят только заголовок:
|
||||
// их содержимое рендерят React-компоненты, HTML-фрагменты не загружаются.
|
||||
export const ADMIN_ROUTES = Object.freeze({
|
||||
dashboard: { title: 'Дашборд' },
|
||||
users: { title: 'Управление пользователями' },
|
||||
'teacher-requests': { title: 'Заявки' },
|
||||
groups: { title: 'Управление группами' },
|
||||
classrooms: { title: 'Аудиторный фонд' },
|
||||
subjects: { title: 'Дисциплины' },
|
||||
'university-structure': { title: 'Структура вуза' },
|
||||
'department-workspace': { title: 'Кабинет кафедры' },
|
||||
'schedule-view': { title: 'Просмотр расписаний' },
|
||||
schedule: { title: 'Конструктор правил' },
|
||||
'schedule-versions': { title: 'Версии расписания' },
|
||||
'schedule-quality': { title: 'Анализ качества расписания' },
|
||||
'academic-calendar': { title: 'Календарный учебный график' },
|
||||
'auditorium-workload': { title: 'Загруженность' },
|
||||
'teacher-absences': { title: 'Запросы преподавателей' }
|
||||
});
|
||||
|
||||
const NAV_ITEMS = Object.freeze({
|
||||
dashboard: { navLabel: 'Дашборд' },
|
||||
'teacher-requests': { navLabel: 'Заявки' },
|
||||
'department-workspace': { navLabel: 'Кабинет кафедры' },
|
||||
schedule: { navLabel: 'Конструктор правил' },
|
||||
'schedule-versions': { navLabel: 'Версии расписания' },
|
||||
'schedule-quality': { navLabel: 'Качество расписания' },
|
||||
'academic-calendar': { navLabel: 'Календарный график' },
|
||||
'auditorium-workload': { navLabel: 'Загруженность' },
|
||||
'schedule-view': { navLabel: 'Просмотр расписаний' },
|
||||
'teacher-absences': { navLabel: 'Запросы преподавателей' },
|
||||
classrooms: { navLabel: 'Аудиторный фонд' },
|
||||
groups: { navLabel: 'Группы обучения' },
|
||||
'university-structure': { navLabel: 'Структура вуза' },
|
||||
subjects: { navLabel: 'Дисциплины' },
|
||||
users: { navLabel: 'Пользователи' }
|
||||
});
|
||||
|
||||
const NAV_SECTIONS = Object.freeze([
|
||||
{ title: '', items: Object.freeze(['dashboard', 'teacher-requests', 'department-workspace']) },
|
||||
{
|
||||
title: 'Расписание',
|
||||
items: Object.freeze([
|
||||
'schedule',
|
||||
'schedule-versions',
|
||||
'schedule-quality',
|
||||
'academic-calendar',
|
||||
'auditorium-workload',
|
||||
'schedule-view',
|
||||
'teacher-absences'
|
||||
])
|
||||
},
|
||||
{
|
||||
title: 'Ресурсы',
|
||||
items: Object.freeze(['classrooms', 'groups', 'university-structure', 'subjects'])
|
||||
},
|
||||
{ title: 'Система', items: Object.freeze(['users']) }
|
||||
]);
|
||||
|
||||
const LOGO_TITLES = Object.freeze({
|
||||
ADMIN: 'Magistr',
|
||||
EDUCATION_OFFICE: 'Учебный отдел',
|
||||
DEPARTMENT: 'Кафедра',
|
||||
SCHEDULE_VIEWER: 'Расписания'
|
||||
});
|
||||
|
||||
export function allowedAdminTabs(role) {
|
||||
const capabilities = capabilitiesForRole(role);
|
||||
return new Set(capabilities ? capabilities.adminTabs : []);
|
||||
}
|
||||
|
||||
export function navSectionsForRole(role) {
|
||||
const allowed = allowedAdminTabs(role);
|
||||
return NAV_SECTIONS
|
||||
.map(section => ({
|
||||
title: section.title,
|
||||
items: section.items
|
||||
.filter(tab => allowed.has(tab))
|
||||
.map(tab => ({ id: tab, label: NAV_ITEMS[tab].navLabel }))
|
||||
}))
|
||||
.filter(section => section.items.length > 0);
|
||||
}
|
||||
|
||||
export function logoTitleForRole(role) {
|
||||
return LOGO_TITLES[role] || 'Magistr';
|
||||
}
|
||||
|
||||
export function defaultAdminTabForRole(role) {
|
||||
const capabilities = capabilitiesForRole(role);
|
||||
return capabilities ? capabilities.defaultAdminTab : null;
|
||||
}
|
||||
|
||||
export function tabFromHash(hash, role) {
|
||||
const allowed = allowedAdminTabs(role);
|
||||
const candidate = String(hash || '').replace('#', '');
|
||||
if (candidate && allowed.has(candidate)) return candidate;
|
||||
return defaultAdminTabForRole(role);
|
||||
}
|
||||
|
||||
export function requestCountLabel(count) {
|
||||
const value = Number(count) || 0;
|
||||
const mod10 = value % 10;
|
||||
const mod100 = value % 100;
|
||||
if (mod10 === 1 && mod100 !== 11) return `${value} заявка`;
|
||||
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return `${value} заявки`;
|
||||
return `${value} заявок`;
|
||||
}
|
||||
|
||||
export function navBadgeLabel(count) {
|
||||
const value = Number(count) || 0;
|
||||
return value > 99 ? '99+' : String(value);
|
||||
}
|
||||
|
||||
export function badgeVisibility(count) {
|
||||
const value = Number(count) || 0;
|
||||
return {
|
||||
hidden: value <= 0,
|
||||
ariaLabel: value > 0 ? `Заявок в очереди: ${value}` : 'Заявок в очереди нет'
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchPendingRequestsCount(client = apiClient) {
|
||||
const page = await client.get(
|
||||
'/api/teacher-requests/page?status=PENDING&page=0&size=1',
|
||||
{ errorMessage: 'Не удалось загрузить счётчик заявок' }
|
||||
);
|
||||
return Number(page?.totalItems || 0);
|
||||
}
|
||||
681
frontend/react/admin/auditorium-workload-service.js
Normal file
681
frontend/react/admin/auditorium-workload-service.js
Normal file
@@ -0,0 +1,681 @@
|
||||
import { apiClient } from '../shared/api-client.js';
|
||||
import { addDays, formatIsoDate, parseIsoDate, toIsoDate, trimTime } from '../shared/dates.js';
|
||||
|
||||
export const NO_BUILDING = '__no_building__';
|
||||
export const DISPLAY_ALL = 'all';
|
||||
export const TARGET_CLASSROOMS = 'classrooms';
|
||||
export const TARGET_TEACHERS = 'teachers';
|
||||
export const TARGET_DEPARTMENTS = 'departments';
|
||||
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
export const WORKLOAD_TARGETS = Object.freeze({
|
||||
[TARGET_CLASSROOMS]: Object.freeze({
|
||||
allLabel: 'Все аудитории',
|
||||
emptyLabel: 'Нет аудиторий по выбранным фильтрам',
|
||||
rowLabel: 'Аудитория',
|
||||
summaryLabel: 'Аудиторий',
|
||||
periodTitle: 'Нагрузка аудитории по неделям',
|
||||
selectError: 'Выберите аудиторию',
|
||||
prefix: 'room:'
|
||||
}),
|
||||
[TARGET_TEACHERS]: Object.freeze({
|
||||
allLabel: 'Все преподаватели',
|
||||
emptyLabel: 'Нет преподавателей',
|
||||
rowLabel: 'Преподаватель',
|
||||
summaryLabel: 'Преподавателей',
|
||||
periodTitle: 'Нагрузка преподавателя по неделям',
|
||||
selectError: 'Выберите преподавателя',
|
||||
prefix: 'teacher:'
|
||||
}),
|
||||
[TARGET_DEPARTMENTS]: Object.freeze({
|
||||
allLabel: 'Все кафедры',
|
||||
emptyLabel: 'Нет кафедр',
|
||||
rowLabel: 'Кафедра',
|
||||
summaryLabel: 'Кафедр',
|
||||
periodTitle: 'Нагрузка кафедры по неделям',
|
||||
selectError: 'Выберите кафедру',
|
||||
prefix: 'department:'
|
||||
})
|
||||
});
|
||||
|
||||
export const DAY_SHORT_LABELS = Object.freeze({
|
||||
1: 'Пн',
|
||||
2: 'Вт',
|
||||
3: 'Ср',
|
||||
4: 'Чт',
|
||||
5: 'Пт',
|
||||
6: 'Сб',
|
||||
7: 'Вс'
|
||||
});
|
||||
|
||||
export const DAY_FULL_LABELS = Object.freeze({
|
||||
1: 'Понедельник',
|
||||
2: 'Вторник',
|
||||
3: 'Среда',
|
||||
4: 'Четверг',
|
||||
5: 'Пятница',
|
||||
6: 'Суббота',
|
||||
7: 'Воскресенье'
|
||||
});
|
||||
|
||||
export const DAY_ORDER = Object.freeze([1, 2, 3, 4, 5, 6, 7]);
|
||||
|
||||
export const CELL_PARITY_LAYOUT = Object.freeze([
|
||||
Object.freeze({ parity: 'ODD', label: 'Нечётная' }),
|
||||
Object.freeze({ parity: 'EVEN', label: 'Чётная' })
|
||||
]);
|
||||
|
||||
// --- Контракты API ---
|
||||
|
||||
export function fetchWorkloadClassrooms(client = apiClient) {
|
||||
return client.get('/api/classrooms', { errorMessage: 'Ошибка загрузки аудиторий' });
|
||||
}
|
||||
|
||||
export function fetchWorkloadEquipments(client = apiClient) {
|
||||
return client.get('/api/equipments', { errorMessage: 'Ошибка загрузки оборудования' });
|
||||
}
|
||||
|
||||
export function fetchWorkloadGroups(client = apiClient) {
|
||||
return client.get('/api/groups', { errorMessage: 'Ошибка загрузки групп' });
|
||||
}
|
||||
|
||||
// Справочник учебных годов не критичен для расчёта: сбой молча даёт пустой список.
|
||||
export async function fetchAcademicYears(client = apiClient) {
|
||||
try {
|
||||
return await client.get('/api/admin/calendar/years', {
|
||||
errorMessage: 'Ошибка загрузки учебных годов'
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function fetchWorkloadTeachers(client = apiClient) {
|
||||
return client.get('/api/users/teachers', { errorMessage: 'Ошибка загрузки преподавателей' });
|
||||
}
|
||||
|
||||
export function fetchWorkloadDepartments(client = apiClient) {
|
||||
return client.get('/api/departments', { errorMessage: 'Ошибка загрузки кафедр' });
|
||||
}
|
||||
|
||||
export function effectiveTimeSlotsEndpoint(date) {
|
||||
return `/api/admin/time-slots/effective?date=${encodeURIComponent(date)}`;
|
||||
}
|
||||
|
||||
export function fetchEffectiveTimeSlots(date, client = apiClient) {
|
||||
return client.get(effectiveTimeSlotsEndpoint(date), {
|
||||
errorMessage: 'Ошибка загрузки сетки времени'
|
||||
});
|
||||
}
|
||||
|
||||
export function groupScheduleEndpoint(group, startDate, endDate) {
|
||||
return `/api/schedule?${new URLSearchParams({
|
||||
groupId: String(group.id),
|
||||
startDate,
|
||||
endDate
|
||||
})}`;
|
||||
}
|
||||
|
||||
export function fetchGroupSchedule(group, startDate, endDate, client = apiClient) {
|
||||
return client.get(groupScheduleEndpoint(group, startDate, endDate), {
|
||||
errorMessage: 'Ошибка загрузки расписания'
|
||||
});
|
||||
}
|
||||
|
||||
// --- Справочники ---
|
||||
|
||||
export function sortClassrooms(classrooms) {
|
||||
return [...(classrooms || [])].sort((a, b) => naturalCompare(a.name, b.name));
|
||||
}
|
||||
|
||||
export function sortTeachers(teachers) {
|
||||
return [...(teachers || [])].sort((a, b) => naturalCompare(userName(a), userName(b)));
|
||||
}
|
||||
|
||||
export function sortDepartments(departments) {
|
||||
return [...(departments || [])].sort((a, b) => naturalCompare(departmentName(a), departmentName(b)));
|
||||
}
|
||||
|
||||
export function semestersFromYears(academicYears) {
|
||||
return (academicYears || []).flatMap(year => year.semesters || []);
|
||||
}
|
||||
|
||||
export function userName(user) {
|
||||
return user?.fullName || user?.username || 'Преподаватель без имени';
|
||||
}
|
||||
|
||||
export function departmentName(department) {
|
||||
return department?.departmentName || department?.name || 'Кафедра без названия';
|
||||
}
|
||||
|
||||
export function buildingKey(room) {
|
||||
return room.building ? String(room.building) : NO_BUILDING;
|
||||
}
|
||||
|
||||
export function buildingOptions(classrooms) {
|
||||
const values = new Map();
|
||||
(classrooms || []).forEach(room => {
|
||||
const key = buildingKey(room);
|
||||
values.set(key, key === NO_BUILDING ? 'Корпус не указан' : room.building);
|
||||
});
|
||||
return Array.from(values.entries())
|
||||
.map(([value, label]) => ({ value, label }))
|
||||
.sort((a, b) => naturalCompare(a.label, b.label));
|
||||
}
|
||||
|
||||
export function equipmentOptions(equipments, classrooms) {
|
||||
const source = (equipments || []).length
|
||||
? equipments
|
||||
: (classrooms || []).flatMap(room => room.equipments || []);
|
||||
const values = new Map();
|
||||
source.forEach(item => {
|
||||
if (item?.id && item?.name) values.set(String(item.id), item.name);
|
||||
});
|
||||
return Array.from(values.entries())
|
||||
.map(([id, name]) => ({ id, name }))
|
||||
.sort((a, b) => naturalCompare(a.name, b.name));
|
||||
}
|
||||
|
||||
export function entityOptionLabel(target, entity) {
|
||||
if (target === TARGET_TEACHERS) {
|
||||
const details = [entity.jobTitle, entity.departmentName].filter(Boolean).join(', ');
|
||||
return details ? `${userName(entity)} · ${details}` : userName(entity);
|
||||
}
|
||||
if (target === TARGET_DEPARTMENTS) {
|
||||
const code = entity.departmentCode ? `код ${entity.departmentCode}` : '';
|
||||
return code ? `${departmentName(entity)} · ${code}` : departmentName(entity);
|
||||
}
|
||||
const details = [entity.building, entity.floor ? `${entity.floor} этаж` : null].filter(Boolean).join(', ');
|
||||
return details ? `${entity.name} · ${details}` : entity.name;
|
||||
}
|
||||
|
||||
// Части подписи оси таблицы: заголовок, подзаголовок и примечание сущности.
|
||||
export function entityAxisParts(target, entity) {
|
||||
if (target === TARGET_TEACHERS) {
|
||||
return {
|
||||
title: userName(entity),
|
||||
subtitle: entity.jobTitle || 'Должность не указана',
|
||||
note: entity.departmentName || ''
|
||||
};
|
||||
}
|
||||
if (target === TARGET_DEPARTMENTS) {
|
||||
return {
|
||||
title: departmentName(entity),
|
||||
subtitle: entity.departmentCode ? `Код: ${entity.departmentCode}` : 'Код не указан',
|
||||
note: ''
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: entity.name,
|
||||
subtitle: entity.capacity ? `${entity.capacity} мест` : 'Вместимость не указана',
|
||||
note: entity.building
|
||||
? `${entity.building}${entity.floor ? `, ${entity.floor} этаж` : ''}`
|
||||
: ''
|
||||
};
|
||||
}
|
||||
|
||||
export function entityName(target, entity) {
|
||||
if (target === TARGET_TEACHERS) return userName(entity);
|
||||
if (target === TARGET_DEPARTMENTS) return departmentName(entity);
|
||||
return entity?.name || 'Аудитория без названия';
|
||||
}
|
||||
|
||||
export function entitySummaryDetails(target, entity) {
|
||||
if (target === TARGET_TEACHERS) {
|
||||
return [entity.jobTitle, entity.departmentName].filter(Boolean).join(', ');
|
||||
}
|
||||
if (target === TARGET_DEPARTMENTS) {
|
||||
return entity.departmentCode ? `код ${entity.departmentCode}` : '';
|
||||
}
|
||||
return [
|
||||
entity.capacity ? `${entity.capacity} мест` : null,
|
||||
entity.building || null,
|
||||
entity.floor ? `${entity.floor} этаж` : null
|
||||
].filter(Boolean).join(', ');
|
||||
}
|
||||
|
||||
// --- Фильтры аудиторий ---
|
||||
|
||||
export function parseMinCapacity(value) {
|
||||
return parseInt(value || '0', 10) || 0;
|
||||
}
|
||||
|
||||
export function filteredClassrooms(classrooms, { buildings = [], minCapacity = 0, equipment = [] } = {}) {
|
||||
return (classrooms || []).filter(room => {
|
||||
const buildingMatches = !buildings.length || buildings.includes(buildingKey(room));
|
||||
const capacityMatches = !minCapacity || Number(room.capacity || 0) >= minCapacity;
|
||||
const equipmentMatches = !equipment.length || equipment.every(id =>
|
||||
(room.equipments || []).some(item => String(item.id) === String(id))
|
||||
);
|
||||
return buildingMatches && capacityMatches && equipmentMatches;
|
||||
});
|
||||
}
|
||||
|
||||
// --- Занятия ---
|
||||
|
||||
export function uniqueLessons(lessons) {
|
||||
const unique = new Map();
|
||||
(lessons || []).forEach(lesson => {
|
||||
if (!lesson.timeSlotId) return;
|
||||
const key = [
|
||||
lesson.date,
|
||||
lesson.scheduleRuleSlotId || lesson.scheduleRuleId || '',
|
||||
lesson.classroomId,
|
||||
lesson.timeSlotId,
|
||||
lesson.subjectId || '',
|
||||
lesson.teacherId || ''
|
||||
].join(':');
|
||||
const existing = unique.get(key);
|
||||
if (existing) {
|
||||
existing.groupNames = mergeNames(existing.groupNames, lesson.groupNames);
|
||||
existing.groupIds = mergeIds(existing.groupIds, lesson.groupIds);
|
||||
existing.subgroupNames = mergeNames(existing.subgroupNames, lesson.subgroupNames);
|
||||
existing.subgroupIds = mergeIds(existing.subgroupIds, lesson.subgroupIds);
|
||||
return;
|
||||
}
|
||||
unique.set(key, {
|
||||
...lesson,
|
||||
groupNames: [...(lesson.groupNames || [])],
|
||||
groupIds: [...(lesson.groupIds || [])],
|
||||
subgroupNames: [...(lesson.subgroupNames || [])],
|
||||
subgroupIds: [...(lesson.subgroupIds || [])]
|
||||
});
|
||||
});
|
||||
return Array.from(unique.values());
|
||||
}
|
||||
|
||||
export function teacherDepartmentId(teacherId, teachers, departments) {
|
||||
const teacher = (teachers || []).find(item => sameId(item.id, teacherId));
|
||||
if (!teacher) return null;
|
||||
if (teacher.departmentId != null) return teacher.departmentId;
|
||||
if (!teacher.departmentName) return null;
|
||||
return (departments || []).find(department => departmentName(department) === teacher.departmentName)?.id ?? null;
|
||||
}
|
||||
|
||||
export function entityIdsForLesson(lesson, target, teachers, departments) {
|
||||
if (target === TARGET_TEACHERS) {
|
||||
return lesson.teacherId ? [lesson.teacherId] : [];
|
||||
}
|
||||
if (target === TARGET_DEPARTMENTS) {
|
||||
const departmentId = teacherDepartmentId(lesson.teacherId, teachers, departments);
|
||||
return departmentId == null ? [] : [departmentId];
|
||||
}
|
||||
return lesson.classroomId ? [lesson.classroomId] : [];
|
||||
}
|
||||
|
||||
export function groupLessonsByEntitySlot(lessons, target, teachers, departments) {
|
||||
const grouped = new Map();
|
||||
uniqueLessons(lessons).forEach(lesson => {
|
||||
entityIdsForLesson(lesson, target, teachers, departments).forEach(entityId => {
|
||||
const key = entitySlotKey(entityId, lesson.timeSlotId);
|
||||
if (!grouped.has(key)) grouped.set(key, []);
|
||||
grouped.get(key).push(lesson);
|
||||
});
|
||||
});
|
||||
grouped.forEach(items => {
|
||||
items.sort((a, b) => naturalCompare(a.subjectName, b.subjectName));
|
||||
});
|
||||
return grouped;
|
||||
}
|
||||
|
||||
// --- Асинхронные загрузки (повторяют legacy allSettled-стратегию) ---
|
||||
|
||||
export async function loadSlotsForDates(dates, client = apiClient) {
|
||||
const responses = await Promise.allSettled(
|
||||
dates.map(date => fetchEffectiveTimeSlots(date, client))
|
||||
);
|
||||
const slotsByDate = new Map();
|
||||
responses.forEach((response, index) => {
|
||||
const slots = response.status === 'fulfilled' ? response.value || [] : [];
|
||||
slotsByDate.set(dates[index], [...slots].sort(compareSlots));
|
||||
});
|
||||
return {
|
||||
slotsByDate,
|
||||
failedLoads: responses.filter(response => response.status === 'rejected').length
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadOverviewLessons({ groups, date, target, teachers, departments }, client = apiClient) {
|
||||
if (!(groups || []).length) {
|
||||
return { lessonsByEntitySlot: new Map(), failedLoads: 0 };
|
||||
}
|
||||
const responses = await Promise.allSettled(
|
||||
groups.map(group => fetchGroupSchedule(group, date, date, client))
|
||||
);
|
||||
const lessons = responses
|
||||
.filter(response => response.status === 'fulfilled')
|
||||
.flatMap(response => response.value || []);
|
||||
return {
|
||||
lessonsByEntitySlot: groupLessonsByEntitySlot(lessons, target, teachers, departments),
|
||||
failedLoads: responses.filter(response => response.status === 'rejected').length
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadEntityLessons({ groups, startDate, endDate, entityId, target, teachers, departments }, client = apiClient) {
|
||||
if (!(groups || []).length) {
|
||||
return {
|
||||
lessons: [],
|
||||
lessonsByDateSlot: new Map(),
|
||||
parityLessons: [],
|
||||
failedLoads: 0
|
||||
};
|
||||
}
|
||||
const responses = await Promise.allSettled(
|
||||
groups.map(group => fetchGroupSchedule(group, startDate, endDate, client))
|
||||
);
|
||||
const parityLessons = responses
|
||||
.filter(response => response.status === 'fulfilled')
|
||||
.flatMap(response => response.value || []);
|
||||
const lessons = uniqueLessons(parityLessons)
|
||||
.filter(lesson => entityIdsForLesson(lesson, target, teachers, departments).some(id => sameId(id, entityId)));
|
||||
const lessonsByDateSlot = new Map();
|
||||
lessons.forEach(lesson => {
|
||||
const slotKey = lessonSlotKey(lesson);
|
||||
if (!lesson.date || slotKey == null) return;
|
||||
const key = dateSlotKey(lesson.date, slotKey);
|
||||
if (!lessonsByDateSlot.has(key)) lessonsByDateSlot.set(key, []);
|
||||
lessonsByDateSlot.get(key).push(lesson);
|
||||
});
|
||||
lessonsByDateSlot.forEach(items => {
|
||||
items.sort((a, b) => naturalCompare(a.subjectName, b.subjectName));
|
||||
});
|
||||
return {
|
||||
lessons,
|
||||
lessonsByDateSlot,
|
||||
parityLessons,
|
||||
failedLoads: responses.filter(response => response.status === 'rejected').length
|
||||
};
|
||||
}
|
||||
|
||||
// --- Ключи и метки слотов ---
|
||||
|
||||
export function entitySlotKey(entityId, timeSlotId) {
|
||||
return `${entityId}:${timeSlotId}`;
|
||||
}
|
||||
|
||||
export function dateSlotKey(date, slotKey) {
|
||||
return `${date}:${slotKey}`;
|
||||
}
|
||||
|
||||
export function slotOrderKey(slot) {
|
||||
const value = slot?.orderNumber ?? slot?.timeSlotOrder ?? slot?.id;
|
||||
return value == null ? null : String(value);
|
||||
}
|
||||
|
||||
export function lessonSlotKey(lesson) {
|
||||
const value = lesson?.timeSlotOrder ?? lesson?.timeSlotId;
|
||||
return value == null ? null : String(value);
|
||||
}
|
||||
|
||||
export function timeSlotLabel(slot) {
|
||||
return `${trimTime(slot.startTime)}-${trimTime(slot.endTime)}`;
|
||||
}
|
||||
|
||||
export function scopeShort(slot) {
|
||||
if (slot.scopeApplyMode === 'WEEKDAY') {
|
||||
return dayShort(slot.scopeDayOfWeek);
|
||||
}
|
||||
return 'Ручн.';
|
||||
}
|
||||
|
||||
export function dayShort(dayOfWeek) {
|
||||
return DAY_SHORT_LABELS[Number(dayOfWeek)] || '-';
|
||||
}
|
||||
|
||||
export function isoDay(value) {
|
||||
const day = parseIsoDate(value).getDay();
|
||||
return day === 0 ? 7 : day;
|
||||
}
|
||||
|
||||
// --- Двухнедельный период ---
|
||||
|
||||
export function twoWeekRange(value) {
|
||||
const selected = parseIsoDate(value);
|
||||
const start = addDays(selected, 1 - isoDay(value));
|
||||
const end = addDays(start, 13);
|
||||
return {
|
||||
startDate: toIsoDate(start),
|
||||
endDate: toIsoDate(end)
|
||||
};
|
||||
}
|
||||
|
||||
export function datesBetween(startDate, endDate) {
|
||||
const dates = [];
|
||||
for (let date = parseIsoDate(startDate); date <= parseIsoDate(endDate); date = addDays(date, 1)) {
|
||||
dates.push(toIsoDate(date));
|
||||
}
|
||||
return dates;
|
||||
}
|
||||
|
||||
// --- Чётность недель ---
|
||||
|
||||
export function weekNumberForDate(date, semesters) {
|
||||
const semester = (semesters || []).find(item => item.startDate <= date && item.endDate >= date);
|
||||
if (!semester) return null;
|
||||
const days = Math.floor((parseIsoDate(date) - parseIsoDate(semester.startDate)) / MS_PER_DAY);
|
||||
return Math.floor(days / 7) + 1;
|
||||
}
|
||||
|
||||
export function parityForDate(date, lessons, semesters) {
|
||||
const lessonParity = (lessons || []).find(lesson =>
|
||||
lesson.date === date && ['ODD', 'EVEN'].includes(lesson.parity)
|
||||
)?.parity;
|
||||
if (lessonParity) return lessonParity;
|
||||
const weekNumber = weekNumberForDate(date, semesters);
|
||||
if (!weekNumber) return null;
|
||||
return weekNumber % 2 === 0 ? 'EVEN' : 'ODD';
|
||||
}
|
||||
|
||||
export function buildDateGridByDay(dates, lessons, semesters) {
|
||||
const datesByDay = {};
|
||||
let unknownParityDates = 0;
|
||||
dates.forEach((date, index) => {
|
||||
let parity = parityForDate(date, lessons, semesters);
|
||||
if (!parity) {
|
||||
unknownParityDates += 1;
|
||||
parity = index < 7 ? 'ODD' : 'EVEN';
|
||||
}
|
||||
const day = isoDay(date);
|
||||
if (!datesByDay[day]) datesByDay[day] = {};
|
||||
datesByDay[day][parity] = date;
|
||||
});
|
||||
return { datesByDay, unknownParityDates };
|
||||
}
|
||||
|
||||
export function uniqueWeekNumbers(dates, semesters) {
|
||||
return Array.from(new Set(dates.map(date => weekNumberForDate(date, semesters)).filter(Boolean)))
|
||||
.sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
export function roomWeekCaption(datesByDay, semesters) {
|
||||
return CELL_PARITY_LAYOUT
|
||||
.map(({ parity, label }) => {
|
||||
const dates = DAY_ORDER.map(day => datesByDay[day]?.[parity]).filter(Boolean);
|
||||
const weeks = uniqueWeekNumbers(dates, semesters);
|
||||
const weekText = weeks.length ? `учебная неделя ${weeks.join(', ')}` : 'неделя вне семестра';
|
||||
const positionText = parity === 'ODD' ? 'сверху' : 'снизу';
|
||||
return `${label} ${positionText}, ${weekText}`;
|
||||
})
|
||||
.join('; ');
|
||||
}
|
||||
|
||||
// --- Строки слотов сводной недели ---
|
||||
|
||||
export function buildSlotRows(dates, slotsByDate, lessons) {
|
||||
const rows = new Map();
|
||||
dates.forEach(date => {
|
||||
(slotsByDate.get(date) || []).forEach(slot => {
|
||||
const key = slotOrderKey(slot);
|
||||
if (key == null) return;
|
||||
putSlotRow(rows, key, slot.orderNumber, slot.startTime, slot.endTime);
|
||||
});
|
||||
});
|
||||
(lessons || []).forEach(lesson => {
|
||||
const key = lessonSlotKey(lesson);
|
||||
if (key == null) return;
|
||||
putSlotRow(rows, key, lesson.timeSlotOrder, lesson.startTime, lesson.endTime);
|
||||
});
|
||||
return Array.from(rows.values()).sort(compareSlotRows);
|
||||
}
|
||||
|
||||
function putSlotRow(rows, key, orderNumber, startTime, endTime) {
|
||||
const existing = rows.get(key);
|
||||
const next = {
|
||||
key,
|
||||
orderNumber,
|
||||
startTime,
|
||||
endTime,
|
||||
orderLabel: orderNumber ? `${orderNumber} пара` : 'Пара',
|
||||
timeLabel: startTime || endTime ? `${trimTime(startTime)}-${trimTime(endTime)}` : 'Время не указано'
|
||||
};
|
||||
if (!existing || existing.timeLabel === 'Время не указано') {
|
||||
rows.set(key, next);
|
||||
}
|
||||
}
|
||||
|
||||
export function hasSlotForDate(date, key, slotsByDate) {
|
||||
return (slotsByDate.get(date) || []).some(slot => slotOrderKey(slot) === key);
|
||||
}
|
||||
|
||||
// --- Состояния ячеек сводной недели ---
|
||||
|
||||
export function roomSlotState(date, row, slotsByDate, lessonsByDateSlot) {
|
||||
if (!date) {
|
||||
return { kind: 'empty', lessons: [] };
|
||||
}
|
||||
const key = dateSlotKey(date, row.key);
|
||||
const lessons = lessonsByDateSlot.get(key) || [];
|
||||
const slotExists = hasSlotForDate(date, row.key, slotsByDate) || lessons.length > 0;
|
||||
if (lessons.length) {
|
||||
return { kind: 'busy', lessons };
|
||||
}
|
||||
if (!slotExists) {
|
||||
return { kind: 'missing', lessons: [] };
|
||||
}
|
||||
return { kind: 'free', lessons: [] };
|
||||
}
|
||||
|
||||
export function roomSlotStatesEqual(first, second) {
|
||||
if (first.kind !== second.kind) return false;
|
||||
if (first.kind !== 'busy') return true;
|
||||
return lessonSetSignature(first.lessons) === lessonSetSignature(second.lessons);
|
||||
}
|
||||
|
||||
function lessonSetSignature(lessons) {
|
||||
return lessons
|
||||
.map(lessonContentSignature)
|
||||
.sort((a, b) => naturalCompare(a, b))
|
||||
.join('|');
|
||||
}
|
||||
|
||||
function lessonContentSignature(lesson) {
|
||||
return [
|
||||
lesson.scheduleRuleSlotId || lesson.scheduleRuleId || '',
|
||||
lesson.subjectId || lesson.subjectName || '',
|
||||
lesson.teacherId || lesson.teacherName || '',
|
||||
lesson.lessonTypeId || lesson.lessonTypeName || '',
|
||||
lesson.lessonFormat || '',
|
||||
valueListSignature(lesson.groupIds, lesson.groupNames),
|
||||
valueListSignature(lesson.subgroupIds, lesson.subgroupNames)
|
||||
].join(':');
|
||||
}
|
||||
|
||||
function valueListSignature(primary = [], fallback = []) {
|
||||
const primaryList = Array.isArray(primary) ? primary : [];
|
||||
const fallbackList = Array.isArray(fallback) ? fallback : [];
|
||||
const source = primaryList.length ? primaryList : fallbackList;
|
||||
return [...source]
|
||||
.map(value => String(value))
|
||||
.sort((a, b) => naturalCompare(a, b))
|
||||
.join(',');
|
||||
}
|
||||
|
||||
// --- Счётчики ---
|
||||
|
||||
export function countOccupiedCells(entities, timeSlots, lessonsByEntitySlot) {
|
||||
return (entities || []).reduce((count, entity) => count + (timeSlots || []).filter(slot =>
|
||||
(lessonsByEntitySlot.get(entitySlotKey(entity.id, slot.id)) || []).length > 0
|
||||
).length, 0);
|
||||
}
|
||||
|
||||
export function countAvailableRoomCells(dates, slotsByDate, lessonsByDateSlot) {
|
||||
return (dates || []).reduce((count, date) => {
|
||||
const keys = new Set((slotsByDate.get(date) || []).map(slotOrderKey).filter(key => key != null));
|
||||
lessonsByDateSlot.forEach((items, key) => {
|
||||
if (items.length && key.startsWith(`${date}:`)) {
|
||||
keys.add(key.slice(date.length + 1));
|
||||
}
|
||||
});
|
||||
return count + keys.size;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
export function countOccupiedRoomCells(lessonsByDateSlot) {
|
||||
return Array.from(lessonsByDateSlot.values()).filter(items => items.length > 0).length;
|
||||
}
|
||||
|
||||
// --- Сводки ---
|
||||
|
||||
export function overviewSummaryText({ target, dateValue, entityCount, occupiedCells, totalCells, failedGroupLoads, selectedEntityId }) {
|
||||
const config = WORKLOAD_TARGETS[target] || WORKLOAD_TARGETS[TARGET_CLASSROOMS];
|
||||
const percent = totalCells ? Math.round((occupiedCells / totalCells) * 100) : 0;
|
||||
const date = dateValue ? formatIsoDate(dateValue) : '-';
|
||||
const day = dateValue ? dayShort(isoDay(dateValue)) : '-';
|
||||
const failed = failedGroupLoads ? `, ошибок загрузки групп: ${failedGroupLoads}` : '';
|
||||
const selected = selectedEntityId ? ', выбран один объект' : '';
|
||||
return `Дата: ${date}, ${day}. ${config.summaryLabel}: ${entityCount}${selected}. Занятых слотов: ${occupiedCells} из ${totalCells} (${percent}%)${failed}.`;
|
||||
}
|
||||
|
||||
export function roomSummaryParts({ target, entity, range, totalCells, occupiedCells, failedGroupLoads, failedSlotLoads, unknownParityDates }) {
|
||||
const percent = totalCells ? Math.round((occupiedCells / totalCells) * 100) : 0;
|
||||
const warnings = [];
|
||||
if (failedGroupLoads) warnings.push(`ошибок групп: ${failedGroupLoads}`);
|
||||
if (failedSlotLoads) warnings.push(`ошибок сеток: ${failedSlotLoads}`);
|
||||
if (unknownParityDates) warnings.push('часть дат вне семестра');
|
||||
return {
|
||||
name: entityName(target, entity),
|
||||
details: entitySummaryDetails(target, entity),
|
||||
rangeLabel: `${formatIsoDate(range.startDate)} - ${formatIsoDate(range.endDate)}`,
|
||||
occupancyLabel: `Занято: ${occupiedCells} из ${totalCells} (${percent}%)`,
|
||||
warnings: warnings.join(', ')
|
||||
};
|
||||
}
|
||||
|
||||
export function formatDateShort(value) {
|
||||
const [, month, day] = String(value).split('-');
|
||||
return `${day}.${month}`;
|
||||
}
|
||||
|
||||
// --- Сравнения ---
|
||||
|
||||
export function compareSlots(a, b) {
|
||||
return Number(a.orderNumber || 0) - Number(b.orderNumber || 0)
|
||||
|| naturalCompare(a.startTime, b.startTime);
|
||||
}
|
||||
|
||||
export function compareSlotRows(a, b) {
|
||||
const first = Number(a.orderNumber || a.key);
|
||||
const second = Number(b.orderNumber || b.key);
|
||||
if (!Number.isNaN(first) && !Number.isNaN(second) && first !== second) {
|
||||
return first - second;
|
||||
}
|
||||
return naturalCompare(a.startTime, b.startTime)
|
||||
|| naturalCompare(a.key, b.key);
|
||||
}
|
||||
|
||||
export function sameId(first, second) {
|
||||
return String(first) === String(second);
|
||||
}
|
||||
|
||||
export function naturalCompare(a, b) {
|
||||
return String(a || '').localeCompare(String(b || ''), 'ru', { numeric: true, sensitivity: 'base' });
|
||||
}
|
||||
|
||||
function mergeNames(first = [], second = []) {
|
||||
return Array.from(new Set([...first, ...second].filter(Boolean))).sort((a, b) => naturalCompare(a, b));
|
||||
}
|
||||
|
||||
function mergeIds(first = [], second = []) {
|
||||
return Array.from(new Set([...first, ...second].filter(id => id != null)));
|
||||
}
|
||||
87
frontend/react/admin/classrooms-service.js
Normal file
87
frontend/react/admin/classrooms-service.js
Normal file
@@ -0,0 +1,87 @@
|
||||
import { apiClient } from '../shared/api-client.js';
|
||||
|
||||
export async function fetchEquipments(client = apiClient) {
|
||||
try {
|
||||
return await client.get('/api/equipments');
|
||||
} catch {
|
||||
console.error('Не удалось загрузить список оборудования');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function fetchClassrooms(client = apiClient) {
|
||||
return client.get(
|
||||
'/api/classrooms?includeArchived=true',
|
||||
{ errorMessage: 'Не удалось загрузить список аудиторий' }
|
||||
);
|
||||
}
|
||||
|
||||
export function createClassroom({ name, capacity, equipmentIds }, client = apiClient) {
|
||||
return client.post(
|
||||
'/api/classrooms',
|
||||
{ name, capacity, equipmentIds, isAvailable: true },
|
||||
{ errorMessage: 'Ошибка создания' }
|
||||
);
|
||||
}
|
||||
|
||||
export function updateClassroom(id, payload, client = apiClient) {
|
||||
return client.put(`/api/classrooms/${id}`, payload, { errorMessage: 'Ошибка обновления' });
|
||||
}
|
||||
|
||||
export function toggleClassroomAvailability(id, isAvailable, client = apiClient) {
|
||||
return updateClassroom(id, { isAvailable }, client);
|
||||
}
|
||||
|
||||
export function archiveClassroom(id, client = apiClient) {
|
||||
return client.delete(`/api/classrooms/${id}`, { errorMessage: 'Не удалось архивировать аудиторию' });
|
||||
}
|
||||
|
||||
export function restoreClassroom(id, client = apiClient) {
|
||||
return client.post(
|
||||
`/api/classrooms/${id}/restore`,
|
||||
{},
|
||||
{ errorMessage: 'Не удалось восстановить аудиторию' }
|
||||
);
|
||||
}
|
||||
|
||||
export function createEquipment(name, client = apiClient) {
|
||||
return client.post('/api/equipments', { name }, { errorMessage: 'Ошибка добавления' });
|
||||
}
|
||||
|
||||
export function deleteEquipment(id, client = apiClient) {
|
||||
return client.delete(`/api/equipments/${id}`, { errorMessage: 'Не удалось удалить оборудование' });
|
||||
}
|
||||
|
||||
export function parseCapacity(value) {
|
||||
return parseInt(value, 10);
|
||||
}
|
||||
|
||||
export function activeClassrooms(classrooms) {
|
||||
return (classrooms || []).filter(classroom => classroom.isAvailable && classroom.status !== 'ARCHIVED');
|
||||
}
|
||||
|
||||
export function classroomStatusLabel(classroom) {
|
||||
if (classroom.status === 'ARCHIVED') return 'Архив';
|
||||
return classroom.isAvailable ? 'Доступна' : 'Не доступна';
|
||||
}
|
||||
|
||||
export function equipmentNamesText(classroom) {
|
||||
const names = (classroom.equipments || []).map(equipment => equipment.name);
|
||||
return names.length ? names.join(', ') : null;
|
||||
}
|
||||
|
||||
export function equipmentSelectLabel(selectedNames) {
|
||||
const names = selectedNames || [];
|
||||
if (names.length === 0) return 'Выберите оборудование...';
|
||||
if (names.length === 1) return names[0];
|
||||
return `Выбрано: ${names.length}`;
|
||||
}
|
||||
|
||||
export function equipmentIdSelection(equipments, selectedIds) {
|
||||
const selected = new Set((selectedIds || []).map(String));
|
||||
return (equipments || []).map(equipment => ({
|
||||
id: equipment.id,
|
||||
name: equipment.name,
|
||||
checked: selected.has(String(equipment.id))
|
||||
}));
|
||||
}
|
||||
232
frontend/react/admin/dashboard-service.js
Normal file
232
frontend/react/admin/dashboard-service.js
Normal file
@@ -0,0 +1,232 @@
|
||||
import { apiClient } from '../shared/api-client.js';
|
||||
import {
|
||||
CONFLICT_CHECK_STATE,
|
||||
conflictCheckPresentation,
|
||||
currentWeekDateRange,
|
||||
formatLocalDate,
|
||||
loadDepartmentSchedules
|
||||
} from '../../admin/js/dashboard-conflicts.js';
|
||||
|
||||
export { CONFLICT_CHECK_STATE, conflictCheckPresentation, currentWeekDateRange, formatLocalDate };
|
||||
|
||||
export async function fetchDashboardReferenceData(client = apiClient) {
|
||||
const [departments, classrooms, groups, teachers] = await Promise.all([
|
||||
client.get('/api/departments', { errorMessage: 'Не удалось загрузить кафедры' }),
|
||||
client.get('/api/classrooms?includeArchived=false', { errorMessage: 'Не удалось загрузить аудитории' }),
|
||||
client.get('/api/groups?includeArchived=false', { errorMessage: 'Не удалось загрузить группы' }),
|
||||
client.get('/api/users/teachers', { errorMessage: 'Не удалось загрузить преподавателей' })
|
||||
]);
|
||||
return { departments, classrooms, groups, teachers };
|
||||
}
|
||||
|
||||
export function todayScheduleEndpoint(departmentId, dateStr) {
|
||||
return `/api/schedule/search?departmentId=${departmentId}&startDate=${dateStr}&endDate=${dateStr}`;
|
||||
}
|
||||
|
||||
export async function fetchTodayLessons(departments, dateStr, client = apiClient) {
|
||||
const loads = (departments || []).map(department =>
|
||||
client.get(todayScheduleEndpoint(department.id, dateStr)).catch(() => [])
|
||||
);
|
||||
const perDepartment = await Promise.all(loads);
|
||||
return perDepartment.flat();
|
||||
}
|
||||
|
||||
export function weekScheduleEndpoint(departmentId, startDate, endDate) {
|
||||
return `/api/schedule/search?departmentId=${departmentId}&startDate=${startDate}&endDate=${endDate}`;
|
||||
}
|
||||
|
||||
export async function loadWeekDepartmentSchedules(departments, range, client = apiClient) {
|
||||
return loadDepartmentSchedules(
|
||||
departments,
|
||||
department => client.get(weekScheduleEndpoint(department.id, range.startDate, range.endDate))
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchEffectiveTimeSlots(dateStr, client = apiClient) {
|
||||
try {
|
||||
return await client.get(`/api/admin/time-slots/effective?date=${dateStr}`);
|
||||
} catch {
|
||||
const baseSlots = await client.get('/api/admin/time-slots').catch(() => []);
|
||||
return (baseSlots || []).filter(slot => slot.scopeId === 1 || !slot.scopeId);
|
||||
}
|
||||
}
|
||||
|
||||
export function sortTimeSlots(timeSlots) {
|
||||
return [...(timeSlots || [])].sort((a, b) => (a.orderNumber || 0) - (b.orderNumber || 0));
|
||||
}
|
||||
|
||||
export function totalStudents(groups) {
|
||||
return (groups || []).reduce((total, group) => total + (group.groupSize || 0), 0);
|
||||
}
|
||||
|
||||
export function activeClassrooms(classrooms) {
|
||||
return (classrooms || []).filter(classroom => classroom.isAvailable && classroom.status !== 'ARCHIVED');
|
||||
}
|
||||
|
||||
export function classroomOccupancyLabel(classrooms, todayLessons) {
|
||||
const occupied = new Set((todayLessons || []).map(lesson => lesson.classroomId).filter(Boolean));
|
||||
const active = activeClassrooms(classrooms);
|
||||
const percent = active.length ? Math.round((occupied.size / active.length) * 100) : 0;
|
||||
return `${percent}% (${occupied.size}/${active.length})`;
|
||||
}
|
||||
|
||||
export function currentTimeString(date = new Date()) {
|
||||
return date.toTimeString().split(' ')[0];
|
||||
}
|
||||
|
||||
export function findCurrentSlot(timeSlots, date = new Date()) {
|
||||
const time = currentTimeString(date);
|
||||
return (timeSlots || []).find(slot => time >= slot.startTime && time <= slot.endTime) || null;
|
||||
}
|
||||
|
||||
export function currentSlotLabel(slot) {
|
||||
if (!slot) return 'Вне сетки пар';
|
||||
return `Пара №${slot.orderNumber} (${slot.startTime.substring(0, 5)} – ${slot.endTime.substring(0, 5)})`;
|
||||
}
|
||||
|
||||
export function lessonsForSlot(lessons, slotOrder) {
|
||||
return (lessons || []).filter(lesson => lesson.timeSlotOrder === slotOrder);
|
||||
}
|
||||
|
||||
export function freeClassroomsForSlot(classrooms, lessons, slotOrder) {
|
||||
const active = activeClassrooms(classrooms);
|
||||
if (slotOrder === null || slotOrder === undefined) return active;
|
||||
const occupiedIds = new Set(
|
||||
lessonsForSlot(lessons, slotOrder).map(lesson => lesson.classroomId).filter(Boolean)
|
||||
);
|
||||
return active.filter(classroom => !occupiedIds.has(classroom.id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Выявляет конфликты расписания недели: накладки преподавателей, накладки
|
||||
* аудиторий и превышение вместимости. Описания возвращаются сегментами,
|
||||
* где { strong } выделяется полужирным при рендере.
|
||||
*/
|
||||
export function detectScheduleConflicts(weekLessons, groups, classrooms) {
|
||||
const lessons = weekLessons || [];
|
||||
const groupSizeMap = new Map((groups || []).map(group => [group.id, group.groupSize || 0]));
|
||||
const classroomCapacityMap = new Map((classrooms || []).map(classroom => [classroom.id, classroom.capacity || 0]));
|
||||
const conflicts = [];
|
||||
|
||||
collectTeacherCollisions(lessons, conflicts);
|
||||
collectClassroomCollisions(lessons, conflicts);
|
||||
collectCapacityOverflows(lessons, groupSizeMap, classroomCapacityMap, conflicts);
|
||||
|
||||
return conflicts;
|
||||
}
|
||||
|
||||
function collectTeacherCollisions(lessons, conflicts) {
|
||||
const slots = groupLessons(lessons, lesson => lesson.teacherId && `${lesson.date}_${lesson.timeSlotOrder}_${lesson.teacherId}`);
|
||||
|
||||
slots.forEach(collisions => {
|
||||
if (collisions.length < 2) return;
|
||||
const classroomIds = new Set(collisions.map(lesson => lesson.classroomId));
|
||||
if (classroomIds.size <= 1) return;
|
||||
|
||||
const first = collisions[0];
|
||||
const list = collisions
|
||||
.map(lesson => `ауд. ${lesson.classroomName} (группа ${lesson.groupNames.join(', ')})`)
|
||||
.join(', ');
|
||||
conflicts.push({
|
||||
type: 'TEACHER_COLLISION',
|
||||
title: 'Накладка в расписании преподавателя',
|
||||
severity: 'error',
|
||||
segments: [
|
||||
'Преподаватель ',
|
||||
{ strong: first.teacherName },
|
||||
' назначен одновременно в разные места: ',
|
||||
list,
|
||||
' на ',
|
||||
{ strong: `${first.date} (${first.dayName}), пара №${first.timeSlotOrder}` },
|
||||
'.'
|
||||
]
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function collectClassroomCollisions(lessons, conflicts) {
|
||||
const slots = groupLessons(lessons, lesson => lesson.classroomId && `${lesson.date}_${lesson.timeSlotOrder}_${lesson.classroomId}`);
|
||||
|
||||
slots.forEach(collisions => {
|
||||
if (collisions.length < 2) return;
|
||||
const teacherIds = new Set(collisions.map(lesson => lesson.teacherId));
|
||||
const subjectIds = new Set(collisions.map(lesson => lesson.subjectId));
|
||||
if (teacherIds.size <= 1 && subjectIds.size <= 1) return;
|
||||
|
||||
const first = collisions[0];
|
||||
const details = collisions
|
||||
.map(lesson => `препод. ${lesson.teacherName} (предмет: ${lesson.subjectName}, группа: ${lesson.groupNames.join(', ')})`)
|
||||
.join(' И ');
|
||||
conflicts.push({
|
||||
type: 'CLASSROOM_COLLISION',
|
||||
title: 'Накладка в аудитории',
|
||||
severity: 'error',
|
||||
segments: [
|
||||
'Аудитория ',
|
||||
{ strong: first.classroomName },
|
||||
' занята одновременно разными занятиями: ',
|
||||
details,
|
||||
' на ',
|
||||
{ strong: `${first.date} (${first.dayName}), пара №${first.timeSlotOrder}` },
|
||||
'.'
|
||||
]
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function collectCapacityOverflows(lessons, groupSizeMap, classroomCapacityMap, conflicts) {
|
||||
const slots = groupLessons(lessons, lesson => lesson.classroomId && `${lesson.date}_${lesson.timeSlotOrder}_${lesson.classroomId}`);
|
||||
|
||||
slots.forEach(collisions => {
|
||||
if (!collisions.length) return;
|
||||
const first = collisions[0];
|
||||
const capacity = classroomCapacityMap.get(first.classroomId) || 0;
|
||||
|
||||
const groupIds = new Set();
|
||||
collisions.forEach(lesson => {
|
||||
(lesson.groupIds || []).forEach(id => groupIds.add(id));
|
||||
});
|
||||
|
||||
let students = 0;
|
||||
groupIds.forEach(id => {
|
||||
students += groupSizeMap.get(id) || 0;
|
||||
});
|
||||
|
||||
if (students <= capacity) return;
|
||||
|
||||
const groupNamesList = collisions.map(lesson => lesson.groupNames.join(', ')).join(', ');
|
||||
conflicts.push({
|
||||
type: 'CAPACITY_OVERFLOW',
|
||||
title: 'Превышение вместимости аудитории',
|
||||
severity: 'warning',
|
||||
segments: [
|
||||
'Вместимость аудитории ',
|
||||
{ strong: first.classroomName },
|
||||
' составляет ',
|
||||
{ strong: `${capacity} чел.` },
|
||||
', однако на занятие назначено ',
|
||||
{ strong: `${students} человек` },
|
||||
` (группы: ${groupNamesList}) на `,
|
||||
{ strong: `${first.date} (${first.dayName}), пара №${first.timeSlotOrder}` },
|
||||
'.'
|
||||
]
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function groupLessons(lessons, keyOf) {
|
||||
const slots = new Map();
|
||||
lessons.forEach(lesson => {
|
||||
const key = keyOf(lesson);
|
||||
if (!key) return;
|
||||
if (!slots.has(key)) slots.set(key, []);
|
||||
slots.get(key).push(lesson);
|
||||
});
|
||||
return slots;
|
||||
}
|
||||
|
||||
export function conflictDescriptionText(conflict) {
|
||||
return (conflict.segments || [])
|
||||
.map(segment => (typeof segment === 'string' ? segment : segment.strong))
|
||||
.join('');
|
||||
}
|
||||
262
frontend/react/admin/department-workspace-service.js
Normal file
262
frontend/react/admin/department-workspace-service.js
Normal file
@@ -0,0 +1,262 @@
|
||||
import { apiClient } from '../shared/api-client.js';
|
||||
import { defaultSchedulePeriod } from '../../admin/js/schedule-period.js';
|
||||
import { toIsoDate } from '../shared/dates.js';
|
||||
|
||||
export const SUBJECT_TABLE_COLUMNS = 4;
|
||||
export const DEPARTMENT_REQUEST_TABLE_COLUMNS = 6;
|
||||
|
||||
// --- Справочники и период ---
|
||||
|
||||
export function fetchDepartments(client = apiClient) {
|
||||
return client.get('/api/departments', { errorMessage: 'Не удалось загрузить список кафедр' });
|
||||
}
|
||||
|
||||
// Селектор кафедры кабинета подписывает варианты только названием кафедры.
|
||||
export function departmentOptionLabel(department) {
|
||||
return department.departmentName || department.name || department.id;
|
||||
}
|
||||
|
||||
export function fetchSemesters(client = apiClient) {
|
||||
return client.get('/api/schedule/semesters', { errorMessage: 'Не удалось загрузить семестры' });
|
||||
}
|
||||
|
||||
export function defaultWorkloadPeriod(semesters, date = new Date()) {
|
||||
return defaultSchedulePeriod(semesters, toIsoDate(date));
|
||||
}
|
||||
|
||||
export function fallbackWorkloadPeriod(date = new Date()) {
|
||||
const end = new Date(date);
|
||||
end.setDate(date.getDate() + 7);
|
||||
return { startDate: toIsoDate(date), endDate: toIsoDate(end) };
|
||||
}
|
||||
|
||||
// --- Дисциплины кафедры ---
|
||||
|
||||
export function departmentSubjectsEndpoint(departmentId) {
|
||||
return `/api/department/subjects?departmentId=${encodeURIComponent(departmentId || '')}`;
|
||||
}
|
||||
|
||||
export function fetchDepartmentSubjects(departmentId, client = apiClient) {
|
||||
return client.get(departmentSubjectsEndpoint(departmentId), {
|
||||
errorMessage: 'Ошибка загрузки дисциплин'
|
||||
});
|
||||
}
|
||||
|
||||
export function importSubjectsPayload(rows, departmentId) {
|
||||
if (!departmentId) return { error: 'Выберите кафедру' };
|
||||
const payload = (rows || [])
|
||||
.filter(item => item.name)
|
||||
.map(item => ({
|
||||
code: item.code,
|
||||
name: item.name,
|
||||
departmentId: Number(departmentId)
|
||||
}));
|
||||
if (!payload.length) return { error: 'Заполните название хотя бы одной дисциплины' };
|
||||
return { payload };
|
||||
}
|
||||
|
||||
export function importSubjects(rows, client = apiClient) {
|
||||
return client.post('/api/department/subjects/import', rows, {
|
||||
errorMessage: 'Ошибка загрузки дисциплин'
|
||||
});
|
||||
}
|
||||
|
||||
// --- Комментарии к дисциплинам ---
|
||||
|
||||
export function fetchSubjectComments(subjectId, client = apiClient) {
|
||||
return client.get(`/api/department/subjects/${encodeURIComponent(subjectId)}/comments`, {
|
||||
errorMessage: 'Ошибка загрузки комментариев'
|
||||
});
|
||||
}
|
||||
|
||||
export function saveSubjectComment(subjectId, comment, client = apiClient) {
|
||||
return client.post(
|
||||
`/api/department/subjects/${encodeURIComponent(subjectId)}/comments`,
|
||||
{ comment },
|
||||
{ errorMessage: 'Ошибка сохранения комментария' }
|
||||
);
|
||||
}
|
||||
|
||||
// --- Преподаватели и нагрузка ---
|
||||
|
||||
export function departmentTeachersEndpoint(departmentId) {
|
||||
return `/api/department/teachers?departmentId=${encodeURIComponent(departmentId || '')}`;
|
||||
}
|
||||
|
||||
export function fetchDepartmentTeachers(departmentId, client = apiClient) {
|
||||
return client.get(departmentTeachersEndpoint(departmentId), {
|
||||
errorMessage: 'Ошибка загрузки преподавателей'
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchAllTeachers(client = apiClient) {
|
||||
return client.get('/api/users/teachers', {
|
||||
errorMessage: 'Ошибка загрузки преподавателей'
|
||||
});
|
||||
}
|
||||
|
||||
export function availableTeachers(allTeachers, assignedTeachers) {
|
||||
const assignedIds = new Set((assignedTeachers || []).map(teacher => String(teacher.id)));
|
||||
return (allTeachers || []).filter(teacher => !assignedIds.has(String(teacher.id)));
|
||||
}
|
||||
|
||||
export function teacherOptionLabel(teacher) {
|
||||
return teacher.fullName || teacher.username || `ID ${teacher.id}`;
|
||||
}
|
||||
|
||||
export function assignTeacherError(departmentId, teacherId) {
|
||||
if (!departmentId) return 'Выберите кафедру';
|
||||
if (!teacherId) return 'Выберите преподавателя';
|
||||
return null;
|
||||
}
|
||||
|
||||
export function assignTeacher(teacherId, departmentId, comment, client = apiClient) {
|
||||
return client.post(
|
||||
`/api/department/teachers/${encodeURIComponent(teacherId)}/assignments`,
|
||||
{
|
||||
departmentId: Number(departmentId),
|
||||
comment: comment || null
|
||||
},
|
||||
{ errorMessage: 'Ошибка привязки преподавателя' }
|
||||
);
|
||||
}
|
||||
|
||||
export function teacherWorkloadEndpoint({ startDate, endDate, departmentId }) {
|
||||
const params = new URLSearchParams({ startDate, endDate });
|
||||
if (departmentId) params.set('departmentId', departmentId);
|
||||
return `/api/workload/teachers?${params}`;
|
||||
}
|
||||
|
||||
export function fetchTeacherWorkload(params, client = apiClient) {
|
||||
return client.get(teacherWorkloadEndpoint(params), {
|
||||
errorMessage: 'Ошибка расчёта нагрузки'
|
||||
});
|
||||
}
|
||||
|
||||
// --- Заявки кафедры ---
|
||||
|
||||
export function departmentTeacherRequestsEndpoint(departmentId) {
|
||||
return `/api/department/teacher-requests?departmentId=${encodeURIComponent(departmentId || '')}`;
|
||||
}
|
||||
|
||||
export function fetchDepartmentTeacherRequests(departmentId, client = apiClient) {
|
||||
return client.get(departmentTeacherRequestsEndpoint(departmentId), {
|
||||
errorMessage: 'Ошибка загрузки заявок'
|
||||
});
|
||||
}
|
||||
|
||||
export function teacherRequestPayload(fields, departmentId) {
|
||||
if (!departmentId) return { error: 'Выберите кафедру' };
|
||||
const username = (fields.username || '').trim();
|
||||
const fullName = (fields.fullName || '').trim();
|
||||
if (!username || !fullName) return { error: 'Заполните логин и ФИО преподавателя' };
|
||||
return {
|
||||
payload: {
|
||||
departmentId: Number(departmentId),
|
||||
username,
|
||||
fullName,
|
||||
jobTitle: (fields.jobTitle || '').trim(),
|
||||
comment: (fields.comment || '').trim()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function submitTeacherRequest(payload, client = apiClient) {
|
||||
return client.post('/api/department/teacher-requests', payload, {
|
||||
errorMessage: 'Ошибка отправки заявки'
|
||||
});
|
||||
}
|
||||
|
||||
// --- Метки ---
|
||||
|
||||
export function subjectCountLabel(count) {
|
||||
const tail = count % 100;
|
||||
if (tail >= 11 && tail <= 14) return `${count} дисциплин`;
|
||||
if (count % 10 === 1) return `${count} дисциплина`;
|
||||
if ([2, 3, 4].includes(count % 10)) return `${count} дисциплины`;
|
||||
return `${count} дисциплин`;
|
||||
}
|
||||
|
||||
export function requestStatusLabel(status) {
|
||||
return {
|
||||
PENDING: 'На рассмотрении',
|
||||
APPROVED: 'Одобрена',
|
||||
REJECTED: 'Отклонена'
|
||||
}[status] || status || '-';
|
||||
}
|
||||
|
||||
export function requestStatusClass(status) {
|
||||
return {
|
||||
PENDING: 'badge-ef',
|
||||
APPROVED: 'badge-student',
|
||||
REJECTED: 'badge-admin'
|
||||
}[status] || 'badge-ef';
|
||||
}
|
||||
|
||||
export function formatDateTime(value) {
|
||||
if (!value) return '-';
|
||||
return String(value).replace('T', ' ').slice(0, 16);
|
||||
}
|
||||
|
||||
// --- Сопоставление преподавателей и нагрузки ---
|
||||
|
||||
export function normalizeTeacherName(value) {
|
||||
return value ? `name:${String(value).trim().toLowerCase()}` : null;
|
||||
}
|
||||
|
||||
export function workloadKeys(item) {
|
||||
return [
|
||||
item.id == null ? null : `id:${item.id}`,
|
||||
normalizeTeacherName(item.fullName || item.name || item.username)
|
||||
].filter(Boolean);
|
||||
}
|
||||
|
||||
export function workloadLabel(item, errorMessage) {
|
||||
if (errorMessage) return errorMessage;
|
||||
if (!item) return '0 ак. ч., 0 занятий за период';
|
||||
return `${item.academicHours ?? 0} ак. ч., ${item.lessonCount ?? 0} занятий за период`;
|
||||
}
|
||||
|
||||
// Воспроизводит legacy-логику сводки: преподаватели кафедры, затем записи
|
||||
// нагрузки, не привязанные ни к одному преподавателю (есть занятия в расписании).
|
||||
export function teacherMetrics(teachers, workload) {
|
||||
const workloadByTeacher = new Map();
|
||||
const usedWorkloadKeys = new Set();
|
||||
|
||||
(workload || [])
|
||||
.filter(item => item && (item.id || item.name))
|
||||
.forEach(item => {
|
||||
workloadKeys(item).forEach(key => {
|
||||
if (!workloadByTeacher.has(key)) {
|
||||
workloadByTeacher.set(key, item);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const rows = (teachers || []).map(teacher => {
|
||||
const workloadItem = workloadKeys(teacher)
|
||||
.map(key => workloadByTeacher.get(key))
|
||||
.find(Boolean);
|
||||
if (workloadItem) {
|
||||
workloadKeys(workloadItem).forEach(key => usedWorkloadKeys.add(key));
|
||||
}
|
||||
return {
|
||||
name: teacher.fullName || teacher.username || '-',
|
||||
meta: teacher.jobTitle || 'Должность не указана',
|
||||
workload: workloadItem
|
||||
};
|
||||
});
|
||||
|
||||
(workload || [])
|
||||
.filter(item => item && (item.id || item.name))
|
||||
.filter(item => workloadKeys(item).every(key => !usedWorkloadKeys.has(key)))
|
||||
.forEach(item => {
|
||||
rows.push({
|
||||
name: item.name || '-',
|
||||
meta: 'Есть занятия в расписании кафедры',
|
||||
workload: item
|
||||
});
|
||||
});
|
||||
|
||||
return rows;
|
||||
}
|
||||
354
frontend/react/admin/groups-service.js
Normal file
354
frontend/react/admin/groups-service.js
Normal file
@@ -0,0 +1,354 @@
|
||||
import { apiClient } from '../shared/api-client.js';
|
||||
import { buildAcademicCalendarTitle } from '../../admin/js/views/academic-calendar-title.js';
|
||||
|
||||
export const GROUP_TABLE_COLUMNS = 10;
|
||||
export const GROUP_PAGE_SIZES = [25, 50, 100];
|
||||
|
||||
export const GROUP_SORT_OPTIONS = Object.freeze([
|
||||
{ value: 'name,asc', label: 'По названию А–Я' },
|
||||
{ value: 'name,desc', label: 'По названию Я–А' },
|
||||
{ value: 'yearStartStudy,desc', label: 'Сначала новые наборы' },
|
||||
{ value: 'groupSize,desc', label: 'Сначала крупные' },
|
||||
{ value: 'status,asc', label: 'По статусу' }
|
||||
]);
|
||||
|
||||
export const SUBGROUP_COUNT_OPTIONS = Object.freeze([
|
||||
{ value: '0', label: 'Без разделения на подгруппы' },
|
||||
{ value: '2', label: 'Две подгруппы' },
|
||||
{ value: '3', label: 'Три подгруппы' }
|
||||
]);
|
||||
|
||||
export const FILTER_EF_EMPTY_TEXT = 'Выберите форму обучения';
|
||||
|
||||
export function normalizeGroupsState(restored = {}) {
|
||||
const size = Number(restored.groupSize);
|
||||
return {
|
||||
query: restored.groupQuery || '',
|
||||
page: Math.max(Number(restored.groupPage) || 0, 0),
|
||||
size: GROUP_PAGE_SIZES.includes(size) ? size : 25,
|
||||
sort: GROUP_SORT_OPTIONS.some(option => option.value === restored.groupSort)
|
||||
? restored.groupSort
|
||||
: 'name,asc',
|
||||
educationFormIds: Array.isArray(restored.groupEducationForm)
|
||||
? restored.groupEducationForm.map(String)
|
||||
: []
|
||||
};
|
||||
}
|
||||
|
||||
export function initialGroupsState(restored = {}) {
|
||||
return normalizeGroupsState(restored);
|
||||
}
|
||||
|
||||
export function groupsQueryParams(state) {
|
||||
const [sort, direction] = String(state.sort || 'name,asc').split(',');
|
||||
const params = new URLSearchParams({
|
||||
query: state.query || '',
|
||||
page: String(state.page || 0),
|
||||
size: String(state.size || 25),
|
||||
sort,
|
||||
direction,
|
||||
includeArchived: 'true'
|
||||
});
|
||||
(state.educationFormIds || []).forEach(id => params.append('educationFormId', id));
|
||||
return `/api/groups/page?${params}`;
|
||||
}
|
||||
|
||||
export function groupsUrlState(state) {
|
||||
return {
|
||||
groupQuery: state.query || '',
|
||||
groupPage: state.page || '',
|
||||
groupSize: state.size === 25 ? '' : state.size,
|
||||
groupSort: state.sort === 'name,asc' ? '' : state.sort,
|
||||
groupEducationForm: state.educationFormIds || []
|
||||
};
|
||||
}
|
||||
|
||||
// --- Справочники ---
|
||||
|
||||
export function fetchEducationForms(client = apiClient) {
|
||||
// legacy молча подставляет пустой список при сбое загрузки форм обучения
|
||||
return client.get('/api/education-forms').catch(() => []);
|
||||
}
|
||||
|
||||
export function fetchDepartments(client = apiClient) {
|
||||
return client.get('/api/departments', { errorMessage: 'Не удалось загрузить список кафедр' });
|
||||
}
|
||||
|
||||
export function fetchSpecialties(client = apiClient) {
|
||||
return client.get('/api/specialties', { errorMessage: 'Не удалось загрузить список специальностей' });
|
||||
}
|
||||
|
||||
export function fetchAcademicYears(client = apiClient) {
|
||||
return client.get('/api/admin/calendar/years', { errorMessage: 'Не удалось загрузить учебные годы' });
|
||||
}
|
||||
|
||||
export async function fetchProfilesForSpecialties(specialties, client = apiClient) {
|
||||
const pairs = await Promise.all((specialties || []).map(async (speciality) => {
|
||||
const profiles = await client.get(
|
||||
`/api/specialties/${speciality.id}/profiles`,
|
||||
{ errorMessage: 'Не удалось загрузить профили обучения' }
|
||||
);
|
||||
return [String(speciality.id), profiles || []];
|
||||
}));
|
||||
return new Map(pairs);
|
||||
}
|
||||
|
||||
// --- Реестр групп ---
|
||||
|
||||
export function fetchGroupsPage(state, client = apiClient) {
|
||||
return client.get(groupsQueryParams(state), { errorMessage: 'Не удалось загрузить группы' });
|
||||
}
|
||||
|
||||
export function groupPayloadFromFields(fields) {
|
||||
const checks = [
|
||||
['name', 'Введите название группы'],
|
||||
['groupSize', 'Введите размер группы'],
|
||||
['educationFormId', 'Выберите форму обучения'],
|
||||
['departmentId', 'Выберите кафедру'],
|
||||
['yearStartStudy', 'Введите год начала обучения'],
|
||||
['specialtyId', 'Выберите специальность'],
|
||||
['specialtyProfileId', 'Выберите профиль обучения']
|
||||
];
|
||||
for (const [field, message] of checks) {
|
||||
if (!fields[field]) return { error: message };
|
||||
}
|
||||
return {
|
||||
payload: {
|
||||
name: fields.name,
|
||||
groupSize: Number(fields.groupSize),
|
||||
educationFormId: Number(fields.educationFormId),
|
||||
departmentId: Number(fields.departmentId),
|
||||
yearStartStudy: Number(fields.yearStartStudy),
|
||||
specialtyId: Number(fields.specialtyId),
|
||||
specialtyProfileId: Number(fields.specialtyProfileId)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function createGroup(payload, client = apiClient) {
|
||||
return client.post('/api/groups', payload, { errorMessage: 'Ошибка создания' });
|
||||
}
|
||||
|
||||
export function updateGroup(id, payload, client = apiClient) {
|
||||
return client.put(`/api/groups/${id}`, payload, { errorMessage: 'Ошибка обновления' });
|
||||
}
|
||||
|
||||
export function archiveGroup(id, client = apiClient) {
|
||||
return client.delete(`/api/groups/${id}`, { errorMessage: 'Ошибка архивирования' });
|
||||
}
|
||||
|
||||
export function restoreGroup(id, client = apiClient) {
|
||||
return client.post(`/api/groups/${id}/restore`, {}, { errorMessage: 'Ошибка восстановления' });
|
||||
}
|
||||
|
||||
// --- Подгруппы ---
|
||||
|
||||
export function fetchSubgroups(groupId, client = apiClient) {
|
||||
return client.get(`/api/groups/${groupId}/subgroups`, { errorMessage: 'Не удалось загрузить подгруппы' });
|
||||
}
|
||||
|
||||
export function createSubgroup(groupId, payload, client = apiClient) {
|
||||
return client.post(`/api/groups/${groupId}/subgroups`, payload, { errorMessage: 'Ошибка сохранения подгруппы' });
|
||||
}
|
||||
|
||||
export function updateSubgroup(groupId, id, payload, client = apiClient) {
|
||||
return client.put(`/api/groups/${groupId}/subgroups/${id}`, payload, { errorMessage: 'Ошибка сохранения подгруппы' });
|
||||
}
|
||||
|
||||
export function deleteSubgroup(groupId, id, client = apiClient) {
|
||||
return client.delete(`/api/groups/${groupId}/subgroups/${id}`, { errorMessage: 'Ошибка удаления подгруппы' });
|
||||
}
|
||||
|
||||
export function naturalSubgroupOrder(name) {
|
||||
const number = String(name || '').match(/\d+/)?.[0];
|
||||
return number ? String(number).padStart(3, '0') : String(name || '');
|
||||
}
|
||||
|
||||
export function sortSubgroups(subgroups) {
|
||||
return [...(subgroups || [])].sort((left, right) =>
|
||||
naturalSubgroupOrder(left.name).localeCompare(naturalSubgroupOrder(right.name), 'ru'));
|
||||
}
|
||||
|
||||
export function suggestedSubgroupCapacities(groupSize, count) {
|
||||
if (!groupSize || !count) return [];
|
||||
const base = Math.floor(groupSize / count);
|
||||
const remainder = groupSize % count;
|
||||
return Array.from({ length: count }, (_, index) => base + (index < remainder ? 1 : 0));
|
||||
}
|
||||
|
||||
export function validateSubgroupCapacities(capacities, groupSize, count) {
|
||||
if (capacities.length !== count || capacities.some(value => !Number.isInteger(value) || value <= 0)) {
|
||||
return 'Укажите положительную численность для каждой подгруппы';
|
||||
}
|
||||
|
||||
const total = capacities.reduce((sum, value) => sum + value, 0);
|
||||
if (total !== groupSize) {
|
||||
return `Сумма численностей подгрупп должна быть равна численности группы (${groupSize} чел.)`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Синхронизация подгрупп воспроизводит legacy-порядок: сначала удаляем лишние,
|
||||
// затем обновляем существующие (сначала уменьшающие вместимость), затем создаём новые.
|
||||
export async function syncSubgroups(groupId, existingSubgroups, capacities, client = apiClient) {
|
||||
const targetCount = capacities.length;
|
||||
const extraSubgroups = existingSubgroups.slice(targetCount);
|
||||
for (const subgroup of extraSubgroups) {
|
||||
await deleteSubgroup(groupId, subgroup.id, client);
|
||||
}
|
||||
|
||||
const updates = [];
|
||||
const creates = [];
|
||||
for (let index = 0; index < targetCount; index += 1) {
|
||||
const payload = {
|
||||
name: `Подгруппа ${index + 1}`,
|
||||
studentCapacity: capacities[index]
|
||||
};
|
||||
const existing = existingSubgroups[index];
|
||||
if (existing) {
|
||||
updates.push({ existing, payload });
|
||||
} else {
|
||||
creates.push(payload);
|
||||
}
|
||||
}
|
||||
|
||||
updates.sort((left, right) => {
|
||||
const leftIncreases = left.payload.studentCapacity > Number(left.existing.studentCapacity || 0);
|
||||
const rightIncreases = right.payload.studentCapacity > Number(right.existing.studentCapacity || 0);
|
||||
return Number(leftIncreases) - Number(rightIncreases);
|
||||
});
|
||||
|
||||
for (const update of updates) {
|
||||
await updateSubgroup(groupId, update.existing.id, update.payload, client);
|
||||
}
|
||||
|
||||
for (const payload of creates) {
|
||||
await createSubgroup(groupId, payload, client);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Календарные графики ---
|
||||
|
||||
export function calendarsOptionsEndpoint({ query, academicYearId, specialtyId, profileId, studyFormId, selectedId }) {
|
||||
const params = new URLSearchParams({
|
||||
query,
|
||||
academicYearId,
|
||||
specialtyId,
|
||||
profileId,
|
||||
studyFormId,
|
||||
limit: '30'
|
||||
});
|
||||
if (selectedId) params.set('selectedId', selectedId);
|
||||
return `/api/admin/academic-calendars/options?${params}`;
|
||||
}
|
||||
|
||||
export function fetchCalendarsOptions(params, client = apiClient) {
|
||||
return client.get(calendarsOptionsEndpoint(params), { errorMessage: 'Не удалось загрузить календарные графики' });
|
||||
}
|
||||
|
||||
export function fetchCalendarAssignments(groupId, client = apiClient) {
|
||||
return client.get(`/api/groups/${groupId}/calendar-assignments`, { errorMessage: 'Не удалось загрузить назначения графиков' });
|
||||
}
|
||||
|
||||
export function saveCalendarAssignment(groupId, { academicYearId, calendarId }, client = apiClient) {
|
||||
return client.put(
|
||||
`/api/groups/${groupId}/calendar-assignments`,
|
||||
{ academicYearId: Number(academicYearId), calendarId: Number(calendarId) },
|
||||
{ errorMessage: 'Ошибка назначения графика' }
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteCalendarAssignment(groupId, id, client = apiClient) {
|
||||
return client.delete(`/api/groups/${groupId}/calendar-assignments/${id}`, { errorMessage: 'Ошибка удаления назначения' });
|
||||
}
|
||||
|
||||
// --- Фильтр форм обучения ---
|
||||
|
||||
export function efFilterLabel(educationForms, selectedIds) {
|
||||
const ids = selectedIds || [];
|
||||
if (!ids.length) return 'Все';
|
||||
if (ids.length === 1) {
|
||||
const form = (educationForms || []).find(item => String(item.id) === ids[0]);
|
||||
return form?.name || FILTER_EF_EMPTY_TEXT;
|
||||
}
|
||||
return `Выбрано: ${ids.length}`;
|
||||
}
|
||||
|
||||
// --- Метки ---
|
||||
|
||||
export function departmentLabel(departments, departmentId) {
|
||||
const department = (departments || []).find(item => String(item.id) === String(departmentId));
|
||||
if (!department) return departmentId || '-';
|
||||
return department.departmentName || department.name || departmentId;
|
||||
}
|
||||
|
||||
export function departmentOptionLabel(department) {
|
||||
const name = department.departmentName || department.name || 'Без названия';
|
||||
const code = department.departmentCode || department.code || department.id;
|
||||
return `${name} (${code})`;
|
||||
}
|
||||
|
||||
export function specialityLabel(specialties, specialityId) {
|
||||
const speciality = (specialties || []).find(item => String(item.id) === String(specialityId));
|
||||
if (!speciality) return specialityId || '-';
|
||||
const code = speciality.specialityCode || speciality.specialtyCode || speciality.specialty_code || speciality.id;
|
||||
const name = speciality.specialityName || speciality.name || '';
|
||||
return name ? `${code} — ${name}` : code;
|
||||
}
|
||||
|
||||
export function specialityOptionLabel(speciality) {
|
||||
const code = speciality.specialityCode || speciality.specialtyCode || speciality.specialty_code;
|
||||
const name = speciality.specialityName || speciality.name;
|
||||
return `${code} · ${name}`;
|
||||
}
|
||||
|
||||
export function subjectLabel(subject) {
|
||||
const code = subject.subjectCode ? `${subject.subjectCode} · ` : '';
|
||||
return `${code}${subject.subjectName || subject.subjectId || 'Без названия'}`;
|
||||
}
|
||||
|
||||
export function statusLabel(group) {
|
||||
if (group.status === 'ARCHIVED') return 'Архив';
|
||||
return group.studyStateName || (group.active ? 'Активна' : 'Неактивна');
|
||||
}
|
||||
|
||||
export function statusBadgeClass(group) {
|
||||
if (group.status === 'ARCHIVED' || group.studyState === 'GRADUATED' || group.studyState === 'INACTIVE') {
|
||||
return 'badge-unavailable';
|
||||
}
|
||||
if (group.studyState === 'NOT_STARTED') {
|
||||
return 'badge-ef';
|
||||
}
|
||||
return 'badge-available';
|
||||
}
|
||||
|
||||
export function calendarOptionLabel(calendar) {
|
||||
return buildAcademicCalendarTitle(calendar) || calendar.title || `График ${calendar.id}`;
|
||||
}
|
||||
|
||||
export function calendarOptionDescription(calendar) {
|
||||
return `${calendar.academicYearTitle || ''} · ${calendar.studyFormName || ''}`;
|
||||
}
|
||||
|
||||
export function assignmentCalendarTitle(assignment) {
|
||||
return assignment.calendarTitle || '-';
|
||||
}
|
||||
|
||||
export function assignmentSubjectsGroups(subjects) {
|
||||
const grouped = new Map();
|
||||
(subjects || []).forEach(subject => {
|
||||
const key = String(subject.semesterNumber || '-');
|
||||
if (!grouped.has(key)) grouped.set(key, []);
|
||||
grouped.get(key).push(subject);
|
||||
});
|
||||
return Array.from(grouped.entries())
|
||||
.sort(([a], [b]) => Number(a) - Number(b))
|
||||
.map(([semesterNumber, items]) => ({ semesterNumber, items }));
|
||||
}
|
||||
|
||||
export function groupsEmptyMessage(state) {
|
||||
return state.query || (state.educationFormIds || []).length
|
||||
? 'По заданным условиям группы не найдены'
|
||||
: 'Групп пока нет';
|
||||
}
|
||||
22
frontend/react/admin/main.jsx
Normal file
22
frontend/react/admin/main.jsx
Normal 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 { AdminApp } from './AdminApp.jsx';
|
||||
|
||||
void initializeTelemetry();
|
||||
|
||||
const rootElement = document.getElementById('admin-root');
|
||||
|
||||
if (!rootElement) {
|
||||
throw new Error('Не найден корневой элемент админ-панели');
|
||||
}
|
||||
|
||||
createRoot(rootElement).render(
|
||||
<StrictMode>
|
||||
<AppErrorBoundary>
|
||||
<AdminApp />
|
||||
</AppErrorBoundary>
|
||||
</StrictMode>
|
||||
);
|
||||
25
frontend/react/admin/react-tabs.js
vendored
Normal file
25
frontend/react/admin/react-tabs.js
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
import { lazy } from 'react';
|
||||
|
||||
// Реестр вкладок админ-панели (этап 5). Каждая вкладка загружается
|
||||
// отдельным lazy-chunk; переходный LegacyTabView удалён
|
||||
// после завершения волны 6.
|
||||
//
|
||||
// Компоненты вкладок используют именованные экспорты, а React.lazy
|
||||
// требует default-экспорт, поэтому import() явно отображается на default.
|
||||
export const REACT_TABS = Object.freeze({
|
||||
dashboard: { Component: lazy(() => import('./tabs/DashboardTab.jsx').then(m => ({ default: m.DashboardTab }))) },
|
||||
classrooms: { Component: lazy(() => import('./tabs/ClassroomsTab.jsx').then(m => ({ default: m.ClassroomsTab }))) },
|
||||
subjects: { Component: lazy(() => import('./tabs/SubjectsTab.jsx').then(m => ({ default: m.SubjectsTab }))) },
|
||||
users: { Component: lazy(() => import('./tabs/UsersTab.jsx').then(m => ({ default: m.UsersTab }))) },
|
||||
groups: { Component: lazy(() => import('./tabs/GroupsTab.jsx').then(m => ({ default: m.GroupsTab }))) },
|
||||
'university-structure': { Component: lazy(() => import('./tabs/UniversityStructureTab.jsx').then(m => ({ default: m.UniversityStructureTab }))) },
|
||||
'teacher-requests': { Component: lazy(() => import('./tabs/TeacherRequestsTab.jsx').then(m => ({ default: m.TeacherRequestsTab }))) },
|
||||
'department-workspace': { Component: lazy(() => import('./tabs/DepartmentWorkspaceTab.jsx').then(m => ({ default: m.DepartmentWorkspaceTab }))) },
|
||||
'auditorium-workload': { Component: lazy(() => import('./tabs/AuditoriumWorkloadTab.jsx').then(m => ({ default: m.AuditoriumWorkloadTab }))) },
|
||||
'academic-calendar': { Component: lazy(() => import('./tabs/AcademicCalendarTab.jsx').then(m => ({ default: m.AcademicCalendarTab }))) },
|
||||
'schedule-view': { Component: lazy(() => import('./tabs/ScheduleViewTab.jsx').then(m => ({ default: m.ScheduleViewTab }))) },
|
||||
schedule: { Component: lazy(() => import('./tabs/ScheduleTab.jsx').then(m => ({ default: m.ScheduleTab }))) },
|
||||
'schedule-versions': { Component: lazy(() => import('./tabs/ScheduleVersionsTab.jsx').then(m => ({ default: m.ScheduleVersionsTab }))) },
|
||||
'schedule-quality': { Component: lazy(() => import('./tabs/ScheduleQualityTab.jsx').then(m => ({ default: m.ScheduleQualityTab }))) },
|
||||
'teacher-absences': { Component: lazy(() => import('./tabs/TeacherAbsencesTab.jsx').then(m => ({ default: m.TeacherAbsencesTab }))) }
|
||||
});
|
||||
333
frontend/react/admin/schedule-override-service.js
Normal file
333
frontend/react/admin/schedule-override-service.js
Normal file
@@ -0,0 +1,333 @@
|
||||
import { apiClient } from '../shared/api-client.js';
|
||||
import {
|
||||
addDays,
|
||||
formatDate,
|
||||
formatDateShort,
|
||||
parseIsoDate,
|
||||
toIsoDate,
|
||||
trimTime
|
||||
} from './schedule-view-service.js';
|
||||
|
||||
// Волна 6 (этап 5): чистая логика и контракты API разовых правок расписания,
|
||||
// перенесённые из legacy admin/js/views/schedule-override-panel.js без изменения
|
||||
// поведения. Императивный модуль панели заменён React-состоянием во вкладке
|
||||
// просмотра расписаний.
|
||||
|
||||
export const OVERRIDE_EDIT_ROLES = new Set(['ADMIN', 'EDUCATION_OFFICE']);
|
||||
|
||||
export const OVERRIDE_ACTION_LABELS = {
|
||||
MOVE: 'Перенос',
|
||||
REPLACE: 'Замена',
|
||||
CANCEL: 'Отмена'
|
||||
};
|
||||
|
||||
export const OVERRIDE_DAY_LABELS = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
|
||||
|
||||
export const OVERRIDE_MONTH_LABELS = [
|
||||
'Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь',
|
||||
'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'
|
||||
];
|
||||
|
||||
// --- Права и решения ---
|
||||
|
||||
export function canEditScheduleOverrides(role) {
|
||||
return OVERRIDE_EDIT_ROLES.has(role);
|
||||
}
|
||||
|
||||
export function overrideActionForChanges(changes) {
|
||||
if (changes.dateChanged || changes.timeChanged) return 'MOVE';
|
||||
if (changes.teacherChanged || changes.classroomChanged || changes.formatChanged) return 'REPLACE';
|
||||
return null;
|
||||
}
|
||||
|
||||
// --- Даты и время ---
|
||||
|
||||
export function weekDates(value) {
|
||||
const selected = parseIsoDate(value);
|
||||
const day = selected.getDay() || 7;
|
||||
const monday = addDays(selected, 1 - day);
|
||||
return Array.from({ length: 7 }, (_, index) => toIsoDate(addDays(monday, index)));
|
||||
}
|
||||
|
||||
export function pickMatchingTimeSlot(slots, preferredId, startTime, endTime) {
|
||||
const safeSlots = Array.isArray(slots) ? slots : [];
|
||||
const byId = preferredId == null
|
||||
? null
|
||||
: safeSlots.find(slot => String(slot.id) === String(preferredId));
|
||||
if (byId) return byId;
|
||||
const normalizedStart = trimTime(startTime);
|
||||
const normalizedEnd = trimTime(endTime);
|
||||
if (!normalizedStart || !normalizedEnd) return null;
|
||||
return safeSlots.find(slot => trimTime(slot.startTime) === normalizedStart
|
||||
&& trimTime(slot.endTime) === normalizedEnd) || null;
|
||||
}
|
||||
|
||||
export function overrideIntervalLabel(start, end) {
|
||||
return `${trimTime(start)}–${trimTime(end)}`;
|
||||
}
|
||||
|
||||
export function overrideTimeSlotLabel(slot) {
|
||||
const order = slot.orderNumber ? `${slot.orderNumber} пара · ` : '';
|
||||
return `${order}${overrideIntervalLabel(slot.startTime, slot.endTime)}`;
|
||||
}
|
||||
|
||||
// --- Источник правки ---
|
||||
|
||||
export function overrideSourceFrom(lesson, existing) {
|
||||
return {
|
||||
baseRuleSlotId: lesson.scheduleRuleSlotId,
|
||||
lessonDate: lesson.originalLessonDate || existing?.lessonDate || lesson.date,
|
||||
subjectName: existing?.subjectName || lesson.subjectName || 'Без дисциплины',
|
||||
lessonTypeName: existing?.lessonTypeName || lesson.lessonTypeName || 'Тип не указан',
|
||||
groupNames: existing?.groupNames?.length ? existing.groupNames : (lesson.groupNames || []),
|
||||
timeSlotId: existing?.sourceTimeSlotId || lesson.timeSlotId,
|
||||
timeSlotOrder: existing?.sourceTimeSlotOrder || lesson.timeSlotOrder,
|
||||
startTime: existing?.sourceStartTime || lesson.startTime,
|
||||
endTime: existing?.sourceEndTime || lesson.endTime,
|
||||
teacherId: existing?.sourceTeacherId || lesson.teacherId,
|
||||
teacherName: existing?.sourceTeacherName || lesson.teacherName,
|
||||
classroomId: existing?.sourceClassroomId || lesson.classroomId,
|
||||
classroomName: existing?.sourceClassroomName || lesson.classroomName,
|
||||
lessonFormat: existing?.sourceLessonFormat || lesson.lessonFormat || 'Очно'
|
||||
};
|
||||
}
|
||||
|
||||
export function initialTimePreference(existing, source) {
|
||||
return {
|
||||
id: existing?.newTimeSlotId || source.timeSlotId,
|
||||
startTime: existing?.newTimeSlotId ? null : source.startTime,
|
||||
endTime: existing?.newTimeSlotId ? null : source.endTime
|
||||
};
|
||||
}
|
||||
|
||||
// Синтетическое занятие для открытия существующей правки из реестра.
|
||||
export function lessonFromOverride(override, dictionaries) {
|
||||
return {
|
||||
scheduleRuleSlotId: override.baseRuleSlotId,
|
||||
date: override.targetLessonDate || override.lessonDate,
|
||||
originalLessonDate: override.lessonDate,
|
||||
scheduleOverrideId: override.id,
|
||||
subjectName: override.subjectName,
|
||||
lessonTypeName: override.lessonTypeName,
|
||||
groupNames: override.groupNames,
|
||||
timeSlotId: override.newTimeSlotId || override.sourceTimeSlotId,
|
||||
timeSlotOrder: override.sourceTimeSlotOrder,
|
||||
startTime: override.sourceStartTime,
|
||||
endTime: override.sourceEndTime,
|
||||
teacherId: override.newTeacherId || override.sourceTeacherId,
|
||||
teacherName: overrideTeacherName(dictionaries, override.newTeacherId) || override.sourceTeacherName,
|
||||
classroomId: override.newClassroomId || override.sourceClassroomId,
|
||||
classroomName: overrideClassroomName(dictionaries, override.newClassroomId) || override.sourceClassroomName,
|
||||
lessonFormat: override.newLessonFormat || override.sourceLessonFormat
|
||||
};
|
||||
}
|
||||
|
||||
export function overrideTeacherName(dictionaries, id) {
|
||||
if (id == null || id === '') return '';
|
||||
const teacher = (dictionaries.teachers || []).find(item => String(item.id) === String(id));
|
||||
return teacher?.fullName || teacher?.username || '';
|
||||
}
|
||||
|
||||
export function overrideClassroomName(dictionaries, id) {
|
||||
if (id == null || id === '') return '';
|
||||
const classroom = (dictionaries.classrooms || []).find(item => String(item.id) === String(id));
|
||||
return classroom ? classroomOptionLabel(classroom) : '';
|
||||
}
|
||||
|
||||
export function classroomOptionLabel(room) {
|
||||
return room?.name || '';
|
||||
}
|
||||
|
||||
// --- Сравнение «было / станет» ---
|
||||
|
||||
export function overrideBeforeMeta(source) {
|
||||
return [
|
||||
formatDate(source.lessonDate),
|
||||
overrideIntervalLabel(source.startTime, source.endTime),
|
||||
source.teacherName,
|
||||
source.classroomName,
|
||||
source.lessonFormat,
|
||||
source.groupNames.join(', ')
|
||||
].filter(Boolean).join(' · ');
|
||||
}
|
||||
|
||||
export function overrideAfterMeta({ source, targetDate, selectedTime, teacherName, classroomName, lessonFormat }) {
|
||||
return [
|
||||
formatDate(targetDate),
|
||||
selectedTime ? overrideIntervalLabel(selectedTime.startTime, selectedTime.endTime) : 'Время не выбрано',
|
||||
teacherName,
|
||||
classroomName,
|
||||
lessonFormat,
|
||||
source.groupNames.join(', ')
|
||||
].filter(Boolean).join(' · ');
|
||||
}
|
||||
|
||||
// --- Календарь семестра ---
|
||||
|
||||
export function semesterCalendarMonths(startDate, endDate, available, selectedDate) {
|
||||
if (!startDate || !endDate) return [];
|
||||
const start = parseIsoDate(startDate);
|
||||
const end = parseIsoDate(endDate);
|
||||
const months = [];
|
||||
for (let cursor = new Date(start.getFullYear(), start.getMonth(), 1);
|
||||
cursor <= end;
|
||||
cursor = new Date(cursor.getFullYear(), cursor.getMonth() + 1, 1)) {
|
||||
months.push(overrideMonth(cursor, startDate, endDate, available, selectedDate));
|
||||
}
|
||||
return months;
|
||||
}
|
||||
|
||||
function overrideMonth(month, semesterStart, semesterEnd, available, selectedDate) {
|
||||
const year = month.getFullYear();
|
||||
const monthIndex = month.getMonth();
|
||||
const firstDay = new Date(year, monthIndex, 1).getDay() || 7;
|
||||
const dayCount = new Date(year, monthIndex + 1, 0).getDate();
|
||||
const blanks = Array.from({ length: firstDay - 1 }, (_, index) => ({ blank: true, key: `blank-${index}` }));
|
||||
const days = Array.from({ length: dayCount }, (_, index) => {
|
||||
const date = toIsoDate(new Date(year, monthIndex, index + 1));
|
||||
const enabled = date >= semesterStart && date <= semesterEnd && available.has(date);
|
||||
return {
|
||||
key: date,
|
||||
date,
|
||||
day: index + 1,
|
||||
enabled,
|
||||
active: date === selectedDate
|
||||
};
|
||||
});
|
||||
return {
|
||||
title: `${OVERRIDE_MONTH_LABELS[monthIndex]} ${year}`,
|
||||
blanks,
|
||||
days
|
||||
};
|
||||
}
|
||||
|
||||
// --- Payload правки ---
|
||||
|
||||
export function buildOverridePayload({
|
||||
source,
|
||||
selectedOverride,
|
||||
targetDate,
|
||||
timeSlotId,
|
||||
teacherId,
|
||||
classroomId,
|
||||
lessonFormat,
|
||||
comment
|
||||
}) {
|
||||
if (!timeSlotId) {
|
||||
return { error: 'Выберите время занятия для выбранной даты' };
|
||||
}
|
||||
const dateChanged = targetDate !== source.lessonDate;
|
||||
const timeChanged = String(timeSlotId) !== String(source.timeSlotId);
|
||||
const teacherChanged = String(teacherId) !== String(source.teacherId);
|
||||
const classroomChanged = String(classroomId) !== String(source.classroomId);
|
||||
const formatChanged = lessonFormat !== source.lessonFormat;
|
||||
const action = overrideActionForChanges({
|
||||
dateChanged,
|
||||
timeChanged,
|
||||
teacherChanged,
|
||||
classroomChanged,
|
||||
formatChanged
|
||||
});
|
||||
if (!action) {
|
||||
return {
|
||||
error: selectedOverride
|
||||
? 'Параметры совпадают с правилом. Используйте «Вернуть по правилу»'
|
||||
: 'Измените дату, время, преподавателя, аудиторию или формат'
|
||||
};
|
||||
}
|
||||
return {
|
||||
payload: {
|
||||
baseRuleSlotId: source.baseRuleSlotId,
|
||||
lessonDate: source.lessonDate,
|
||||
targetLessonDate: dateChanged ? targetDate : null,
|
||||
action,
|
||||
newTimeSlotId: (dateChanged || timeChanged) ? Number(timeSlotId) : null,
|
||||
newTeacherId: teacherChanged ? Number(teacherId) : null,
|
||||
newClassroomId: classroomChanged ? Number(classroomId) : null,
|
||||
newLessonFormat: formatChanged ? lessonFormat : null,
|
||||
comment: comment.trim() || null
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function buildCancelOverridePayload(source, comment) {
|
||||
return {
|
||||
baseRuleSlotId: source.baseRuleSlotId,
|
||||
lessonDate: source.lessonDate,
|
||||
targetLessonDate: null,
|
||||
action: 'CANCEL',
|
||||
newTimeSlotId: null,
|
||||
newTeacherId: null,
|
||||
newClassroomId: null,
|
||||
newLessonFormat: null,
|
||||
comment: comment.trim() || null
|
||||
};
|
||||
}
|
||||
|
||||
// --- Реестр правок ---
|
||||
|
||||
export function overrideRegistryItem(override, dictionaries) {
|
||||
const target = override.targetLessonDate
|
||||
? ` → ${formatDate(override.targetLessonDate)}`
|
||||
: '';
|
||||
const result = override.action === 'CANCEL'
|
||||
? 'Занятие отменено'
|
||||
: [
|
||||
override.newTimeSlotId ? `слот ${override.newTimeSlotId}` : null,
|
||||
overrideTeacherName(dictionaries, override.newTeacherId),
|
||||
overrideClassroomName(dictionaries, override.newClassroomId),
|
||||
override.newLessonFormat
|
||||
].filter(Boolean).join(' · ');
|
||||
return {
|
||||
id: override.id,
|
||||
actionLabel: OVERRIDE_ACTION_LABELS[override.action] || override.action,
|
||||
actionClass: `action-${String(override.action).toLowerCase()}`,
|
||||
timeText: `${formatDate(override.lessonDate)}${target}`,
|
||||
subject: override.subjectName || 'Занятие',
|
||||
description: [
|
||||
override.lessonTypeName,
|
||||
(override.groupNames || []).join(', '),
|
||||
result || 'Параметры изменены'
|
||||
].filter(Boolean).join(' · '),
|
||||
comment: override.comment || ''
|
||||
};
|
||||
}
|
||||
|
||||
// --- Контракты API ---
|
||||
|
||||
export function overrideAvailabilityEndpoint(baseRuleSlotId, lessonDate) {
|
||||
return `/api/edu-office/schedule/overrides/availability?baseRuleSlotId=${encodeURIComponent(baseRuleSlotId)}`
|
||||
+ `&lessonDate=${encodeURIComponent(lessonDate)}`;
|
||||
}
|
||||
|
||||
export async function fetchOverrideAvailability(baseRuleSlotId, lessonDate, client = apiClient) {
|
||||
return client.get(overrideAvailabilityEndpoint(baseRuleSlotId, lessonDate), {
|
||||
errorMessage: 'Не удалось загрузить доступные даты занятия'
|
||||
});
|
||||
}
|
||||
|
||||
export function effectiveTimeSlotsEndpoint(date) {
|
||||
return `/api/admin/time-slots/effective?date=${encodeURIComponent(date)}`;
|
||||
}
|
||||
|
||||
export async function fetchEffectiveTimeSlots(date, client = apiClient) {
|
||||
return client.get(effectiveTimeSlotsEndpoint(date), {
|
||||
errorMessage: 'Не удалось загрузить время занятий'
|
||||
});
|
||||
}
|
||||
|
||||
export async function saveScheduleOverride(payload, overrideId, client = apiClient) {
|
||||
return overrideId
|
||||
? client.put(`/api/edu-office/schedule/overrides/${encodeURIComponent(overrideId)}`, payload, {
|
||||
errorMessage: 'Не удалось сохранить разовое изменение'
|
||||
})
|
||||
: client.post('/api/edu-office/schedule/overrides', payload, {
|
||||
errorMessage: 'Не удалось сохранить разовое изменение'
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteScheduleOverride(overrideId, client = apiClient) {
|
||||
return client.delete(`/api/edu-office/schedule/overrides/${encodeURIComponent(overrideId)}`, {
|
||||
errorMessage: 'Не удалось удалить разовое изменение'
|
||||
});
|
||||
}
|
||||
95
frontend/react/admin/schedule-quality-service.js
Normal file
95
frontend/react/admin/schedule-quality-service.js
Normal file
@@ -0,0 +1,95 @@
|
||||
import { apiClient } from '../shared/api-client.js';
|
||||
import { formatLocalDate } from '../../admin/js/utils.js';
|
||||
import { fetchScheduleSemesters, fetchScheduleVersions } from './schedule-versions-service.js';
|
||||
|
||||
// Срез «Анализ качества расписания» (этап 5, волна 5): чистая логика и контракты
|
||||
// API, перенесённые из legacy admin/js/views/schedule-quality.js без изменения поведения.
|
||||
|
||||
// --- Диапазоны оценки ---
|
||||
|
||||
export function qualityBand(score) {
|
||||
if (score >= 90) {
|
||||
return { code: 'excellent', label: 'Сильное расписание', summary: 'Найдены только локальные неудобства с небольшим влиянием.' };
|
||||
}
|
||||
if (score >= 75) {
|
||||
return { code: 'stable', label: 'Устойчивое расписание', summary: 'Расписание работоспособно, но несколько перестановок заметно улучшат комфорт.' };
|
||||
}
|
||||
if (score >= 55) {
|
||||
return { code: 'attention', label: 'Нужна доработка', summary: 'Мягкие ограничения и распределение нагрузки требуют внимания учебного отдела.' };
|
||||
}
|
||||
return { code: 'critical', label: 'Высокая цена неудобств', summary: 'Сначала разберите критические проблемы и перегруженные дни.' };
|
||||
}
|
||||
|
||||
// --- Фильтрация проблем ---
|
||||
|
||||
export function filterQualityProblems(problems, severity = '', category = '') {
|
||||
return (problems || []).filter(problem => {
|
||||
const severityMatches = !severity || problem.severity === severity;
|
||||
const problemTypes = problem.problemTypes?.length ? problem.problemTypes : [problem.type];
|
||||
const categoryMatches = !category || problemTypes.some(type => problemCategory(type) === category);
|
||||
return severityMatches && categoryMatches;
|
||||
}).sort((a, b) => (Number(b.penalty) || 0) - (Number(a.penalty) || 0));
|
||||
}
|
||||
|
||||
function problemCategory(type = '') {
|
||||
if (type.includes('GAP') || type.includes('COMPACTNESS')) return 'GAPS';
|
||||
if (type.includes('LOAD')) return 'LOAD';
|
||||
if (type.includes('ROOM')) return 'ROOM';
|
||||
return 'PREFERENCE';
|
||||
}
|
||||
|
||||
// --- Подписи ---
|
||||
|
||||
export function versionStatusLabel(status) {
|
||||
return { DRAFT: 'Черновик', PUBLISHED: 'Опубликована', ARCHIVED: 'Архив' }[status] || 'Версия';
|
||||
}
|
||||
|
||||
export function severityLabel(severity) {
|
||||
return { CRITICAL: 'Критично', HIGH: 'Высокая', MEDIUM: 'Средняя', LOW: 'Низкая' }[severity] || severity;
|
||||
}
|
||||
|
||||
export function formatMetricValue(metric) {
|
||||
const value = Number(metric?.value || 0);
|
||||
const formatted = Number.isInteger(value)
|
||||
? String(value)
|
||||
: value.toLocaleString('ru-RU', { maximumFractionDigits: 1 });
|
||||
return `${formatted}${metric?.unit === '%' ? '%' : metric?.unit ? ` ${metric.unit}` : ''}`;
|
||||
}
|
||||
|
||||
export function qualitySemesterLabel(semester) {
|
||||
const type = semester.semesterType === 'autumn' ? 'Осенний' : 'Весенний';
|
||||
return `${semester.academicYearTitle} · ${type} · ${formatQualityDate(semester.startDate)}—${formatQualityDate(semester.endDate)}`;
|
||||
}
|
||||
|
||||
export function formatQualityDate(value) {
|
||||
if (!value) return 'дата не указана';
|
||||
const [year, month, day] = String(value).split('-');
|
||||
return `${day}.${month}.${year}`;
|
||||
}
|
||||
|
||||
export function lessonWord(count) {
|
||||
const mod100 = count % 100;
|
||||
const mod10 = count % 10;
|
||||
if (mod100 >= 11 && mod100 <= 14) return 'занятий';
|
||||
if (mod10 === 1) return 'занятие';
|
||||
if (mod10 >= 2 && mod10 <= 4) return 'занятия';
|
||||
return 'занятий';
|
||||
}
|
||||
|
||||
export function problemTotalLabel(analysis) {
|
||||
return analysis.problemsTruncated
|
||||
? `Показаны первые ${analysis.problems.length} из ${analysis.totalProblemCount} правил`
|
||||
: `Правил с проблемами: ${analysis.totalProblemCount}`;
|
||||
}
|
||||
|
||||
// --- Контракты API ---
|
||||
|
||||
export function scheduleQualityEndpoint(semesterId, versionId) {
|
||||
return `/api/edu-office/schedule/quality?semesterId=${encodeURIComponent(semesterId)}&versionId=${encodeURIComponent(versionId)}`;
|
||||
}
|
||||
|
||||
export async function fetchScheduleQualityAnalysis(semesterId, versionId, client = apiClient) {
|
||||
return client.get(scheduleQualityEndpoint(semesterId, versionId), { errorMessage: 'Не удалось выполнить анализ качества.' });
|
||||
}
|
||||
|
||||
export { fetchScheduleSemesters, fetchScheduleVersions, formatLocalDate };
|
||||
859
frontend/react/admin/schedule-service.js
Normal file
859
frontend/react/admin/schedule-service.js
Normal file
@@ -0,0 +1,859 @@
|
||||
import { apiClient } from '../shared/api-client.js';
|
||||
import { formatLocalDate } from '../../admin/js/utils.js';
|
||||
import { parseIsoDate, toIsoDate, trimTime } from './schedule-view-service.js';
|
||||
|
||||
// Волна 6 (этап 5): чистая логика и контракты API конструктора правил
|
||||
// расписания, перенесённые из legacy admin/js/views/schedule.js без изменения
|
||||
// поведения.
|
||||
|
||||
export const DAY_OPTIONS = Object.freeze([
|
||||
Object.freeze({ value: 1, label: 'Понедельник' }),
|
||||
Object.freeze({ value: 2, label: 'Вторник' }),
|
||||
Object.freeze({ value: 3, label: 'Среда' }),
|
||||
Object.freeze({ value: 4, label: 'Четверг' }),
|
||||
Object.freeze({ value: 5, label: 'Пятница' }),
|
||||
Object.freeze({ value: 6, label: 'Суббота' }),
|
||||
Object.freeze({ value: 7, label: 'Воскресенье' })
|
||||
]);
|
||||
|
||||
export const PARITY_LABELS = Object.freeze({
|
||||
BOTH: 'каждую неделю',
|
||||
ODD: 'нечётная',
|
||||
EVEN: 'чётная'
|
||||
});
|
||||
|
||||
export const CELL_PARITY_LAYOUT = Object.freeze([
|
||||
Object.freeze({ parity: 'ODD', label: 'Нечётная' }),
|
||||
Object.freeze({ parity: 'EVEN', label: 'Чётная' })
|
||||
]);
|
||||
|
||||
export const SEMESTER_LABELS = Object.freeze({
|
||||
autumn: 'осенний',
|
||||
spring: 'весенний'
|
||||
});
|
||||
|
||||
export const LESSON_FORMAT_OPTIONS = Object.freeze([
|
||||
Object.freeze({ value: 'Очно', label: 'Очно' }),
|
||||
Object.freeze({ value: 'Онлайн', label: 'Онлайн' })
|
||||
]);
|
||||
|
||||
export const WHOLE_GROUP_SUBGROUP_VALUE = '__whole_group__';
|
||||
export const ACADEMIC_HOURS_PER_SLOT = 2;
|
||||
|
||||
const VISUAL_MENU_GAP = 8;
|
||||
const VISUAL_MENU_VIEWPORT_PADDING = 8;
|
||||
|
||||
export const LESSON_TYPE_LIMITS = Object.freeze([
|
||||
Object.freeze({
|
||||
key: 'lecture',
|
||||
label: 'Лекции',
|
||||
hoursField: 'lectureAcademicHours',
|
||||
startWeekField: 'lectureStartWeek'
|
||||
}),
|
||||
Object.freeze({
|
||||
key: 'laboratory',
|
||||
label: 'Лабораторные',
|
||||
hoursField: 'laboratoryAcademicHours',
|
||||
startWeekField: 'laboratoryStartWeek'
|
||||
}),
|
||||
Object.freeze({
|
||||
key: 'practice',
|
||||
label: 'Практики',
|
||||
hoursField: 'practiceAcademicHours',
|
||||
startWeekField: 'practiceStartWeek'
|
||||
})
|
||||
]);
|
||||
|
||||
// --- Позиционирование контекстного меню визуального расписания ---
|
||||
|
||||
export function calculateVisualContextMenuPosition({
|
||||
anchorRect,
|
||||
menuWidth,
|
||||
menuHeight,
|
||||
viewportWidth,
|
||||
viewportHeight
|
||||
}) {
|
||||
const maxLeft = Math.max(VISUAL_MENU_VIEWPORT_PADDING, viewportWidth - menuWidth - VISUAL_MENU_VIEWPORT_PADDING);
|
||||
const maxTop = Math.max(VISUAL_MENU_VIEWPORT_PADDING, viewportHeight - menuHeight - VISUAL_MENU_VIEWPORT_PADDING);
|
||||
let left = anchorRect.right + VISUAL_MENU_GAP;
|
||||
let top = anchorRect.top;
|
||||
|
||||
if (left > maxLeft) {
|
||||
left = anchorRect.left - menuWidth - VISUAL_MENU_GAP;
|
||||
}
|
||||
if (top > maxTop) {
|
||||
top = anchorRect.bottom - menuHeight;
|
||||
}
|
||||
|
||||
return {
|
||||
left: Math.round(Math.max(VISUAL_MENU_VIEWPORT_PADDING, Math.min(left, maxLeft))),
|
||||
top: Math.round(Math.max(VISUAL_MENU_VIEWPORT_PADDING, Math.min(top, maxTop)))
|
||||
};
|
||||
}
|
||||
|
||||
// --- Слоты формы ---
|
||||
|
||||
export function scheduleSlotActionLabel(slotCount) {
|
||||
return slotCount === 1 ? 'Очистить' : 'Удалить';
|
||||
}
|
||||
|
||||
export function shouldMarkRuleFormDirty({ programmatic = false, type = '', fromAsyncCombobox = false } = {}) {
|
||||
return !programmatic && !(type === 'input' && fromAsyncCombobox);
|
||||
}
|
||||
|
||||
// --- Версии расписания ---
|
||||
|
||||
export function chooseScheduleVersion(versions, semesterId = '', requestedVersionId = '') {
|
||||
const available = (versions || []).filter(version => ['PUBLISHED', 'DRAFT'].includes(version.status));
|
||||
return available.find(version => String(version.id) === String(requestedVersionId))
|
||||
|| available.find(version => version.status === 'PUBLISHED'
|
||||
&& String(version.semesterId) === String(semesterId))
|
||||
|| available.find(version => version.status === 'PUBLISHED')
|
||||
|| available.find(version => String(version.semesterId) === String(semesterId))
|
||||
|| available[0]
|
||||
|| null;
|
||||
}
|
||||
|
||||
export function scheduleVersionOptionGroups(versions) {
|
||||
const published = versions.filter(version => version.status === 'PUBLISHED');
|
||||
const drafts = versions.filter(version => version.status === 'DRAFT');
|
||||
return [
|
||||
{ label: 'Опубликованные расписания', items: published },
|
||||
{ label: 'Черновики', items: drafts }
|
||||
].filter(group => group.items.length);
|
||||
}
|
||||
|
||||
export function versionContextTexts(version) {
|
||||
const isDraft = version?.status === 'DRAFT';
|
||||
if (!version) {
|
||||
return {
|
||||
status: 'PUBLISHED',
|
||||
eyebrow: 'Основной режим',
|
||||
title: 'Опубликованное расписание',
|
||||
note: 'Расписание для редактирования не найдено.'
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: isDraft ? 'DRAFT' : 'PUBLISHED',
|
||||
eyebrow: isDraft ? 'Безопасная рабочая копия' : 'Основной режим',
|
||||
title: isDraft ? 'Редактирование черновика' : 'Опубликованное расписание',
|
||||
note: isDraft
|
||||
? `Версия ${version.versionNumber} · ${version.name}. Изменения не видны пользователям до публикации.`
|
||||
: `Версия ${version.versionNumber} · ${version.name}. Сохранённые изменения сразу видны пользователям.`
|
||||
};
|
||||
}
|
||||
|
||||
// --- Подписи семестров и правил ---
|
||||
|
||||
function normalizeSemesterType(type) {
|
||||
return String(type || '').toLowerCase();
|
||||
}
|
||||
|
||||
export function semesterLabel(semester) {
|
||||
const type = normalizeSemesterType(semester.semesterType);
|
||||
return `${semester.academicYearTitle || '-'}, ${SEMESTER_LABELS[type] || type} (${semester.startDate} - ${semester.endDate})`;
|
||||
}
|
||||
|
||||
export function shortSemesterLabel(semester) {
|
||||
const type = normalizeSemesterType(semester.semesterType);
|
||||
return `${semester.academicYearTitle || '-'}, ${SEMESTER_LABELS[type] || type}`;
|
||||
}
|
||||
|
||||
export function formatSemester(rule) {
|
||||
const type = normalizeSemesterType(rule.semesterType);
|
||||
return `${rule.academicYearTitle || '-'}, ${SEMESTER_LABELS[type] || type || '-'}`;
|
||||
}
|
||||
|
||||
export function semesterTextById(semesterId, semesters) {
|
||||
const semester = (semesters || []).find(item => String(item.id) === String(semesterId));
|
||||
return semester ? shortSemesterLabel(semester) : 'семестр не выбран';
|
||||
}
|
||||
|
||||
export function typeLoadText(rule) {
|
||||
return LESSON_TYPE_LIMITS
|
||||
.map(config => {
|
||||
const hours = Number(rule?.[config.hoursField] || 0);
|
||||
const startWeek = Number(rule?.[config.startWeekField] || 1);
|
||||
return hours > 0 ? `${config.label}: ${hours} ч, с ${startWeek} недели` : '';
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('; ');
|
||||
}
|
||||
|
||||
export function groupNameById(id, groups) {
|
||||
return (groups || []).find(group => String(group.id) === String(id))?.name || `Группа ${id}`;
|
||||
}
|
||||
|
||||
export function ruleDraftTitle(payload, subjects) {
|
||||
const subject = (subjects || []).find(item => String(item.id) === String(payload.subjectId));
|
||||
return subject?.name || `Правило по дисциплине ${payload.subjectId}`;
|
||||
}
|
||||
|
||||
export function ruleTitle(rule, subjects) {
|
||||
return rule?.subjectName || ruleDraftTitle(rule || {}, subjects);
|
||||
}
|
||||
|
||||
export function ruleDraftMeta(payload, { groups, semesters }) {
|
||||
const groupNames = (payload.groupIds || []).map(id => groupNameById(id, groups)).join(', ') || 'группы не выбраны';
|
||||
return [semesterTextById(payload.semesterId, semesters), groupNames, typeLoadText(payload)]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
}
|
||||
|
||||
export function ruleMeta(rule, { groups, semesters }) {
|
||||
const groupNames = (rule.groupNames || []).join(', ')
|
||||
|| (rule.groupIds || []).map(id => groupNameById(id, groups)).join(', ')
|
||||
|| 'группы не выбраны';
|
||||
return [formatSemester(rule), groupNames, typeLoadText(rule)]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
}
|
||||
|
||||
// --- Типы занятий ---
|
||||
|
||||
export function lessonTypeKey(lessonType) {
|
||||
const name = String(lessonType.name || lessonType.lessonType || '').toLowerCase();
|
||||
if (name.includes('лекц')) return 'lecture';
|
||||
if (name.includes('лаб')) return 'laboratory';
|
||||
if (name.includes('практ')) return 'practice';
|
||||
return null;
|
||||
}
|
||||
|
||||
export function lessonTypeKeyById(lessonTypeId, lessonTypes) {
|
||||
const lessonType = (lessonTypes || []).find(item => String(item.id) === String(lessonTypeId));
|
||||
return lessonType ? lessonTypeKey(lessonType) : null;
|
||||
}
|
||||
|
||||
function lessonTypeRank(lessonType) {
|
||||
const key = lessonTypeKey(lessonType);
|
||||
if (key === 'lecture') return 0;
|
||||
if (key === 'laboratory') return 1;
|
||||
if (key === 'practice') return 2;
|
||||
return 3;
|
||||
}
|
||||
|
||||
export function toLessonTypeOption(lessonType) {
|
||||
return { value: lessonType.id, label: lessonType.name || lessonType.lessonType || `ID ${lessonType.id}` };
|
||||
}
|
||||
|
||||
export function orderedLessonTypes(lessonTypes) {
|
||||
return [...(lessonTypes || [])].sort((first, second) => (
|
||||
lessonTypeRank(first) - lessonTypeRank(second)
|
||||
|| naturalCompare(toLessonTypeOption(first).label, toLessonTypeOption(second).label)
|
||||
));
|
||||
}
|
||||
|
||||
export function isLaboratorySlot(slot, lessonTypes) {
|
||||
return lessonTypeKeyById(slot.lessonTypeId, lessonTypes) === 'laboratory';
|
||||
}
|
||||
|
||||
export function toTimeSlotOption(slot) {
|
||||
return { value: slot.id, label: `${slot.orderNumber} пара, ${trimTime(slot.startTime)} - ${trimTime(slot.endTime)}` };
|
||||
}
|
||||
|
||||
// --- Подгруппы ---
|
||||
|
||||
export function slotSubgroupIds(slot) {
|
||||
if (Array.isArray(slot.subgroupIds) && slot.subgroupIds.length) {
|
||||
return slot.subgroupIds.map(Number).filter(Boolean);
|
||||
}
|
||||
return slot.subgroupId ? [Number(slot.subgroupId)] : [];
|
||||
}
|
||||
|
||||
export function slotSubgroupNames(slot) {
|
||||
if (Array.isArray(slot.subgroupNames) && slot.subgroupNames.length) {
|
||||
return slot.subgroupNames.filter(Boolean);
|
||||
}
|
||||
return slot.subgroupName ? [slot.subgroupName] : [];
|
||||
}
|
||||
|
||||
export function subgroupLabel(subgroup) {
|
||||
return `${subgroup.groupName || `Группа ${subgroup.groupId}`}: ${subgroup.name}`;
|
||||
}
|
||||
|
||||
export function matchingSubgroupsForGroups(subgroups, selectedGroupIds) {
|
||||
const selectedGroups = new Set((selectedGroupIds || []).map(String));
|
||||
return (subgroups || [])
|
||||
.filter(subgroup => selectedGroups.has(String(subgroup.groupId)))
|
||||
.sort((first, second) => subgroupLabel(first).localeCompare(subgroupLabel(second), 'ru-RU'));
|
||||
}
|
||||
|
||||
export function slotSubgroupText(slot, subgroups) {
|
||||
const selectedIds = slotSubgroupIds(slot);
|
||||
if (!selectedIds.length) return 'Все выбранные группы';
|
||||
if (selectedIds.length === 1) {
|
||||
const subgroup = (subgroups || []).find(item => String(item.id) === String(selectedIds[0]));
|
||||
return subgroup ? subgroupLabel(subgroup) : 'Выбрана 1 подгруппа';
|
||||
}
|
||||
return `Выбрано подгрупп: ${selectedIds.length}`;
|
||||
}
|
||||
|
||||
// --- Payload правил ---
|
||||
|
||||
export function ruleSlotToPayload(slot) {
|
||||
return {
|
||||
id: slot.id || null,
|
||||
dayOfWeek: Number(slot.dayOfWeek),
|
||||
parity: slot.parity || 'BOTH',
|
||||
timeSlotId: Number(slot.timeSlotId),
|
||||
subgroupId: slot.subgroupId || null,
|
||||
subgroupIds: slotSubgroupIds(slot),
|
||||
teacherId: Number(slot.teacherId),
|
||||
classroomId: Number(slot.classroomId),
|
||||
lessonTypeId: Number(slot.lessonTypeId),
|
||||
lessonFormat: slot.lessonFormat || 'Очно'
|
||||
};
|
||||
}
|
||||
|
||||
export function buildRulePayloadFromRule(rule, slots, fallbackVersionId = '') {
|
||||
const groupIds = (rule.groupIds || []).map(Number).filter(Boolean);
|
||||
if (!rule.id) throw new Error('Конфликтующее правило не найдено');
|
||||
if (!rule.subjectId) throw new Error('У конфликтующего правила не указана дисциплина');
|
||||
if (!rule.semesterId) throw new Error('У конфликтующего правила не указан семестр');
|
||||
if (!groupIds.length) throw new Error('У конфликтующего правила нет групп');
|
||||
if (!slots.length) throw new Error('У конфликтующего правила нет слотов');
|
||||
return {
|
||||
id: Number(rule.id),
|
||||
subjectId: Number(rule.subjectId),
|
||||
semesterId: Number(rule.semesterId),
|
||||
scheduleVersionId: Number(rule.scheduleVersionId || fallbackVersionId),
|
||||
lectureAcademicHours: Number(rule.lectureAcademicHours || 0),
|
||||
laboratoryAcademicHours: Number(rule.laboratoryAcademicHours || 0),
|
||||
practiceAcademicHours: Number(rule.practiceAcademicHours || 0),
|
||||
lectureStartWeek: Number(rule.lectureStartWeek || 1),
|
||||
laboratoryStartWeek: Number(rule.laboratoryStartWeek || 1),
|
||||
practiceStartWeek: Number(rule.practiceStartWeek || 1),
|
||||
groupIds,
|
||||
slots
|
||||
};
|
||||
}
|
||||
|
||||
export function validateTypeLimits(typeLimits, slots, lessonTypes) {
|
||||
const selectedTypeKeys = new Set(
|
||||
(slots || []).map(slot => lessonTypeKeyById(slot.lessonTypeId, lessonTypes)).filter(Boolean)
|
||||
);
|
||||
let hasHours = false;
|
||||
for (const config of LESSON_TYPE_LIMITS) {
|
||||
const hours = typeLimits[config.hoursField];
|
||||
const startWeek = typeLimits[config.startWeekField];
|
||||
if (hours < 0) throw new Error('Количество часов по типам занятий не может быть отрицательным');
|
||||
if (!startWeek || startWeek <= 0) throw new Error('Неделя начала занятий должна быть больше нуля');
|
||||
if (hours > 0) hasHours = true;
|
||||
if (hours > 0 && !selectedTypeKeys.has(config.key)) {
|
||||
throw new Error(`Для типа "${config.label}" добавьте хотя бы один слот`);
|
||||
}
|
||||
if (hours === 0 && selectedTypeKeys.has(config.key)) {
|
||||
throw new Error(`Для типа "${config.label}" укажите часы или удалите слоты этого типа`);
|
||||
}
|
||||
}
|
||||
if (!hasHours) throw new Error('Укажите часы хотя бы для одного типа занятий');
|
||||
}
|
||||
|
||||
export function validateSlotSubgroup(slot, index, { lessonTypes, subgroups, selectedGroupIds }) {
|
||||
const typeKey = lessonTypeKeyById(slot.lessonTypeId, lessonTypes);
|
||||
const subgroupIds = slotSubgroupIds(slot);
|
||||
if (subgroupIds.length && typeKey !== 'laboratory') {
|
||||
throw new Error(`В слоте ${index + 1} подгруппа доступна только для лабораторных`);
|
||||
}
|
||||
if (!subgroupIds.length) return;
|
||||
|
||||
const selectedGroups = new Set((selectedGroupIds || []).map(String));
|
||||
const subgroupGroupIds = new Set();
|
||||
for (const subgroupId of subgroupIds) {
|
||||
const selectedSubgroup = (subgroups || []).find(item => String(item.id) === String(subgroupId));
|
||||
if (!selectedSubgroup) {
|
||||
throw new Error(`В слоте ${index + 1} подгруппа не найдена`);
|
||||
}
|
||||
if (!selectedGroups.has(String(selectedSubgroup.groupId))) {
|
||||
throw new Error(`В слоте ${index + 1} подгруппа должна относиться к выбранным группам правила`);
|
||||
}
|
||||
if (subgroupGroupIds.has(String(selectedSubgroup.groupId))) {
|
||||
throw new Error(`В слоте ${index + 1} выберите не больше одной подгруппы каждой группы`);
|
||||
}
|
||||
subgroupGroupIds.add(String(selectedSubgroup.groupId));
|
||||
}
|
||||
}
|
||||
|
||||
// Слоты формы → payload с валидацией обязательных полей (как readRuleSlots(true)).
|
||||
export function formSlotsToPayloads(slots, { lessonTypes, subgroups, selectedGroupIds }) {
|
||||
return (slots || []).map((slot, index) => {
|
||||
if (!slot.dayOfWeek || !slot.parity || !slot.timeSlotId
|
||||
|| !slot.teacherId || !slot.classroomId || !slot.lessonTypeId || !slot.lessonFormat) {
|
||||
throw new Error(`Заполните слот ${index + 1}`);
|
||||
}
|
||||
const payload = ruleSlotToPayload(slot);
|
||||
validateSlotSubgroup(payload, index, { lessonTypes, subgroups, selectedGroupIds });
|
||||
return payload;
|
||||
});
|
||||
}
|
||||
|
||||
export function buildRulePayloadFromForm({
|
||||
ruleId,
|
||||
subjectId,
|
||||
semesterId,
|
||||
versionId,
|
||||
typeLimits,
|
||||
groupIds,
|
||||
slots,
|
||||
lessonTypes,
|
||||
subgroups
|
||||
}) {
|
||||
if (!subjectId) throw new Error('Выберите дисциплину');
|
||||
if (!versionId) throw new Error('Выберите версию расписания');
|
||||
if (!semesterId) throw new Error('Выберите семестр');
|
||||
validateTypeLimits(typeLimits, slots, lessonTypes);
|
||||
if (!groupIds.length) throw new Error('Выберите хотя бы одну группу');
|
||||
if (!slots.length) throw new Error('Добавьте хотя бы один слот занятия');
|
||||
return {
|
||||
id: ruleId ? Number(ruleId) : null,
|
||||
subjectId: Number(subjectId),
|
||||
semesterId: Number(semesterId),
|
||||
scheduleVersionId: Number(versionId),
|
||||
...typeLimits,
|
||||
groupIds,
|
||||
slots
|
||||
};
|
||||
}
|
||||
|
||||
// --- Конфликты правил ---
|
||||
|
||||
export function isScheduleRuleConflictError(error) {
|
||||
return error?.status === 409 && Boolean(error?.data?.conflictRule);
|
||||
}
|
||||
|
||||
export function conflictFieldClass(field, fields) {
|
||||
return (fields || []).includes(field) ? 'schedule-conflict-field-hit' : '';
|
||||
}
|
||||
|
||||
export function conflictFieldLabel(field) {
|
||||
if (field === 'teacher') return 'Преподаватель';
|
||||
if (field === 'classroom') return 'Аудитория';
|
||||
if (field === 'group') return 'Группа';
|
||||
return field;
|
||||
}
|
||||
|
||||
export function conflictSlotCurrentText(slot) {
|
||||
const parity = PARITY_LABELS[slot.parity] || slot.parity || '-';
|
||||
return [
|
||||
slot.dayName,
|
||||
slot.timeSlotLabel,
|
||||
parity,
|
||||
slot.teacherName,
|
||||
slot.classroomName
|
||||
].filter(Boolean).join(' · ');
|
||||
}
|
||||
|
||||
export function conflictReasonLabels(fields, reasons) {
|
||||
const labels = Array.isArray(reasons) && reasons.length
|
||||
? reasons
|
||||
: (fields || []).map(conflictFieldLabel).filter(Boolean);
|
||||
return labels;
|
||||
}
|
||||
|
||||
// --- Подсказки пожеланий преподавателя ---
|
||||
|
||||
export function preferenceHintLabel(type) {
|
||||
return {
|
||||
HARD_UNAVAILABLE: 'Строгая недоступность',
|
||||
SOFT_PREFERRED: 'Предпочтительно',
|
||||
SOFT_UNWANTED: 'Нежелательно',
|
||||
CONSECUTIVE: 'Пары подряд',
|
||||
NO_GAPS: 'Без окон'
|
||||
}[type] || type;
|
||||
}
|
||||
|
||||
export function preferenceHintPills({ semesterId, teacherId, dayOfWeek, timeSlotId, preferences }) {
|
||||
if (!semesterId || !teacherId) return null;
|
||||
const teacherItems = (preferences || []).filter(item =>
|
||||
Number(item.semesterId) === Number(semesterId) && Number(item.teacherId) === Number(teacherId)
|
||||
);
|
||||
const exact = teacherItems.filter(item =>
|
||||
Number(item.dayOfWeek) === Number(dayOfWeek) && Number(item.timeSlotId) === Number(timeSlotId)
|
||||
);
|
||||
const dateCount = teacherItems.filter(item => item.preferenceType === 'HARD_UNAVAILABLE' && item.preferenceDate).length;
|
||||
const flags = teacherItems.filter(item => ['CONSECUTIVE', 'NO_GAPS'].includes(item.preferenceType));
|
||||
return [
|
||||
...exact.map(item => ({
|
||||
text: preferenceHintLabel(item.preferenceType),
|
||||
kind: item.preferenceType === 'HARD_UNAVAILABLE' ? 'hard'
|
||||
: item.preferenceType === 'SOFT_PREFERRED' ? 'preferred' : 'unwanted'
|
||||
})),
|
||||
...(dateCount ? [{ text: `Недоступных дат: ${dateCount}`, kind: 'dates' }] : []),
|
||||
...flags.map(item => ({ text: preferenceHintLabel(item.preferenceType), kind: 'compact' }))
|
||||
];
|
||||
}
|
||||
|
||||
// --- Визуальное расписание ---
|
||||
|
||||
export function visualCellKey(groupId, dayOfWeek, slotKey) {
|
||||
return `${groupId}:${dayOfWeek}:${slotKey}`;
|
||||
}
|
||||
|
||||
export function visualSlotKey(slot) {
|
||||
return String(slot.timeSlotId ?? slot.timeSlotOrder ?? slot.timeSlotLabel ?? 'unknown');
|
||||
}
|
||||
|
||||
export function visualRuleSlotKey(slot, index) {
|
||||
return String(slot.id ?? `${visualSlotKey(slot)}:${slot.dayOfWeek}:${slot.parity}:${index}`);
|
||||
}
|
||||
|
||||
export function activeWeekKey(ruleId, groupId, slotKey) {
|
||||
return `${ruleId}:${groupId}:${slotKey}`;
|
||||
}
|
||||
|
||||
export function slotAppliesToWeek(slot, week) {
|
||||
if (!slot.parity || slot.parity === 'BOTH') return true;
|
||||
const weekParity = week % 2 === 0 ? 'EVEN' : 'ODD';
|
||||
return slot.parity === weekParity;
|
||||
}
|
||||
|
||||
export function maxWeeksForRule(rule, semesters) {
|
||||
const semester = (semesters || []).find(item => String(item.id) === String(rule.semesterId));
|
||||
if (semester?.startDate && semester?.endDate) {
|
||||
const start = parseIsoDate(semester.startDate);
|
||||
const end = parseIsoDate(semester.endDate);
|
||||
const days = Math.max(1, Math.round((end - start) / 86400000) + 1);
|
||||
return Math.max(1, Math.ceil(days / 7));
|
||||
}
|
||||
return 18;
|
||||
}
|
||||
|
||||
export function compactWeekRanges(weeks, maxWeeks, parity) {
|
||||
const uniqueWeeks = [...new Set((weeks || []).map(Number).filter(Boolean))].sort((a, b) => a - b);
|
||||
if (!uniqueWeeks.length) return '';
|
||||
const start = uniqueWeeks[0];
|
||||
const end = uniqueWeeks[uniqueWeeks.length - 1];
|
||||
const semesterEndWeek = lastApplicableSemesterWeek(maxWeeks, parity);
|
||||
const semesterStartWeek = firstApplicableSemesterWeek(parity);
|
||||
if (semesterEndWeek && start <= semesterStartWeek && end >= semesterEndWeek) return '';
|
||||
if (semesterEndWeek && end >= semesterEndWeek) return `(с ${start} нед.)`;
|
||||
return weekRangeText(start, end);
|
||||
}
|
||||
|
||||
function weekRangeText(start, end) {
|
||||
return start === end
|
||||
? `(${start} нед.)`
|
||||
: `(с ${start} по ${end} нед.)`;
|
||||
}
|
||||
|
||||
function firstApplicableSemesterWeek(parity) {
|
||||
return parity === 'EVEN' ? 2 : 1;
|
||||
}
|
||||
|
||||
function lastApplicableSemesterWeek(maxWeeks, parity) {
|
||||
const totalWeeks = Number(maxWeeks || 0);
|
||||
if (!totalWeeks) return 0;
|
||||
if (parity === 'ODD' && totalWeeks % 2 === 0) return totalWeeks - 1;
|
||||
if (parity === 'EVEN' && totalWeeks % 2 !== 0) return totalWeeks - 1;
|
||||
return totalWeeks;
|
||||
}
|
||||
|
||||
function compareSlotItems(first, second) {
|
||||
return Number(first.slot.dayOfWeek || 0) - Number(second.slot.dayOfWeek || 0)
|
||||
|| compareVisualSlots(slotDescriptor(first.slot), slotDescriptor(second.slot))
|
||||
|| Number(first.index || 0) - Number(second.index || 0);
|
||||
}
|
||||
|
||||
function slotDescriptor(slot) {
|
||||
return {
|
||||
key: visualSlotKey(slot),
|
||||
orderNumber: slot.timeSlotOrder,
|
||||
timeLabel: slot.timeSlotLabel
|
||||
};
|
||||
}
|
||||
|
||||
export function compareVisualSlots(first, second) {
|
||||
const firstOrder = Number(first.orderNumber || first.key);
|
||||
const secondOrder = Number(second.orderNumber || second.key);
|
||||
if (!Number.isNaN(firstOrder) && !Number.isNaN(secondOrder) && firstOrder !== secondOrder) {
|
||||
return firstOrder - secondOrder;
|
||||
}
|
||||
return naturalCompare(first.timeLabel, second.timeLabel) || naturalCompare(first.key, second.key);
|
||||
}
|
||||
|
||||
export function buildActiveWeeksByRuleSlot(visualRules, { lessonTypes, semesters }) {
|
||||
const activeWeeks = new Map();
|
||||
(visualRules || []).forEach(rule => {
|
||||
const maxWeeks = maxWeeksForRule(rule, semesters);
|
||||
(rule.groupIds || []).forEach(groupId => {
|
||||
LESSON_TYPE_LIMITS.forEach(config => {
|
||||
const totalHours = Number(rule[config.hoursField] || 0);
|
||||
if (totalHours <= 0) return;
|
||||
const categorySlots = (rule.slots || [])
|
||||
.map((slot, index) => ({ slot, index }))
|
||||
.filter(item => lessonTypeKeyById(item.slot.lessonTypeId, lessonTypes) === config.key)
|
||||
.sort(compareSlotItems);
|
||||
if (!categorySlots.length) return;
|
||||
|
||||
let consumedHours = 0;
|
||||
const startWeek = Math.max(1, Number(rule[config.startWeekField] || 1));
|
||||
for (let week = startWeek; week <= maxWeeks && consumedHours < totalHours; week += 1) {
|
||||
for (const item of categorySlots) {
|
||||
if (consumedHours >= totalHours) break;
|
||||
if (!slotAppliesToWeek(item.slot, week)) continue;
|
||||
const key = activeWeekKey(rule.id, groupId, visualRuleSlotKey(item.slot, item.index));
|
||||
if (!activeWeeks.has(key)) activeWeeks.set(key, []);
|
||||
activeWeeks.get(key).push(week);
|
||||
consumedHours += ACADEMIC_HOURS_PER_SLOT;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
return activeWeeks;
|
||||
}
|
||||
|
||||
export function buildVisualLessonsByCell(allowedGroupIds, visualRules, { lessonTypes, semesters, groups }) {
|
||||
const grouped = new Map();
|
||||
const activeWeeks = buildActiveWeeksByRuleSlot(visualRules, { lessonTypes, semesters });
|
||||
(visualRules || []).forEach(rule => {
|
||||
(rule.slots || []).forEach((slot, index) => {
|
||||
const slotKey = visualRuleSlotKey(slot, index);
|
||||
(rule.groupIds || []).forEach((groupId, groupIndex) => {
|
||||
if (!allowedGroupIds.has(String(groupId))) return;
|
||||
const weeks = activeWeeks.get(activeWeekKey(rule.id, groupId, slotKey)) || [];
|
||||
if (!weeks.length) return;
|
||||
const lesson = {
|
||||
id: `${rule.id}:${slotKey}:${groupId}`,
|
||||
ruleId: rule.id,
|
||||
slotId: slot.id || '',
|
||||
slotIndex: index,
|
||||
dayOfWeek: slot.dayOfWeek,
|
||||
timeSlotId: slot.timeSlotId,
|
||||
parity: slot.parity || 'BOTH',
|
||||
subjectName: rule.subjectName || 'Без дисциплины',
|
||||
teacherName: slot.teacherName || '',
|
||||
classroomName: slot.classroomName || '',
|
||||
lessonTypeName: slot.lessonTypeName || '',
|
||||
lessonFormat: slot.lessonFormat || '',
|
||||
subgroupNames: slotSubgroupNames(slot),
|
||||
weekText: compactWeekRanges(weeks, maxWeeksForRule(rule, semesters), slot.parity),
|
||||
groupName: (rule.groupNames || [])[groupIndex] || groupNameById(groupId, groups)
|
||||
};
|
||||
const key = visualCellKey(groupId, slot.dayOfWeek, visualSlotKey(slot));
|
||||
if (!grouped.has(key)) grouped.set(key, []);
|
||||
grouped.get(key).push(lesson);
|
||||
});
|
||||
});
|
||||
});
|
||||
grouped.forEach(items => items.sort(compareVisualLessons));
|
||||
return grouped;
|
||||
}
|
||||
|
||||
export function buildVisualRows(columns, slots, lessonsByCell) {
|
||||
const rows = [];
|
||||
DAY_OPTIONS.forEach(day => {
|
||||
const dayRows = (slots || [])
|
||||
.map(slot => ({
|
||||
day,
|
||||
slot,
|
||||
cells: columns.map(group => ({
|
||||
group,
|
||||
lessons: lessonsByCell.get(visualCellKey(group.id, day.value, slot.key)) || []
|
||||
}))
|
||||
}))
|
||||
.filter(row => row.cells.some(cell => cell.lessons.length));
|
||||
|
||||
dayRows.forEach((row, index) => {
|
||||
rows.push({
|
||||
...row,
|
||||
dayRowspan: index === 0 ? dayRows.length : 0
|
||||
});
|
||||
});
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function collectVisualGroups(visualRules, groups) {
|
||||
const byId = new Map();
|
||||
(visualRules || []).forEach(rule => {
|
||||
(rule.groupIds || []).forEach((id, index) => {
|
||||
if (!byId.has(String(id))) {
|
||||
byId.set(String(id), {
|
||||
id,
|
||||
name: (rule.groupNames || [])[index] || groupNameById(id, groups)
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
return Array.from(byId.values()).sort((first, second) => naturalCompare(first.name, second.name));
|
||||
}
|
||||
|
||||
export function collectVisualSlotsFromRules(visualRules) {
|
||||
const byKey = new Map();
|
||||
(visualRules || []).flatMap(rule => rule.slots || []).forEach(slot => {
|
||||
const key = visualSlotKey(slot);
|
||||
if (!byKey.has(key)) {
|
||||
byKey.set(key, {
|
||||
key,
|
||||
orderNumber: slot.timeSlotOrder,
|
||||
orderLabel: slot.timeSlotOrder ? `${slot.timeSlotOrder} пара` : 'Пара',
|
||||
timeLabel: slot.timeSlotLabel || 'Время не указано'
|
||||
});
|
||||
}
|
||||
});
|
||||
return Array.from(byKey.values()).sort(compareVisualSlots);
|
||||
}
|
||||
|
||||
export function visualSlotFromTimeSlot(slot) {
|
||||
return {
|
||||
key: String(slot.id),
|
||||
orderNumber: slot.orderNumber,
|
||||
orderLabel: slot.orderNumber ? `${slot.orderNumber} пара` : 'Пара',
|
||||
timeLabel: slot.startTime || slot.endTime ? `${trimTime(slot.startTime)}-${trimTime(slot.endTime)}` : 'Время не указано'
|
||||
};
|
||||
}
|
||||
|
||||
export function compareVisualLessons(first, second) {
|
||||
return naturalCompare(first.subjectName, second.subjectName)
|
||||
|| naturalCompare(first.weekText, second.weekText)
|
||||
|| naturalCompare(first.lessonTypeName, second.lessonTypeName)
|
||||
|| naturalCompare(first.teacherName, second.teacherName);
|
||||
}
|
||||
|
||||
export function visualLessonSignature(lessons) {
|
||||
return (lessons || [])
|
||||
.map(lesson => [
|
||||
lesson.id,
|
||||
lesson.subjectName,
|
||||
lesson.weekText,
|
||||
lesson.lessonTypeName,
|
||||
lesson.teacherName,
|
||||
lesson.classroomName,
|
||||
lesson.subgroupNames.join(',')
|
||||
].join(':'))
|
||||
.sort((first, second) => naturalCompare(first, second))
|
||||
.join('|');
|
||||
}
|
||||
|
||||
export function uniqueVisualLessons(lessons) {
|
||||
const unique = new Map();
|
||||
(lessons || []).forEach(lesson => {
|
||||
unique.set(visualLessonSignature([lesson]), lesson);
|
||||
});
|
||||
return Array.from(unique.values()).sort(compareVisualLessons);
|
||||
}
|
||||
|
||||
export function naturalCompare(a, b) {
|
||||
return String(a || '').localeCompare(String(b || ''), 'ru', { numeric: true, sensitivity: 'base' });
|
||||
}
|
||||
|
||||
export function ruleCountLabel(count) {
|
||||
const tail = count % 100;
|
||||
if (tail >= 11 && tail <= 14) return 'правил';
|
||||
if (count % 10 === 1) return 'правило';
|
||||
if ([2, 3, 4].includes(count % 10)) return 'правила';
|
||||
return 'правил';
|
||||
}
|
||||
|
||||
export function groupCountLabel(count) {
|
||||
const tail = count % 100;
|
||||
if (tail >= 11 && tail <= 14) return 'групп';
|
||||
if (count % 10 === 1) return 'группа';
|
||||
if ([2, 3, 4].includes(count % 10)) return 'группы';
|
||||
return 'групп';
|
||||
}
|
||||
|
||||
export function rowCountLabel(count) {
|
||||
const tail = count % 100;
|
||||
if (tail >= 11 && tail <= 14) return 'строк';
|
||||
if (count % 10 === 1) return 'строка';
|
||||
if ([2, 3, 4].includes(count % 10)) return 'строки';
|
||||
return 'строк';
|
||||
}
|
||||
|
||||
export function mergeEntitiesById(items) {
|
||||
return [...new Map((items || []).filter(Boolean).map(item => [String(item.id), item])).values()];
|
||||
}
|
||||
|
||||
export { formatLocalDate, toIsoDate };
|
||||
|
||||
// --- Контракты API ---
|
||||
|
||||
export async function fetchSubjects(client = apiClient) {
|
||||
return client.get('/api/subjects', { errorMessage: 'Ошибка загрузки дисциплин' });
|
||||
}
|
||||
|
||||
export async function fetchLessonTypes(client = apiClient) {
|
||||
return client.get('/api/lesson-types', { errorMessage: 'Ошибка загрузки типов занятий' });
|
||||
}
|
||||
|
||||
export async function fetchTimeSlots(client = apiClient) {
|
||||
const slots = await client.get('/api/admin/time-slots', { errorMessage: 'Ошибка загрузки пар' });
|
||||
return (slots || [])
|
||||
.filter(slot => slot.scopeApplyMode === 'DEFAULT')
|
||||
.sort((first, second) => (first.orderNumber || 0) - (second.orderNumber || 0));
|
||||
}
|
||||
|
||||
export async function fetchAcademicYears(client = apiClient) {
|
||||
return client.get('/api/admin/calendar/years', { errorMessage: 'Ошибка загрузки учебных годов' });
|
||||
}
|
||||
|
||||
export async function fetchApprovedTeacherPreferences(client = apiClient) {
|
||||
return client.get('/api/teacher-preferences?status=APPROVED', {
|
||||
errorMessage: 'Ошибка загрузки пожеланий преподавателей'
|
||||
});
|
||||
}
|
||||
|
||||
export function scheduleRulesPageEndpoint({ versionId, page, size, query }) {
|
||||
const params = new URLSearchParams({
|
||||
versionId: String(versionId),
|
||||
page: String(page),
|
||||
size: String(size),
|
||||
query: String(query || '').trim()
|
||||
});
|
||||
return `/api/admin/schedule-rules/page?${params}`;
|
||||
}
|
||||
|
||||
export async function fetchScheduleRulesPage(filters, client = apiClient) {
|
||||
return client.get(scheduleRulesPageEndpoint(filters), { errorMessage: 'Ошибка загрузки правил расписания' });
|
||||
}
|
||||
|
||||
export function scheduleRulesEndpoint(versionId) {
|
||||
return `/api/admin/schedule-rules?versionId=${encodeURIComponent(versionId)}`;
|
||||
}
|
||||
|
||||
export async function fetchScheduleRules(versionId, client = apiClient) {
|
||||
return client.get(scheduleRulesEndpoint(versionId), { errorMessage: 'Ошибка загрузки визуального расписания' });
|
||||
}
|
||||
|
||||
export async function fetchScheduleRule(ruleId, client = apiClient) {
|
||||
return client.get(`/api/admin/schedule-rules/${encodeURIComponent(ruleId)}`, {
|
||||
errorMessage: 'Ошибка загрузки правила расписания'
|
||||
});
|
||||
}
|
||||
|
||||
export async function saveScheduleRule(payload, ruleId, client = apiClient) {
|
||||
return ruleId
|
||||
? client.put(`/api/admin/schedule-rules/${encodeURIComponent(ruleId)}`, payload, {
|
||||
errorMessage: 'Ошибка сохранения правила'
|
||||
})
|
||||
: client.post('/api/admin/schedule-rules', payload, {
|
||||
errorMessage: 'Ошибка сохранения правила'
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteScheduleRule(ruleId, client = apiClient) {
|
||||
return client.delete(`/api/admin/schedule-rules/${encodeURIComponent(ruleId)}`, {
|
||||
errorMessage: 'Ошибка удаления правила'
|
||||
});
|
||||
}
|
||||
|
||||
export function ruleGroupsOptionsEndpoint(query) {
|
||||
const params = new URLSearchParams({ query: String(query || '').trim(), limit: '50' });
|
||||
return `/api/groups/options?${params}`;
|
||||
}
|
||||
|
||||
export async function fetchRuleGroupOptions(query, client = apiClient) {
|
||||
return client.get(ruleGroupsOptionsEndpoint(query), { errorMessage: 'Не удалось найти группы' });
|
||||
}
|
||||
|
||||
export function subgroupsEndpoint(groupIds) {
|
||||
const params = new URLSearchParams();
|
||||
(groupIds || []).forEach(groupId => params.append('groupId', groupId));
|
||||
return `/api/subgroups?${params}`;
|
||||
}
|
||||
|
||||
export async function fetchSubgroupsForGroups(groupIds, client = apiClient) {
|
||||
return client.get(subgroupsEndpoint(groupIds), { errorMessage: 'Не удалось загрузить подгруппы выбранных групп' });
|
||||
}
|
||||
124
frontend/react/admin/schedule-versions-service.js
Normal file
124
frontend/react/admin/schedule-versions-service.js
Normal file
@@ -0,0 +1,124 @@
|
||||
import { apiClient } from '../shared/api-client.js';
|
||||
import { formatLocalDate } from '../../admin/js/utils.js';
|
||||
|
||||
// Срез «Контур публикации» (этап 5, волна 5): чистая логика и контракты API,
|
||||
// перенесённые из legacy admin/js/views/schedule-versions.js без изменения поведения.
|
||||
|
||||
// --- Ключи localStorage для переходов между вкладками расписания ---
|
||||
// (общие с конструктором правил и анализом качества)
|
||||
|
||||
export const SCHEDULE_YEAR_STORAGE_KEY = 'magistr.schedule.yearId';
|
||||
export const SCHEDULE_SEMESTER_STORAGE_KEY = 'magistr.schedule.semesterId';
|
||||
export const SCHEDULE_OPEN_VERSION_STORAGE_KEY = 'magistr.schedule.openVersionId';
|
||||
export const SCHEDULE_OPEN_RULE_STORAGE_KEY = 'magistr.schedule.openRuleId';
|
||||
export const SCHEDULE_VERSION_STORAGE_KEY = 'magistr.schedule.versionId';
|
||||
export const QUALITY_YEAR_STORAGE_KEY = 'magistr.quality.yearId';
|
||||
export const QUALITY_SEMESTER_STORAGE_KEY = 'magistr.quality.semesterId';
|
||||
export const QUALITY_VERSION_STORAGE_KEY = 'magistr.quality.versionId';
|
||||
|
||||
// --- Подписи ---
|
||||
|
||||
export function historyActionLabel(action) {
|
||||
return {
|
||||
CREATED: 'Создан черновик',
|
||||
PUBLISHED: 'Опубликовано',
|
||||
ARCHIVED: 'Архивировано',
|
||||
RESTORED: 'Восстановлено'
|
||||
}[action] || action;
|
||||
}
|
||||
|
||||
export function versionSemesterLabel(semester) {
|
||||
const type = semester.semesterType === 'autumn' ? 'Осенний' : 'Весенний';
|
||||
return `${semester.academicYearTitle} · ${type} семестр`;
|
||||
}
|
||||
|
||||
export function formatVersionDateTime(value) {
|
||||
if (!value) return 'дата не указана';
|
||||
return new Intl.DateTimeFormat('ru-RU', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit'
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
export function ruleChangeTypeLabel(type) {
|
||||
return { ADDED: 'Добавлено', REMOVED: 'Удалено', CHANGED: 'Изменено' }[type] || type;
|
||||
}
|
||||
|
||||
export function draftBasisText(version) {
|
||||
return `Основа: ${version.basedOnVersionId ? `версия #${version.basedOnVersionId}` : 'пустое расписание'}`;
|
||||
}
|
||||
|
||||
// --- Группировка версий ---
|
||||
|
||||
export function splitScheduleVersions(versions) {
|
||||
const list = Array.isArray(versions) ? versions : [];
|
||||
return {
|
||||
published: list.find(version => version.status === 'PUBLISHED') || null,
|
||||
drafts: list.filter(version => version.status === 'DRAFT'),
|
||||
archive: list.filter(version => version.status === 'ARCHIVED')
|
||||
};
|
||||
}
|
||||
|
||||
// --- Сводка проверки перед публикацией ---
|
||||
|
||||
export function publishValidationSummary(validation, diff) {
|
||||
return validation.valid
|
||||
? `Проверка пройдена: ${validation.ruleCount} правил, ${validation.slotCount} слотов. `
|
||||
+ `Занятия: +${diff.addedLessonCount}, ~${diff.changedLessonCount}, −${diff.removedLessonCount}.`
|
||||
: `Публикация заблокирована: ${(validation.issues || []).join('; ')}`;
|
||||
}
|
||||
|
||||
// --- Контракты API ---
|
||||
|
||||
export function scheduleVersionsEndpoint(semesterId) {
|
||||
return `/api/edu-office/schedule/versions?semesterId=${encodeURIComponent(semesterId)}`;
|
||||
}
|
||||
|
||||
export function scheduleVersionHistoryEndpoint(semesterId) {
|
||||
return `/api/edu-office/schedule/versions/history?semesterId=${encodeURIComponent(semesterId)}`;
|
||||
}
|
||||
|
||||
export function scheduleVersionDiffEndpoint(versionId) {
|
||||
return `/api/edu-office/schedule/versions/${encodeURIComponent(versionId)}/diff`;
|
||||
}
|
||||
|
||||
export function scheduleVersionValidateEndpoint(versionId) {
|
||||
return `/api/edu-office/schedule/versions/${encodeURIComponent(versionId)}/validate`;
|
||||
}
|
||||
|
||||
export function scheduleVersionActionEndpoint(versionId, mode) {
|
||||
return `/api/edu-office/schedule/versions/${encodeURIComponent(versionId)}/${mode}`;
|
||||
}
|
||||
|
||||
export async function fetchScheduleSemesters(client = apiClient) {
|
||||
return client.get('/api/schedule/semesters', { errorMessage: 'Не удалось загрузить семестры.' });
|
||||
}
|
||||
|
||||
export async function fetchScheduleVersions(semesterId, client = apiClient) {
|
||||
return client.get(scheduleVersionsEndpoint(semesterId), { errorMessage: 'Не удалось загрузить версии расписания.' });
|
||||
}
|
||||
|
||||
export async function fetchScheduleVersionHistory(semesterId, client = apiClient) {
|
||||
return client.get(scheduleVersionHistoryEndpoint(semesterId), { errorMessage: 'Не удалось загрузить журнал действий.' });
|
||||
}
|
||||
|
||||
export async function fetchScheduleVersionDiff(versionId, client = apiClient) {
|
||||
return client.get(scheduleVersionDiffEndpoint(versionId), { errorMessage: 'Не удалось сравнить версии расписания.' });
|
||||
}
|
||||
|
||||
export async function fetchScheduleVersionValidation(versionId, client = apiClient) {
|
||||
return client.get(scheduleVersionValidateEndpoint(versionId), { errorMessage: 'Не удалось проверить черновик.' });
|
||||
}
|
||||
|
||||
export async function createScheduleDraftVersion(payload, client = apiClient) {
|
||||
return client.post('/api/edu-office/schedule/versions', payload, { errorMessage: 'Не удалось создать черновик.' });
|
||||
}
|
||||
|
||||
export async function runScheduleVersionAction(versionId, mode, reason, client = apiClient) {
|
||||
return client.post(
|
||||
scheduleVersionActionEndpoint(versionId, mode),
|
||||
{ reason },
|
||||
{ errorMessage: 'Операцию не удалось выполнить.' }
|
||||
);
|
||||
}
|
||||
|
||||
export { formatLocalDate };
|
||||
767
frontend/react/admin/schedule-view-service.js
Normal file
767
frontend/react/admin/schedule-view-service.js
Normal file
@@ -0,0 +1,767 @@
|
||||
import { apiClient } from '../shared/api-client.js';
|
||||
|
||||
// Срез «Просмотр расписаний» (этап 5, волна 5): чистая логика и контракты API,
|
||||
// перенесённые из legacy admin/js/views/schedule-view.js без изменения поведения.
|
||||
|
||||
export const ROLE_DEPARTMENT = 'DEPARTMENT';
|
||||
export const TARGET_GROUPS = 'groups';
|
||||
export const TARGET_TEACHERS = 'teachers';
|
||||
export const TARGET_CLASSROOMS = 'classrooms';
|
||||
export const TARGET_DEPARTMENTS = 'departments';
|
||||
|
||||
export const PARITY_LABELS = Object.freeze({
|
||||
BOTH: 'каждая',
|
||||
ODD: 'нечётная',
|
||||
EVEN: 'чётная'
|
||||
});
|
||||
|
||||
export const DAY_SHORT_LABELS = Object.freeze({
|
||||
1: 'Пн',
|
||||
2: 'Вт',
|
||||
3: 'Ср',
|
||||
4: 'Чт',
|
||||
5: 'Пт',
|
||||
6: 'Сб',
|
||||
7: 'Вс'
|
||||
});
|
||||
|
||||
export const DAY_FULL_LABELS = Object.freeze({
|
||||
1: 'Понедельник',
|
||||
2: 'Вторник',
|
||||
3: 'Среда',
|
||||
4: 'Четверг',
|
||||
5: 'Пятница',
|
||||
6: 'Суббота',
|
||||
7: 'Воскресенье'
|
||||
});
|
||||
|
||||
export const DAY_ORDER = Object.freeze([1, 2, 3, 4, 5, 6, 7]);
|
||||
|
||||
export const CELL_PARITY_LAYOUT = Object.freeze([
|
||||
Object.freeze({ parity: 'ODD', label: 'Нечётная' }),
|
||||
Object.freeze({ parity: 'EVEN', label: 'Чётная' })
|
||||
]);
|
||||
|
||||
export const MOBILE_QUERY = '(max-width: 760px)';
|
||||
export const DEFAULT_SEMESTER_WEEKS = 22;
|
||||
export const SCHEDULE_REFRESH_PROMPT = 'Нажмите «Показать», чтобы обновить расписание';
|
||||
|
||||
// --- Дата и время ---
|
||||
|
||||
export function parseIsoDate(value) {
|
||||
const [year, month, day] = String(value).split('-').map(Number);
|
||||
return new Date(year, month - 1, day);
|
||||
}
|
||||
|
||||
export function toIsoDate(date) {
|
||||
return [
|
||||
date.getFullYear(),
|
||||
String(date.getMonth() + 1).padStart(2, '0'),
|
||||
String(date.getDate()).padStart(2, '0')
|
||||
].join('-');
|
||||
}
|
||||
|
||||
export function addDays(date, days) {
|
||||
const next = new Date(date);
|
||||
next.setDate(next.getDate() + days);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function isoDay(value) {
|
||||
const day = parseIsoDate(value).getDay();
|
||||
return day === 0 ? 7 : day;
|
||||
}
|
||||
|
||||
export function datesBetween(startDate, endDate) {
|
||||
const dates = [];
|
||||
for (let date = parseIsoDate(startDate); date <= parseIsoDate(endDate); date.setDate(date.getDate() + 1)) {
|
||||
dates.push(toIsoDate(date));
|
||||
}
|
||||
return dates;
|
||||
}
|
||||
|
||||
export function weekStart(value) {
|
||||
const date = parseIsoDate(value);
|
||||
const day = date.getDay() || 7;
|
||||
date.setDate(date.getDate() + 1 - day);
|
||||
return toIsoDate(date);
|
||||
}
|
||||
|
||||
export function twoWeekRange(value) {
|
||||
const selected = parseIsoDate(value);
|
||||
const start = addDays(selected, 1 - isoDay(value));
|
||||
const end = addDays(start, 13);
|
||||
return {
|
||||
startDate: toIsoDate(start),
|
||||
endDate: toIsoDate(end)
|
||||
};
|
||||
}
|
||||
|
||||
export function formatDate(value) {
|
||||
if (!value) return '-';
|
||||
const [year, month, day] = String(value).split('-');
|
||||
return `${day}.${month}.${year}`;
|
||||
}
|
||||
|
||||
export function formatDateShort(value) {
|
||||
const [, month, day] = String(value).split('-');
|
||||
return `${day}.${month}`;
|
||||
}
|
||||
|
||||
export function trimTime(value) {
|
||||
return String(value || '').slice(0, 5);
|
||||
}
|
||||
|
||||
export function dayShort(dayOfWeek) {
|
||||
return DAY_SHORT_LABELS[Number(dayOfWeek)] || '-';
|
||||
}
|
||||
|
||||
// --- Семестры и периоды ---
|
||||
|
||||
export function semesterOptionLabel(semester) {
|
||||
const type = String(semester?.semesterType || '').toLowerCase();
|
||||
const typeLabel = type === 'autumn'
|
||||
? 'Осенний семестр'
|
||||
: (type === 'spring' ? 'Весенний семестр' : 'Семестр');
|
||||
const academicYear = semester?.academicYearTitle || 'Учебный год';
|
||||
const dates = semester?.startDate && semester?.endDate
|
||||
? ` · ${formatDate(semester.startDate)} — ${formatDate(semester.endDate)}`
|
||||
: '';
|
||||
return `${academicYear} · ${typeLabel}${dates}`;
|
||||
}
|
||||
|
||||
export function findSemesterForDate(semesters, date) {
|
||||
return (semesters || []).find(semester => dateWithinSemester(date, semester)) || null;
|
||||
}
|
||||
|
||||
export function scheduleRangeForSemester(selectedDate, semester) {
|
||||
if (semester?.startDate && semester?.endDate) {
|
||||
return {
|
||||
startDate: semester.startDate,
|
||||
endDate: semester.endDate
|
||||
};
|
||||
}
|
||||
return twoWeekRange(selectedDate);
|
||||
}
|
||||
|
||||
export function dateWithinSemester(date, semester) {
|
||||
return Boolean(
|
||||
date
|
||||
&& semester?.startDate
|
||||
&& semester?.endDate
|
||||
&& date >= semester.startDate
|
||||
&& date <= semester.endDate
|
||||
);
|
||||
}
|
||||
|
||||
export function semesterWeekCount(semester) {
|
||||
if (!semester?.startDate || !semester?.endDate) return DEFAULT_SEMESTER_WEEKS;
|
||||
const start = parseIsoDate(semester.startDate);
|
||||
const end = parseIsoDate(semester.endDate);
|
||||
const days = Math.max(1, Math.round((end - start) / 86400000) + 1);
|
||||
return Math.max(1, Math.ceil(days / 7));
|
||||
}
|
||||
|
||||
export function semesterScheduleCaption(semester) {
|
||||
const weeks = semesterWeekCount(semester);
|
||||
const weekLabel = weeks === 1 ? '1 неделя' : `недели 1–${weeks}`;
|
||||
return `Весь семестр · ${weekLabel}`;
|
||||
}
|
||||
|
||||
export function periodCaption(range, semester = null) {
|
||||
if (!range) return 'Учебное расписание';
|
||||
const rangeLabel = `${formatDate(range.startDate)} — ${formatDate(range.endDate)}`;
|
||||
return semester ? `${semesterOptionLabel(semester)} · ${rangeLabel}` : rangeLabel;
|
||||
}
|
||||
|
||||
export function formatLessonWeeksText(startWeek, endWeek) {
|
||||
const start = Number(startWeek);
|
||||
const end = Number(endWeek);
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end)) return '';
|
||||
return start === end
|
||||
? `(${start} нед.)`
|
||||
: `(с ${start} по ${end} нед.)`;
|
||||
}
|
||||
|
||||
// Диапазон недель занятия: явные границы из матрицы либо расчёт по часам.
|
||||
export function getLessonWeeksText(lesson) {
|
||||
if (lesson?.displayStartWeek != null && lesson?.displayEndWeek != null) {
|
||||
return formatLessonWeeksText(lesson.displayStartWeek, lesson.displayEndWeek);
|
||||
}
|
||||
if (!lesson || lesson.weekNumber == null || lesson.lessonTypeAcademicHours == null) {
|
||||
return '';
|
||||
}
|
||||
const totalHours = lesson.lessonTypeAcademicHours;
|
||||
const consumedBefore = lesson.consumedLessonTypeAcademicHoursBeforeLesson || 0;
|
||||
|
||||
// Каждое занятие - это 2 часа
|
||||
const totalLessons = Math.ceil(totalHours / 2);
|
||||
if (totalLessons <= 0) return '';
|
||||
|
||||
const parity = lesson.ruleParity || lesson.parity;
|
||||
let startWeek;
|
||||
let endWeek;
|
||||
|
||||
if (parity === 'EVEN' || parity === 'ODD') {
|
||||
// Раз в две недели
|
||||
startWeek = lesson.weekNumber - consumedBefore;
|
||||
endWeek = startWeek + (totalLessons - 1) * 2;
|
||||
} else {
|
||||
// Каждую неделю
|
||||
const steps = Math.floor(consumedBefore / 2);
|
||||
startWeek = lesson.weekNumber - steps;
|
||||
endWeek = startWeek + totalLessons - 1;
|
||||
}
|
||||
|
||||
if (startWeek <= 0) startWeek = 1;
|
||||
if (endWeek < startWeek) endWeek = startWeek;
|
||||
|
||||
return formatLessonWeeksText(startWeek, endWeek);
|
||||
}
|
||||
|
||||
// --- Разделы расписания ---
|
||||
|
||||
export function allowedGroupIdsForScheduleTarget(target, selectedGroupId) {
|
||||
return target === TARGET_GROUPS && selectedGroupId
|
||||
? new Set([String(selectedGroupId)])
|
||||
: new Set();
|
||||
}
|
||||
|
||||
export function splitTypeForTarget(target) {
|
||||
if (target === TARGET_TEACHERS) return TARGET_TEACHERS;
|
||||
if (target === TARGET_CLASSROOMS) return TARGET_CLASSROOMS;
|
||||
return TARGET_GROUPS;
|
||||
}
|
||||
|
||||
export function buildSectionContext({ target, selectedGroupId, groups }) {
|
||||
const allowedGroupIds = allowedGroupIdsForScheduleTarget(target, selectedGroupId);
|
||||
const groupNamesById = new Map((groups || []).map(group => [String(group.id), group.name]));
|
||||
return {
|
||||
selectedGroupId,
|
||||
selectedGroupName: selectedGroupId
|
||||
? (groupNamesById.get(String(selectedGroupId)) || `Группа ${selectedGroupId}`)
|
||||
: '',
|
||||
allowedGroupIds,
|
||||
groupNamesById
|
||||
};
|
||||
}
|
||||
|
||||
export function buildScheduleSections(lessons, splitType, context) {
|
||||
const sections = new Map();
|
||||
|
||||
lessons.forEach(lesson => {
|
||||
splitEntitiesForLesson(lesson, splitType, context).forEach(entity => {
|
||||
if (!sections.has(entity.key)) {
|
||||
sections.set(entity.key, {
|
||||
key: entity.key,
|
||||
title: entity.title,
|
||||
lessons: []
|
||||
});
|
||||
}
|
||||
sections.get(entity.key).lessons.push(lesson);
|
||||
});
|
||||
});
|
||||
|
||||
return Array.from(sections.values())
|
||||
.map(section => ({
|
||||
...section,
|
||||
lessons: sortLessons(section.lessons)
|
||||
}))
|
||||
.sort((a, b) => naturalCompare(a.title, b.title));
|
||||
}
|
||||
|
||||
function splitEntitiesForLesson(lesson, splitType, context) {
|
||||
if (splitType === TARGET_TEACHERS) {
|
||||
return [singleEntity('teacher', lesson.teacherId, lesson.teacherName, 'Преподаватель не указан')];
|
||||
}
|
||||
if (splitType === TARGET_CLASSROOMS) {
|
||||
return [singleEntity('classroom', lesson.classroomId, lesson.classroomName, 'Аудитория не указана')];
|
||||
}
|
||||
if (context.selectedGroupId) {
|
||||
return [singleEntity('group', context.selectedGroupId, context.selectedGroupName, 'Группа не указана')];
|
||||
}
|
||||
return listEntities('group', lesson.groupIds, lesson.groupNames, 'Группа не указана', context.allowedGroupIds, context.groupNamesById);
|
||||
}
|
||||
|
||||
function singleEntity(prefix, id, name, fallback) {
|
||||
const title = name || fallback;
|
||||
return {
|
||||
id,
|
||||
key: `${prefix}:${id ?? title}`,
|
||||
title
|
||||
};
|
||||
}
|
||||
|
||||
function listEntities(prefix, ids = [], names = [], fallback, allowedIds = new Set(), namesById = new Map()) {
|
||||
const safeIds = Array.isArray(ids) ? ids : [];
|
||||
const safeNames = Array.isArray(names) ? names : [];
|
||||
if (!safeIds.length && !safeNames.length) {
|
||||
return allowedIds.size ? [] : [singleEntity(prefix, null, fallback, fallback)];
|
||||
}
|
||||
const maxLength = Math.max(safeIds.length, safeNames.length);
|
||||
const entities = [];
|
||||
for (let index = 0; index < maxLength; index += 1) {
|
||||
const id = safeIds[index] ?? null;
|
||||
if (allowedIds.size && (id == null || !allowedIds.has(String(id)))) {
|
||||
continue;
|
||||
}
|
||||
const name = namesById.get(String(id)) || safeNames[index] || (id == null ? fallback : `Группа ${id}`);
|
||||
entities.push(singleEntity(prefix, id, name, fallback));
|
||||
}
|
||||
return entities;
|
||||
}
|
||||
|
||||
export function sortLessons(lessons) {
|
||||
return [...lessons].sort((a, b) =>
|
||||
naturalCompare(a.date, b.date)
|
||||
|| compareSlots(lessonSlotDescriptor(a), lessonSlotDescriptor(b))
|
||||
|| naturalCompare(a.subjectName, b.subjectName)
|
||||
|| naturalCompare(a.teacherName, b.teacherName)
|
||||
|| naturalCompare(a.classroomName, b.classroomName)
|
||||
);
|
||||
}
|
||||
|
||||
function lessonSlotDescriptor(lesson) {
|
||||
return {
|
||||
key: lessonSlotKey(lesson),
|
||||
orderNumber: lesson.timeSlotOrder,
|
||||
startTime: lesson.startTime
|
||||
};
|
||||
}
|
||||
|
||||
// --- Слоты и сетки ---
|
||||
|
||||
export function lessonSlotKey(lesson) {
|
||||
const value = lesson.timeSlotOrder ?? lesson.timeSlotId ?? `${lesson.startTime || ''}-${lesson.endTime || ''}`;
|
||||
return value ? String(value) : 'unknown';
|
||||
}
|
||||
|
||||
export function dateSlotKey(date, slotKey) {
|
||||
return `${date}:${slotKey}`;
|
||||
}
|
||||
|
||||
export function collectSlots(lessons) {
|
||||
const slots = new Map();
|
||||
lessons.forEach(lesson => {
|
||||
const key = lessonSlotKey(lesson);
|
||||
const existing = slots.get(key);
|
||||
const next = {
|
||||
key,
|
||||
id: lesson.timeSlotId,
|
||||
orderNumber: lesson.timeSlotOrder,
|
||||
startTime: lesson.startTime,
|
||||
endTime: lesson.endTime,
|
||||
orderLabel: lesson.timeSlotOrder ? `${lesson.timeSlotOrder} пара` : 'Пара',
|
||||
timeLabel: lesson.startTime || lesson.endTime
|
||||
? `${trimTime(lesson.startTime)}-${trimTime(lesson.endTime)}`
|
||||
: 'Время не указано'
|
||||
};
|
||||
if (!existing || existing.timeLabel === 'Время не указано') {
|
||||
slots.set(key, next);
|
||||
}
|
||||
});
|
||||
return Array.from(slots.values()).sort(compareSlots);
|
||||
}
|
||||
|
||||
export function groupLessonsByDateSlot(lessons) {
|
||||
const grouped = new Map();
|
||||
lessons.forEach(lesson => {
|
||||
if (!lesson.date) return;
|
||||
const key = dateSlotKey(lesson.date, lessonSlotKey(lesson));
|
||||
if (!grouped.has(key)) grouped.set(key, []);
|
||||
grouped.get(key).push(lesson);
|
||||
});
|
||||
grouped.forEach(items => {
|
||||
items.sort((a, b) => naturalCompare(a.subjectName, b.subjectName)
|
||||
|| naturalCompare(a.teacherName, b.teacherName)
|
||||
|| naturalCompare((a.groupNames || []).join(','), (b.groupNames || []).join(',')));
|
||||
});
|
||||
return grouped;
|
||||
}
|
||||
|
||||
export function buildCombinedWeekBlocks(startDate, endDate, lessons) {
|
||||
const dates = datesBetween(startDate, endDate);
|
||||
const weekGroups = new Map();
|
||||
dates.forEach(date => {
|
||||
const key = weekStart(date);
|
||||
if (!weekGroups.has(key)) weekGroups.set(key, []);
|
||||
weekGroups.get(key).push(date);
|
||||
});
|
||||
|
||||
const weekStarts = Array.from(weekGroups.keys()).sort((a, b) => naturalCompare(a, b));
|
||||
const blocks = [];
|
||||
for (let index = 0; index < weekStarts.length; index += 2) {
|
||||
const firstWeek = weekGroups.get(weekStarts[index]) || [];
|
||||
const secondWeek = weekGroups.get(weekStarts[index + 1]) || [];
|
||||
const blockDates = [...firstWeek, ...secondWeek];
|
||||
const firstParity = weekParity(firstWeek, lessons) || 'ODD';
|
||||
const secondParity = weekParity(secondWeek, lessons) || (firstParity === 'ODD' ? 'EVEN' : 'ODD');
|
||||
const datesWithFallback = [
|
||||
...firstWeek.map(date => ({ date, fallbackParity: firstParity })),
|
||||
...secondWeek.map(date => ({ date, fallbackParity: secondParity }))
|
||||
];
|
||||
blocks.push({
|
||||
startDate: blockDates[0],
|
||||
endDate: blockDates[blockDates.length - 1],
|
||||
datesByDay: buildDatesByDay(datesWithFallback, lessons),
|
||||
label: `Период ${formatDate(blockDates[0])} - ${formatDate(blockDates[blockDates.length - 1])}`
|
||||
});
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
function weekParity(dates, lessons) {
|
||||
for (const date of dates) {
|
||||
const parity = parityForDate(date, lessons);
|
||||
if (parity) return parity;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildDatesByDay(datesWithFallback, lessons) {
|
||||
const datesByDay = {};
|
||||
datesWithFallback.forEach(({ date, fallbackParity }) => {
|
||||
const day = isoDay(date);
|
||||
const parity = parityForDate(date, lessons) || fallbackParity;
|
||||
if (!datesByDay[day]) datesByDay[day] = {};
|
||||
datesByDay[day][parity] = date;
|
||||
});
|
||||
return datesByDay;
|
||||
}
|
||||
|
||||
export function parityForDate(date, lessons) {
|
||||
const lesson = lessons.find(item => item.date === date && ['ODD', 'EVEN'].includes(item.parity));
|
||||
if (lesson?.parity) return lesson.parity;
|
||||
const withWeek = lessons.find(item => item.date === date && item.weekNumber != null);
|
||||
if (withWeek?.weekNumber != null) return Number(withWeek.weekNumber) % 2 === 0 ? 'EVEN' : 'ODD';
|
||||
return null;
|
||||
}
|
||||
|
||||
export function combinedWeekCaption() {
|
||||
return 'Нечётная и чётная недели';
|
||||
}
|
||||
|
||||
export function mobileDayCaption() {
|
||||
return 'Нечётная / Чётная';
|
||||
}
|
||||
|
||||
// --- Матрица семестра ---
|
||||
|
||||
export function buildSemesterLessonMatrix(lessons) {
|
||||
const weekRanges = new Map();
|
||||
lessons.forEach(lesson => {
|
||||
const signature = lessonContentSignature(lesson);
|
||||
const weekNumber = Number(lesson.weekNumber);
|
||||
if (!Number.isFinite(weekNumber)) return;
|
||||
const range = weekRanges.get(signature) || { startWeek: weekNumber, endWeek: weekNumber };
|
||||
range.startWeek = Math.min(range.startWeek, weekNumber);
|
||||
range.endWeek = Math.max(range.endWeek, weekNumber);
|
||||
weekRanges.set(signature, range);
|
||||
});
|
||||
|
||||
const matrix = new Map();
|
||||
lessons.forEach(lesson => {
|
||||
const day = Number(lesson.dayOfWeek) || isoDay(lesson.date);
|
||||
const parity = ['ODD', 'EVEN'].includes(lesson.parity)
|
||||
? lesson.parity
|
||||
: (Number(lesson.weekNumber) % 2 === 0 ? 'EVEN' : 'ODD');
|
||||
const signature = lessonContentSignature(lesson);
|
||||
const key = `${day}:${lessonSlotKey(lesson)}:${parity}`;
|
||||
if (!matrix.has(key)) matrix.set(key, new Map());
|
||||
if (!matrix.get(key).has(signature)) {
|
||||
const range = weekRanges.get(signature);
|
||||
matrix.get(key).set(signature, {
|
||||
...lesson,
|
||||
displayStartWeek: range?.startWeek,
|
||||
displayEndWeek: range?.endWeek
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return new Map(Array.from(matrix.entries()).map(([key, values]) => [
|
||||
key,
|
||||
Array.from(values.values()).sort((a, b) => naturalCompare(a.subjectName, b.subjectName))
|
||||
]));
|
||||
}
|
||||
|
||||
export function slotStatesForDates(datesByParity, slot, lessonsByDateSlot) {
|
||||
return CELL_PARITY_LAYOUT.map(({ parity, label }) => ({
|
||||
parity,
|
||||
label,
|
||||
state: scheduleSlotState(datesByParity[parity], slot, lessonsByDateSlot)
|
||||
}));
|
||||
}
|
||||
|
||||
export function slotStatesForSemester(day, slot, lessonMatrix) {
|
||||
return CELL_PARITY_LAYOUT.map(({ parity, label }) => {
|
||||
const lessons = lessonMatrix.get(`${day}:${slot.key}:${parity}`) || [];
|
||||
return {
|
||||
parity,
|
||||
label,
|
||||
state: lessons.length ? { kind: 'busy', lessons } : { kind: 'free', lessons: [] }
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function scheduleSlotState(date, slot, lessonsByDateSlot) {
|
||||
if (!date) {
|
||||
return { kind: 'empty', lessons: [] };
|
||||
}
|
||||
const lessons = lessonsByDateSlot.get(dateSlotKey(date, slot.key)) || [];
|
||||
if (lessons.length) {
|
||||
return { kind: 'busy', lessons };
|
||||
}
|
||||
return { kind: 'free', lessons: [] };
|
||||
}
|
||||
|
||||
export function slotStatesEqual(first, second) {
|
||||
if (first.kind !== second.kind) return false;
|
||||
if (first.kind !== 'busy') return true;
|
||||
return lessonSetSignature(first.lessons) === lessonSetSignature(second.lessons);
|
||||
}
|
||||
|
||||
function lessonSetSignature(lessons) {
|
||||
return lessons
|
||||
.map(lessonContentSignature)
|
||||
.sort((a, b) => naturalCompare(a, b))
|
||||
.join('|');
|
||||
}
|
||||
|
||||
export function lessonContentSignature(lesson) {
|
||||
return [
|
||||
lesson.scheduleRuleSlotId || lesson.scheduleRuleId || '',
|
||||
lesson.subjectId || lesson.subjectName || '',
|
||||
lesson.teacherId || lesson.teacherName || '',
|
||||
lesson.classroomId || lesson.classroomName || '',
|
||||
lesson.lessonTypeId || lesson.lessonTypeName || '',
|
||||
lesson.lessonFormat || '',
|
||||
lesson.scheduleOverrideId || '',
|
||||
lesson.overrideAction || '',
|
||||
lesson.originalLessonDate || '',
|
||||
valueListSignature(lesson.groupIds, lesson.groupNames),
|
||||
valueListSignature(lesson.subgroupIds, lesson.subgroupNames)
|
||||
].join(':');
|
||||
}
|
||||
|
||||
function valueListSignature(primary = [], fallback = []) {
|
||||
const primaryList = Array.isArray(primary) ? primary : [];
|
||||
const fallbackList = Array.isArray(fallback) ? fallback : [];
|
||||
const source = primaryList.length ? primaryList : fallbackList;
|
||||
return [...source]
|
||||
.map(value => String(value))
|
||||
.sort((a, b) => naturalCompare(a, b))
|
||||
.join(',');
|
||||
}
|
||||
|
||||
// --- Подписи ---
|
||||
|
||||
export function lessonCountLabel(count) {
|
||||
const tail = count % 100;
|
||||
if (tail >= 11 && tail <= 14) return `${count} занятий`;
|
||||
if (count % 10 === 1) return `${count} занятие`;
|
||||
if ([2, 3, 4].includes(count % 10)) return `${count} занятия`;
|
||||
return `${count} занятий`;
|
||||
}
|
||||
|
||||
export function sectionCountLabel(count) {
|
||||
const tail = count % 100;
|
||||
if (tail >= 11 && tail <= 14) return `${count} расписаний`;
|
||||
if (count % 10 === 1) return `${count} расписание`;
|
||||
if ([2, 3, 4].includes(count % 10)) return `${count} расписания`;
|
||||
return `${count} расписаний`;
|
||||
}
|
||||
|
||||
// --- Справочники ---
|
||||
|
||||
export function roomLabel(room) {
|
||||
return [room?.name, room?.building, room?.floor ? `${room.floor} этаж` : null].filter(Boolean).join(' · ');
|
||||
}
|
||||
|
||||
export function groupOptionLabel(group) {
|
||||
return group?.name || '';
|
||||
}
|
||||
|
||||
export function teacherOptionLabel(teacher) {
|
||||
return teacher?.fullName || teacher?.username || '';
|
||||
}
|
||||
|
||||
export function mergeDictionaryItems(dictionaries, key, items) {
|
||||
const merged = new Map((dictionaries[key] || []).map(item => [String(item.id), item]));
|
||||
(items || []).forEach(item => merged.set(String(item.id), item));
|
||||
dictionaries[key] = [...merged.values()];
|
||||
}
|
||||
|
||||
// --- Контракты API ---
|
||||
|
||||
export async function fetchScheduleViewDictionaries(client = apiClient) {
|
||||
const [departments, subjects, lessonTypes, semesters] = await Promise.all([
|
||||
client.get('/api/departments', { errorMessage: 'Ошибка загрузки кафедр' }),
|
||||
client.get('/api/subjects', { errorMessage: 'Ошибка загрузки дисциплин' }),
|
||||
client.get('/api/lesson-types', { errorMessage: 'Ошибка загрузки типов занятий' }),
|
||||
client.get('/api/schedule/semesters', { errorMessage: 'Ошибка загрузки семестров' })
|
||||
]);
|
||||
return {
|
||||
groups: [],
|
||||
teachers: [],
|
||||
classrooms: [],
|
||||
departments: departments || [],
|
||||
subjects: subjects || [],
|
||||
lessonTypes: lessonTypes || [],
|
||||
semesters: semesters || []
|
||||
};
|
||||
}
|
||||
|
||||
export function scheduleSearchEndpoint(filters) {
|
||||
const params = new URLSearchParams({
|
||||
startDate: filters.startDate,
|
||||
endDate: filters.endDate
|
||||
});
|
||||
applyTargetFilter(params, filters);
|
||||
addOptional(params, 'subjectId', filters.subjectId);
|
||||
addOptional(params, 'lessonTypeId', filters.lessonTypeId);
|
||||
addOptional(params, 'parity', filters.parity);
|
||||
return `/api/schedule/search?${params}`;
|
||||
}
|
||||
|
||||
// Backend отвергает поиск сразу по всем активным группам («Уточните группу
|
||||
// или кафедру»), поэтому запрос без выбранной цели заведомо вернёт 400.
|
||||
// Используется, чтобы не показывать ошибку там, где достаточно подсказки.
|
||||
export function hasScheduleSearchTarget(filters) {
|
||||
if (filters.role === ROLE_DEPARTMENT) {
|
||||
return Boolean(filters.userDepartmentId);
|
||||
}
|
||||
if (filters.target === TARGET_GROUPS) {
|
||||
return Boolean(filters.groupId);
|
||||
}
|
||||
if (filters.target === TARGET_TEACHERS) {
|
||||
return Boolean(filters.teacherId);
|
||||
}
|
||||
if (filters.target === TARGET_CLASSROOMS) {
|
||||
return Boolean(filters.classroomId);
|
||||
}
|
||||
if (filters.target === TARGET_DEPARTMENTS) {
|
||||
return Boolean(filters.departmentId);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function applyTargetFilter(params, filters) {
|
||||
if (filters.role === ROLE_DEPARTMENT) {
|
||||
params.set('departmentId', filters.userDepartmentId);
|
||||
return;
|
||||
}
|
||||
if (filters.target === TARGET_GROUPS) {
|
||||
addOptional(params, 'groupId', filters.groupId);
|
||||
return;
|
||||
}
|
||||
if (filters.target === TARGET_TEACHERS) {
|
||||
addOptional(params, 'teacherId', filters.teacherId);
|
||||
return;
|
||||
}
|
||||
if (filters.target === TARGET_CLASSROOMS) {
|
||||
addOptional(params, 'classroomId', filters.classroomId);
|
||||
return;
|
||||
}
|
||||
if (filters.target === TARGET_DEPARTMENTS) {
|
||||
addOptional(params, 'departmentId', filters.departmentId);
|
||||
}
|
||||
}
|
||||
|
||||
function addOptional(params, key, value) {
|
||||
if (value) params.set(key, value);
|
||||
}
|
||||
|
||||
export async function fetchScheduleLessons(filters, client = apiClient) {
|
||||
return client.get(scheduleSearchEndpoint(filters), { errorMessage: 'Ошибка загрузки расписания' });
|
||||
}
|
||||
|
||||
export function scheduleOverridesEndpoint(range) {
|
||||
return `/api/edu-office/schedule/overrides?startDate=${range.startDate}&endDate=${range.endDate}`;
|
||||
}
|
||||
|
||||
export async function fetchScheduleOverrides(range, client = apiClient) {
|
||||
return client.get(scheduleOverridesEndpoint(range), { errorMessage: 'Ошибка загрузки разовых изменений' });
|
||||
}
|
||||
|
||||
export function groupsOptionsEndpoint(query, selectedId, departmentId = '') {
|
||||
const params = new URLSearchParams({ query, limit: '30' });
|
||||
if (selectedId) params.set('selectedId', selectedId);
|
||||
if (departmentId) params.set('departmentId', departmentId);
|
||||
return `/api/groups/options?${params}`;
|
||||
}
|
||||
|
||||
export function teachersOptionsEndpoint(query, selectedId) {
|
||||
const params = new URLSearchParams({ query, limit: '30' });
|
||||
if (selectedId) params.set('selectedId', selectedId);
|
||||
return `/api/users/teachers/options?${params}`;
|
||||
}
|
||||
|
||||
export function classroomsOptionsEndpoint(query, selectedId) {
|
||||
const params = new URLSearchParams({ query, limit: '30' });
|
||||
if (selectedId) params.set('selectedId', selectedId);
|
||||
return `/api/classrooms/options?${params}`;
|
||||
}
|
||||
|
||||
export async function fetchGroupOptions(query, selectedId, departmentId, client = apiClient) {
|
||||
return client.get(groupsOptionsEndpoint(query, selectedId, departmentId), { errorMessage: 'Ошибка поиска групп' });
|
||||
}
|
||||
|
||||
export async function fetchTeacherOptions(query, selectedId, client = apiClient) {
|
||||
return client.get(teachersOptionsEndpoint(query, selectedId), { errorMessage: 'Ошибка поиска преподавателей' });
|
||||
}
|
||||
|
||||
export async function fetchClassroomOptions(query, selectedId, client = apiClient) {
|
||||
return client.get(classroomsOptionsEndpoint(query, selectedId), { errorMessage: 'Ошибка поиска аудиторий' });
|
||||
}
|
||||
|
||||
// --- Состояние фильтров в URL ---
|
||||
|
||||
export function initialScheduleViewState(values = {}) {
|
||||
return {
|
||||
target: values.scheduleTarget || TARGET_GROUPS,
|
||||
entity: values.scheduleEntity || '',
|
||||
date: values.scheduleDate || '',
|
||||
yearId: values.scheduleYear || '',
|
||||
semesterId: values.scheduleSemester || '',
|
||||
subjectId: values.scheduleSubject || '',
|
||||
lessonTypeId: values.scheduleLessonType || '',
|
||||
parity: values.scheduleParity || ''
|
||||
};
|
||||
}
|
||||
|
||||
export function entityValueForTarget(target, entityIds = {}) {
|
||||
if (target === TARGET_GROUPS) return entityIds.groupId || '';
|
||||
if (target === TARGET_TEACHERS) return entityIds.teacherId || '';
|
||||
if (target === TARGET_CLASSROOMS) return entityIds.classroomId || '';
|
||||
if (target === TARGET_DEPARTMENTS) return entityIds.departmentId || '';
|
||||
return '';
|
||||
}
|
||||
|
||||
export function scheduleViewUrlState(filters) {
|
||||
return {
|
||||
scheduleTarget: filters.role === ROLE_DEPARTMENT ? '' : filters.target,
|
||||
scheduleEntity: filters.entityValue || '',
|
||||
scheduleDate: filters.date || '',
|
||||
scheduleYear: filters.yearId || '',
|
||||
scheduleSemester: filters.semesterId || '',
|
||||
scheduleSubject: filters.subjectId || '',
|
||||
scheduleLessonType: filters.lessonTypeId || '',
|
||||
scheduleParity: filters.parity || ''
|
||||
};
|
||||
}
|
||||
|
||||
// --- Служебные сравнения ---
|
||||
|
||||
function compareSlots(a, b) {
|
||||
const first = Number(a.orderNumber || a.key);
|
||||
const second = Number(b.orderNumber || b.key);
|
||||
if (!Number.isNaN(first) && !Number.isNaN(second) && first !== second) {
|
||||
return first - second;
|
||||
}
|
||||
return naturalCompare(a.startTime, b.startTime)
|
||||
|| naturalCompare(a.key, b.key);
|
||||
}
|
||||
|
||||
function naturalCompare(a, b) {
|
||||
return String(a || '').localeCompare(String(b || ''), 'ru', { numeric: true, sensitivity: 'base' });
|
||||
}
|
||||
153
frontend/react/admin/subjects-service.js
Normal file
153
frontend/react/admin/subjects-service.js
Normal file
@@ -0,0 +1,153 @@
|
||||
import { apiClient } from '../shared/api-client.js';
|
||||
|
||||
export const SUBJECT_TABLE_COLUMNS = 6;
|
||||
export const SUBJECT_PAGE_SIZES = [25, 50, 100];
|
||||
|
||||
export const SUBJECT_SORT_OPTIONS = Object.freeze([
|
||||
{ value: 'name,asc', label: 'По названию А–Я' },
|
||||
{ value: 'name,desc', label: 'По названию Я–А' },
|
||||
{ value: 'code,asc', label: 'По коду' },
|
||||
{ value: 'departmentId,asc', label: 'По кафедре' }
|
||||
]);
|
||||
|
||||
const DEFAULT_STATE = Object.freeze({
|
||||
query: '',
|
||||
departmentId: '',
|
||||
page: 0,
|
||||
size: 25,
|
||||
sort: 'name,asc'
|
||||
});
|
||||
|
||||
export function normalizeSubjectsState(restored = {}) {
|
||||
const size = Number(restored.subjectSize);
|
||||
return {
|
||||
query: restored.subjectQuery || '',
|
||||
departmentId: restored.subjectDepartment || '',
|
||||
page: Math.max(Number(restored.subjectPage) || 0, 0),
|
||||
size: SUBJECT_PAGE_SIZES.includes(size) ? size : 25,
|
||||
sort: SUBJECT_SORT_OPTIONS.some(option => option.value === restored.subjectSort)
|
||||
? restored.subjectSort
|
||||
: 'name,asc'
|
||||
};
|
||||
}
|
||||
|
||||
export function initialSubjectsState(restored = {}) {
|
||||
return normalizeSubjectsState(restored);
|
||||
}
|
||||
|
||||
export function defaultSubjectsState() {
|
||||
return { ...DEFAULT_STATE };
|
||||
}
|
||||
|
||||
export function subjectsQueryParams(state) {
|
||||
const [sort, direction] = String(state.sort || 'name,asc').split(',');
|
||||
const params = new URLSearchParams({
|
||||
query: state.query || '',
|
||||
page: String(state.page || 0),
|
||||
size: String(state.size || 25),
|
||||
sort,
|
||||
direction
|
||||
});
|
||||
if (state.departmentId) params.set('departmentId', state.departmentId);
|
||||
return `/api/subjects/page?${params}`;
|
||||
}
|
||||
|
||||
export function subjectsUrlState(state) {
|
||||
return {
|
||||
subjectQuery: state.query || '',
|
||||
subjectDepartment: state.departmentId || '',
|
||||
subjectPage: state.page || '',
|
||||
subjectSize: state.size === 25 ? '' : state.size,
|
||||
subjectSort: state.sort === 'name,asc' ? '' : state.sort
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchDepartments(client = apiClient) {
|
||||
return client.get('/api/departments', { errorMessage: 'Не удалось загрузить список кафедр' });
|
||||
}
|
||||
|
||||
export function fetchSubjectsPage(state, client = apiClient) {
|
||||
return client.get(subjectsQueryParams(state), { errorMessage: 'Не удалось загрузить дисциплины' });
|
||||
}
|
||||
|
||||
export function teacherAssignmentsEndpoint(subjectIds) {
|
||||
const params = new URLSearchParams();
|
||||
(subjectIds || []).forEach(subjectId => params.append('subjectId', subjectId));
|
||||
return `/api/teacher-subjects?${params}`;
|
||||
}
|
||||
|
||||
export function fetchTeacherAssignments(subjectIds, client = apiClient) {
|
||||
if (!(subjectIds || []).length) return Promise.resolve([]);
|
||||
return client.get(
|
||||
teacherAssignmentsEndpoint(subjectIds),
|
||||
{ errorMessage: 'Не удалось загрузить привязки преподавателей' }
|
||||
);
|
||||
}
|
||||
|
||||
export function createSubject({ name, code, departmentId }, client = apiClient) {
|
||||
return client.post(
|
||||
'/api/subjects',
|
||||
{ name, code, departmentId: Number(departmentId) },
|
||||
{ errorMessage: 'Ошибка создания' }
|
||||
);
|
||||
}
|
||||
|
||||
export function archiveSubject(id, client = apiClient) {
|
||||
return client.delete(`/api/subjects/${id}`, { errorMessage: 'Ошибка архивирования' });
|
||||
}
|
||||
|
||||
export function assignTeacher({ userId, subjectId }, client = apiClient) {
|
||||
return client.post(
|
||||
'/api/teacher-subjects',
|
||||
{ userId: Number(userId), subjectId: Number(subjectId) },
|
||||
{ errorMessage: 'Ошибка привязки' }
|
||||
);
|
||||
}
|
||||
|
||||
export function removeTeacherAssignment({ userId, subjectId }, client = apiClient) {
|
||||
return client.delete('/api/teacher-subjects', {
|
||||
json: { userId: Number(userId), subjectId: Number(subjectId) },
|
||||
errorMessage: 'Ошибка удаления привязки'
|
||||
});
|
||||
}
|
||||
|
||||
export function teachersSearchEndpoint(query, selectedId) {
|
||||
const params = new URLSearchParams({ query, limit: '30' });
|
||||
if (selectedId) params.set('selectedId', selectedId);
|
||||
return `/api/users/teachers/options?${params}`;
|
||||
}
|
||||
|
||||
export function departmentLabel(departments, departmentId) {
|
||||
const department = (departments || []).find(item => String(item.id) === String(departmentId));
|
||||
if (!department) return departmentId || '-';
|
||||
return department.departmentName || department.name || departmentId;
|
||||
}
|
||||
|
||||
export function departmentOptionLabel(department) {
|
||||
const name = department.departmentName || department.name || 'Без названия';
|
||||
const code = department.departmentCode || department.code || department.id;
|
||||
return `${name} (${code})`;
|
||||
}
|
||||
|
||||
export function groupTeachersBySubject(assignments) {
|
||||
const grouped = new Map();
|
||||
(assignments || []).forEach(assignment => {
|
||||
const key = String(assignment.subjectId);
|
||||
if (!grouped.has(key)) grouped.set(key, []);
|
||||
grouped.get(key).push(assignment);
|
||||
});
|
||||
return grouped;
|
||||
}
|
||||
|
||||
export function subjectContextLabel(subject) {
|
||||
return `${subject.code ? `${subject.code} · ` : ''}${subject.name}`;
|
||||
}
|
||||
|
||||
export function subjectsEmptyMessage(state) {
|
||||
return state.query || state.departmentId ? 'Дисциплины не найдены' : 'Дисциплин пока нет';
|
||||
}
|
||||
|
||||
export function normalizePageState(state, page) {
|
||||
const nextPage = Number(page);
|
||||
return { ...state, page: Number.isFinite(nextPage) && nextPage > 0 ? nextPage : 0 };
|
||||
}
|
||||
2244
frontend/react/admin/tabs/AcademicCalendarTab.jsx
Normal file
2244
frontend/react/admin/tabs/AcademicCalendarTab.jsx
Normal file
File diff suppressed because it is too large
Load Diff
775
frontend/react/admin/tabs/AuditoriumWorkloadTab.jsx
Normal file
775
frontend/react/admin/tabs/AuditoriumWorkloadTab.jsx
Normal file
@@ -0,0 +1,775 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { toIsoDate } from '../../shared/dates.js';
|
||||
import { FormAlert } from '../../shared/ui/FormAlert.jsx';
|
||||
import {
|
||||
CELL_PARITY_LAYOUT,
|
||||
DAY_FULL_LABELS,
|
||||
DAY_ORDER,
|
||||
DISPLAY_ALL,
|
||||
TARGET_CLASSROOMS,
|
||||
TARGET_DEPARTMENTS,
|
||||
TARGET_TEACHERS,
|
||||
WORKLOAD_TARGETS,
|
||||
buildingOptions,
|
||||
countOccupiedCells,
|
||||
datesBetween,
|
||||
entityAxisParts,
|
||||
entityOptionLabel,
|
||||
entitySlotKey,
|
||||
equipmentOptions,
|
||||
filteredClassrooms,
|
||||
formatDateShort,
|
||||
loadEntityLessons,
|
||||
loadOverviewLessons,
|
||||
loadSlotsForDates,
|
||||
overviewSummaryText,
|
||||
parseMinCapacity,
|
||||
roomSlotState,
|
||||
roomSlotStatesEqual,
|
||||
roomSummaryParts,
|
||||
roomWeekCaption,
|
||||
sameId,
|
||||
scopeShort,
|
||||
semestersFromYears,
|
||||
sortClassrooms,
|
||||
sortDepartments,
|
||||
sortTeachers,
|
||||
timeSlotLabel,
|
||||
twoWeekRange,
|
||||
buildDateGridByDay,
|
||||
buildSlotRows,
|
||||
countAvailableRoomCells,
|
||||
countOccupiedRoomCells,
|
||||
dayShort,
|
||||
fetchAcademicYears,
|
||||
fetchEffectiveTimeSlots,
|
||||
fetchWorkloadClassrooms,
|
||||
fetchWorkloadDepartments,
|
||||
fetchWorkloadEquipments,
|
||||
fetchWorkloadGroups,
|
||||
fetchWorkloadTeachers
|
||||
} from '../auditorium-workload-service.js';
|
||||
|
||||
export function AuditoriumWorkloadTab() {
|
||||
const [date, setDate] = useState(() => toIsoDate(new Date()));
|
||||
const [currentTarget, setCurrentTarget] = useState(TARGET_CLASSROOMS);
|
||||
const [displayValue, setDisplayValue] = useState(DISPLAY_ALL);
|
||||
const [buildings, setBuildings] = useState([]);
|
||||
const [equipment, setEquipment] = useState([]);
|
||||
const [minCapacityText, setMinCapacityText] = useState('');
|
||||
|
||||
const [classrooms, setClassrooms] = useState([]);
|
||||
const [teachers, setTeachers] = useState([]);
|
||||
const [departments, setDepartments] = useState([]);
|
||||
const [equipments, setEquipments] = useState([]);
|
||||
const [groups, setGroups] = useState([]);
|
||||
const [semesters, setSemesters] = useState([]);
|
||||
|
||||
const [overview, setOverview] = useState({
|
||||
loading: true,
|
||||
message: 'Загрузка справочников и расписания...',
|
||||
error: '',
|
||||
timeSlots: [],
|
||||
lessonsByEntitySlot: new Map(),
|
||||
failedGroupLoads: 0,
|
||||
summaryReady: false
|
||||
});
|
||||
const [detail, setDetail] = useState({
|
||||
loading: false,
|
||||
message: '',
|
||||
error: '',
|
||||
summary: null,
|
||||
weekTable: null
|
||||
});
|
||||
const [alertState, setAlertState] = useState(null);
|
||||
|
||||
const dateRef = useRef(date);
|
||||
const targetRef = useRef(currentTarget);
|
||||
const displayRef = useRef(displayValue);
|
||||
|
||||
const targetConfig = WORKLOAD_TARGETS[currentTarget] || WORKLOAD_TARGETS[TARGET_CLASSROOMS];
|
||||
const roomMode = displayValue !== DISPLAY_ALL;
|
||||
const showOverviewFilters = !roomMode && currentTarget === TARGET_CLASSROOMS;
|
||||
|
||||
function showWorkloadAlert(text) {
|
||||
setAlertState({ message: text, type: 'error' });
|
||||
}
|
||||
|
||||
function hideWorkloadAlert() {
|
||||
setAlertState(null);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
(async () => {
|
||||
try {
|
||||
const [loadedClassrooms, loadedEquipments, loadedGroups, academicYears, loadedTeachers, loadedDepartments] = await Promise.all([
|
||||
fetchWorkloadClassrooms(),
|
||||
fetchWorkloadEquipments(),
|
||||
fetchWorkloadGroups(),
|
||||
fetchAcademicYears(),
|
||||
fetchWorkloadTeachers(),
|
||||
fetchWorkloadDepartments()
|
||||
]);
|
||||
if (!active) return;
|
||||
const dictionaries = {
|
||||
classrooms: sortClassrooms(loadedClassrooms),
|
||||
teachers: sortTeachers(loadedTeachers),
|
||||
departments: sortDepartments(loadedDepartments),
|
||||
equipments: loadedEquipments || [],
|
||||
groups: loadedGroups || [],
|
||||
semesters: semestersFromYears(academicYears)
|
||||
};
|
||||
setClassrooms(dictionaries.classrooms);
|
||||
setTeachers(dictionaries.teachers);
|
||||
setDepartments(dictionaries.departments);
|
||||
setEquipments(dictionaries.equipments);
|
||||
setGroups(dictionaries.groups);
|
||||
setSemesters(dictionaries.semesters);
|
||||
await loadCurrentView({ dictionaries });
|
||||
} catch (error) {
|
||||
if (!active) return;
|
||||
const message = error.message || 'Ошибка загрузки данных загруженности';
|
||||
setOverview(current => ({ ...current, loading: false, error: message }));
|
||||
setDetail(current => ({ ...current, loading: false, error: message }));
|
||||
showWorkloadAlert(message);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function loadCurrentView(context = {}) {
|
||||
const targetValue = context.target ?? targetRef.current;
|
||||
const displayVal = context.display ?? displayRef.current;
|
||||
const dateValue = context.date ?? dateRef.current;
|
||||
const source = context.dictionaries ?? { classrooms, teachers, departments, groups, semesters };
|
||||
const config = WORKLOAD_TARGETS[targetValue] || WORKLOAD_TARGETS[TARGET_CLASSROOMS];
|
||||
const entities = targetValue === TARGET_TEACHERS
|
||||
? source.teachers
|
||||
: targetValue === TARGET_DEPARTMENTS ? source.departments : source.classrooms;
|
||||
if (displayVal !== DISPLAY_ALL) {
|
||||
await loadDetail({
|
||||
dateValue,
|
||||
targetValue,
|
||||
displayVal,
|
||||
entities,
|
||||
groups: source.groups,
|
||||
teachers: source.teachers,
|
||||
departments: source.departments,
|
||||
semesters: source.semesters
|
||||
});
|
||||
} else {
|
||||
await loadOverview({
|
||||
dateValue,
|
||||
targetValue,
|
||||
groups: source.groups,
|
||||
teachers: source.teachers,
|
||||
departments: source.departments
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function loadOverview({ dateValue, targetValue, groups: groupList, teachers: teacherList, departments: departmentList }) {
|
||||
if (!dateValue) {
|
||||
showWorkloadAlert('Выберите дату');
|
||||
return;
|
||||
}
|
||||
hideWorkloadAlert();
|
||||
setOverview(current => ({
|
||||
...current,
|
||||
loading: true,
|
||||
message: 'Загрузка расписания на выбранную дату...',
|
||||
error: ''
|
||||
}));
|
||||
try {
|
||||
const slots = await fetchEffectiveTimeSlots(dateValue);
|
||||
if (!(groupList || []).length) {
|
||||
setOverview({
|
||||
loading: false,
|
||||
error: '',
|
||||
timeSlots: slots || [],
|
||||
lessonsByEntitySlot: new Map(),
|
||||
failedGroupLoads: 0,
|
||||
summaryReady: true
|
||||
});
|
||||
showWorkloadAlert('Группы не созданы, загруженность пуста');
|
||||
return;
|
||||
}
|
||||
const { lessonsByEntitySlot, failedLoads } = await loadOverviewLessons({
|
||||
groups: groupList,
|
||||
date: dateValue,
|
||||
target: targetValue,
|
||||
teachers: teacherList,
|
||||
departments: departmentList
|
||||
});
|
||||
setOverview({
|
||||
loading: false,
|
||||
error: '',
|
||||
timeSlots: slots || [],
|
||||
lessonsByEntitySlot,
|
||||
failedGroupLoads: failedLoads,
|
||||
summaryReady: true
|
||||
});
|
||||
if (failedLoads > 0) {
|
||||
showWorkloadAlert(`Часть групп не загрузилась: ${failedLoads}. Показаны доступные данные.`);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error.message || 'Ошибка загрузки загруженности';
|
||||
setOverview(current => ({ ...current, loading: false, error: message }));
|
||||
showWorkloadAlert(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDetail({ dateValue, targetValue, displayVal, entities, groups: groupList, teachers: teacherList, departments: departmentList, semesters: semesterList }) {
|
||||
const config = WORKLOAD_TARGETS[targetValue] || WORKLOAD_TARGETS[TARGET_CLASSROOMS];
|
||||
const entityId = displayVal !== DISPLAY_ALL ? displayVal.slice(config.prefix.length) : null;
|
||||
const entity = entityId
|
||||
? (entities || []).find(item => sameId(item.id, entityId)) || null
|
||||
: null;
|
||||
if (!dateValue) {
|
||||
showWorkloadAlert('Выберите дату');
|
||||
setDetail({
|
||||
loading: false,
|
||||
message: '',
|
||||
error: 'Выберите дату для расчёта двухнедельного периода',
|
||||
summary: null,
|
||||
weekTable: null
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!entity) {
|
||||
showWorkloadAlert(config.selectError);
|
||||
setDetail({
|
||||
loading: false,
|
||||
message: '',
|
||||
error: `${config.selectError} в поле «Отображение»`,
|
||||
summary: null,
|
||||
weekTable: null
|
||||
});
|
||||
return;
|
||||
}
|
||||
hideWorkloadAlert();
|
||||
setDetail({
|
||||
loading: true,
|
||||
message: 'Загрузка расписания на две недели...',
|
||||
error: '',
|
||||
summary: null,
|
||||
weekTable: null
|
||||
});
|
||||
try {
|
||||
const range = twoWeekRange(dateValue);
|
||||
const dates = datesBetween(range.startDate, range.endDate);
|
||||
const [slotLoad, lessonLoad] = await Promise.all([
|
||||
loadSlotsForDates(dates),
|
||||
loadEntityLessons({
|
||||
groups: groupList,
|
||||
startDate: range.startDate,
|
||||
endDate: range.endDate,
|
||||
entityId: entity.id,
|
||||
target: targetValue,
|
||||
teachers: teacherList,
|
||||
departments: departmentList
|
||||
})
|
||||
]);
|
||||
const slotRows = buildSlotRows(dates, slotLoad.slotsByDate, lessonLoad.lessons);
|
||||
const dateGrid = buildDateGridByDay(dates, lessonLoad.parityLessons, semesterList);
|
||||
setDetail({
|
||||
loading: false,
|
||||
message: '',
|
||||
error: '',
|
||||
summary: roomSummaryParts({
|
||||
target: targetValue,
|
||||
entity,
|
||||
range,
|
||||
totalCells: countAvailableRoomCells(dates, slotLoad.slotsByDate, lessonLoad.lessonsByDateSlot),
|
||||
occupiedCells: countOccupiedRoomCells(lessonLoad.lessonsByDateSlot),
|
||||
failedGroupLoads: lessonLoad.failedLoads,
|
||||
failedSlotLoads: slotLoad.failedLoads,
|
||||
unknownParityDates: dateGrid.unknownParityDates
|
||||
}),
|
||||
weekTable: slotRows.length ? {
|
||||
datesByDay: dateGrid.datesByDay,
|
||||
slotRows,
|
||||
slotsByDate: slotLoad.slotsByDate,
|
||||
lessonsByDateSlot: lessonLoad.lessonsByDateSlot
|
||||
} : null
|
||||
});
|
||||
const warnings = [];
|
||||
if (lessonLoad.failedLoads > 0) warnings.push(`групп не загрузилось: ${lessonLoad.failedLoads}`);
|
||||
if (slotLoad.failedLoads > 0) warnings.push(`сеток времени не загрузилось: ${slotLoad.failedLoads}`);
|
||||
if (warnings.length) {
|
||||
showWorkloadAlert(`Часть данных не загрузилась (${warnings.join(', ')}). Показаны доступные данные.`);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error.message || 'Ошибка загрузки загруженности';
|
||||
setDetail({
|
||||
loading: false,
|
||||
message: '',
|
||||
error: message,
|
||||
summary: null,
|
||||
weekTable: null
|
||||
});
|
||||
showWorkloadAlert(message);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDateChange(value) {
|
||||
dateRef.current = value;
|
||||
setDate(value);
|
||||
void loadCurrentView();
|
||||
}
|
||||
|
||||
function handleTargetChange(value) {
|
||||
const nextTarget = value || TARGET_CLASSROOMS;
|
||||
targetRef.current = nextTarget;
|
||||
displayRef.current = DISPLAY_ALL;
|
||||
setCurrentTarget(nextTarget);
|
||||
setDisplayValue(DISPLAY_ALL);
|
||||
void loadCurrentView({ target: nextTarget, display: DISPLAY_ALL });
|
||||
}
|
||||
|
||||
function handleDisplayChange(value) {
|
||||
displayRef.current = value;
|
||||
setDisplayValue(value);
|
||||
void loadCurrentView({ display: value });
|
||||
}
|
||||
|
||||
function toggleBuilding(value) {
|
||||
setBuildings(current => (
|
||||
current.includes(value) ? current.filter(item => item !== value) : [...current, value]
|
||||
));
|
||||
}
|
||||
|
||||
function toggleEquipment(value) {
|
||||
setEquipment(current => (
|
||||
current.includes(value) ? current.filter(item => item !== value) : [...current, value]
|
||||
));
|
||||
}
|
||||
|
||||
const buildingOptionItems = useMemo(() => buildingOptions(classrooms), [classrooms]);
|
||||
const equipmentOptionItems = useMemo(
|
||||
() => equipmentOptions(equipments, classrooms),
|
||||
[equipments, classrooms]
|
||||
);
|
||||
const minCapacity = parseMinCapacity(minCapacityText);
|
||||
|
||||
const entities = currentTarget === TARGET_TEACHERS
|
||||
? teachers
|
||||
: currentTarget === TARGET_DEPARTMENTS ? departments : classrooms;
|
||||
|
||||
const gridRows = useMemo(() => {
|
||||
if (currentTarget === TARGET_CLASSROOMS) {
|
||||
return filteredClassrooms(classrooms, { buildings, minCapacity, equipment });
|
||||
}
|
||||
return currentTarget === TARGET_TEACHERS ? teachers : departments;
|
||||
}, [currentTarget, classrooms, teachers, departments, buildings, minCapacity, equipment]);
|
||||
|
||||
const displayOptions = useMemo(() => ([
|
||||
{ value: DISPLAY_ALL, label: targetConfig.allLabel },
|
||||
...entities.map(entity => ({
|
||||
value: `${targetConfig.prefix}${entity.id}`,
|
||||
label: entityOptionLabel(currentTarget, entity)
|
||||
}))
|
||||
]), [currentTarget, entities, targetConfig]);
|
||||
|
||||
const summaryText = !overview.loading && overview.summaryReady
|
||||
? overviewSummaryText({
|
||||
target: currentTarget,
|
||||
dateValue: date,
|
||||
entityCount: gridRows.length,
|
||||
occupiedCells: countOccupiedCells(gridRows, overview.timeSlots, overview.lessonsByEntitySlot),
|
||||
totalCells: gridRows.length * overview.timeSlots.length,
|
||||
failedGroupLoads: overview.failedGroupLoads,
|
||||
selectedEntityId: null
|
||||
})
|
||||
: '';
|
||||
|
||||
const stateColSpan = Math.max(overview.timeSlots.length, 1) + 1;
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-header-row workload-card-header">
|
||||
<h2>Загруженность</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-md btn-secondary"
|
||||
id="workload-refresh"
|
||||
onClick={() => void loadCurrentView()}
|
||||
>
|
||||
Обновить
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="filter-row workload-filter-row">
|
||||
<div className="form-group workload-target-control">
|
||||
<label htmlFor="workload-target-select">Что смотреть</label>
|
||||
<select
|
||||
id="workload-target-select"
|
||||
className="workload-display-select"
|
||||
value={currentTarget}
|
||||
onChange={event => handleTargetChange(event.target.value)}
|
||||
>
|
||||
<option value={TARGET_CLASSROOMS}>Аудитории</option>
|
||||
<option value={TARGET_TEACHERS}>Преподаватели</option>
|
||||
<option value={TARGET_DEPARTMENTS}>Кафедры</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group workload-display-control">
|
||||
<label htmlFor="workload-display-select">Отображение</label>
|
||||
<select
|
||||
id="workload-display-select"
|
||||
className="workload-display-select"
|
||||
value={displayValue}
|
||||
onChange={event => handleDisplayChange(event.target.value)}
|
||||
>
|
||||
{displayOptions.map(option => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{showOverviewFilters && (
|
||||
<div className="form-group workload-overview-filter">
|
||||
<label>Корпус</label>
|
||||
<WorkloadMultiSelect
|
||||
options={buildingOptionItems}
|
||||
selectedValues={buildings}
|
||||
emptyText="Все корпуса"
|
||||
onToggle={toggleBuilding}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showOverviewFilters && (
|
||||
<div className="form-group workload-overview-filter">
|
||||
<label htmlFor="workload-min-capacity">Мин. вместимость</label>
|
||||
<input
|
||||
type="number"
|
||||
id="workload-min-capacity"
|
||||
className="workload-min-capacity"
|
||||
placeholder="0"
|
||||
min="0"
|
||||
value={minCapacityText}
|
||||
onChange={event => setMinCapacityText(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showOverviewFilters && (
|
||||
<div className="form-group workload-overview-filter">
|
||||
<label>Оборудование</label>
|
||||
<WorkloadMultiSelect
|
||||
options={equipmentOptionItems}
|
||||
selectedValues={equipment}
|
||||
emptyText="Любое оборудование"
|
||||
onToggle={toggleEquipment}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-group workload-date-field">
|
||||
<label htmlFor="workload-date">{roomMode ? 'Дата в периоде' : 'Дата'}</label>
|
||||
<input
|
||||
type="date"
|
||||
id="workload-date"
|
||||
value={date}
|
||||
onChange={event => handleDateChange(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FormAlert alert={alertState} id="workload-alert" />
|
||||
|
||||
{!roomMode && (
|
||||
<div id="workload-overview-block">
|
||||
<div className="workload-summary" id="workload-summary">{summaryText}</div>
|
||||
|
||||
<div className="workload-grid-container">
|
||||
<table className="workload-table" id="workload-table">
|
||||
<thead>
|
||||
<tr id="workload-header-row">
|
||||
<th className="top-left-cell">
|
||||
<span className="top-label">Время</span>
|
||||
<span className="bottom-label">{targetConfig.rowLabel}</span>
|
||||
</th>
|
||||
{overview.timeSlots.map(slot => (
|
||||
<th key={slot.id ?? slot.orderNumber}>
|
||||
<strong>{slot.orderNumber || ''} пара</strong>
|
||||
<span>{timeSlotLabel(slot)}</span>
|
||||
{slot.scopeApplyMode !== 'DEFAULT' && <small>{scopeShort(slot)}</small>}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="workload-tbody">
|
||||
{overview.loading ? (
|
||||
<tr>
|
||||
<td colSpan={stateColSpan} className="loading-row">{overview.message}</td>
|
||||
</tr>
|
||||
) : overview.error ? (
|
||||
<tr>
|
||||
<td colSpan={stateColSpan} className="loading-row">{overview.error}</td>
|
||||
</tr>
|
||||
) : gridRows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={overview.timeSlots.length + 1} className="loading-row">
|
||||
{targetConfig.emptyLabel}
|
||||
</td>
|
||||
</tr>
|
||||
) : gridRows.map(entity => (
|
||||
<WorkloadRow
|
||||
key={entity.id}
|
||||
target={currentTarget}
|
||||
entity={entity}
|
||||
timeSlots={overview.timeSlots}
|
||||
lessonsByEntitySlot={overview.lessonsByEntitySlot}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{roomMode && (
|
||||
<div className="room-workload-block" id="room-workload-block">
|
||||
<div className="room-workload-summary" id="room-workload-summary">
|
||||
{detail.loading || detail.error || !detail.summary ? '' : (
|
||||
<>
|
||||
<div className="room-summary-main">
|
||||
<strong>{detail.summary.name}</strong>
|
||||
{detail.summary.details && <span>{detail.summary.details}</span>}
|
||||
</div>
|
||||
<span className="room-summary-chip">{detail.summary.rangeLabel}</span>
|
||||
<span className="room-summary-chip">{detail.summary.occupancyLabel}</span>
|
||||
{detail.summary.warnings && (
|
||||
<span className="room-summary-warning">{detail.summary.warnings}</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="room-workload-tables" id="room-workload-tables">
|
||||
{detail.loading ? (
|
||||
<div className="loading-row room-workload-state">{detail.message}</div>
|
||||
) : detail.error ? (
|
||||
<div className="loading-row room-workload-state">{detail.error}</div>
|
||||
) : !detail.weekTable ? (
|
||||
<div className="loading-row room-workload-state">
|
||||
За выбранный период нет временных слотов
|
||||
</div>
|
||||
) : (
|
||||
<RoomWeekTable
|
||||
target={currentTarget}
|
||||
semesters={semesters}
|
||||
datesByDay={detail.weekTable.datesByDay}
|
||||
slotRows={detail.weekTable.slotRows}
|
||||
slotsByDate={detail.weekTable.slotsByDate}
|
||||
lessonsByDateSlot={detail.weekTable.lessonsByDateSlot}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkloadRow({ target, entity, timeSlots, lessonsByEntitySlot }) {
|
||||
const axis = entityAxisParts(target, entity);
|
||||
const axisClass = `axis-cell${target === TARGET_CLASSROOMS && entity.isAvailable === false ? ' room-unavailable' : ''}`;
|
||||
|
||||
return (
|
||||
<tr>
|
||||
<td className={axisClass}>
|
||||
<strong>{axis.title}</strong>
|
||||
<span>{axis.subtitle}</span>
|
||||
{axis.note && <small>{axis.note}</small>}
|
||||
</td>
|
||||
{timeSlots.map(slot => {
|
||||
const lessons = lessonsByEntitySlot.get(entitySlotKey(entity.id, slot.id)) || [];
|
||||
return (
|
||||
<td
|
||||
key={slot.id ?? slot.orderNumber}
|
||||
className={lessons.length ? 'workload-busy-cell' : 'workload-free-cell'}
|
||||
>
|
||||
{lessons.length
|
||||
? lessons.map((lesson, index) => <LessonCard key={index} lesson={lesson} />)
|
||||
: <span className="free-slot">Свободно</span>}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function LessonCard({ lesson }) {
|
||||
const subgroupText = (lesson.subgroupNames || []).join(', ');
|
||||
return (
|
||||
<div className="lesson-card">
|
||||
<div className="lesson-subject">{lesson.subjectName || 'Без дисциплины'}</div>
|
||||
<div className="lesson-group">{(lesson.groupNames || []).join(', ') || 'Группа не указана'}</div>
|
||||
{subgroupText && <div className="lesson-subgroup">{subgroupText}</div>}
|
||||
<div className="lesson-teacher">{lesson.teacherName || 'Преподаватель не указан'}</div>
|
||||
<div className="lesson-meta">
|
||||
{[lesson.lessonTypeName, lesson.lessonFormat].filter(Boolean).join(' · ')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RoomWeekTable({ target, semesters, datesByDay, slotRows, slotsByDate, lessonsByDateSlot }) {
|
||||
const config = WORKLOAD_TARGETS[target] || WORKLOAD_TARGETS[TARGET_CLASSROOMS];
|
||||
return (
|
||||
<section className="room-week-panel">
|
||||
<div className="room-week-heading">
|
||||
<h3>{config.periodTitle}</h3>
|
||||
<span>{roomWeekCaption(datesByDay, semesters)}</span>
|
||||
</div>
|
||||
<div className="workload-grid-container room-grid-container">
|
||||
<table className="workload-table room-week-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="room-time-heading">Время</th>
|
||||
{DAY_ORDER.map(day => (
|
||||
<th className="room-day-cell" key={day}>
|
||||
<strong>{dayShort(day)}</strong>
|
||||
<span>{DAY_FULL_LABELS[day] || ''}</span>
|
||||
<div className="room-day-parity-dates">
|
||||
{CELL_PARITY_LAYOUT.map(({ parity, label }) => (
|
||||
<span key={parity}>
|
||||
{label}: {datesByDay[day]?.[parity]
|
||||
? formatDateShort(datesByDay[day][parity])
|
||||
: '-'}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{slotRows.map(row => (
|
||||
<tr key={row.key}>
|
||||
<td className="room-time-cell">
|
||||
<strong>{row.orderLabel}</strong>
|
||||
<span>{row.timeLabel}</span>
|
||||
</td>
|
||||
{DAY_ORDER.map(day => (
|
||||
<RoomCombinedCell
|
||||
key={day}
|
||||
datesByParity={datesByDay[day] || {}}
|
||||
row={row}
|
||||
slotsByDate={slotsByDate}
|
||||
lessonsByDateSlot={lessonsByDateSlot}
|
||||
/>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function RoomCombinedCell({ datesByParity, row, slotsByDate, lessonsByDateSlot }) {
|
||||
const states = CELL_PARITY_LAYOUT.map(({ parity, label }) => ({
|
||||
parity,
|
||||
label,
|
||||
state: roomSlotState(datesByParity[parity], row, slotsByDate, lessonsByDateSlot)
|
||||
}));
|
||||
|
||||
if (roomSlotStatesEqual(states[0].state, states[1].state)) {
|
||||
const state = states[0].state;
|
||||
return (
|
||||
<td className="room-combined-cell room-single-cell">
|
||||
<div className={`room-week-unified room-week-unified-${state.kind === 'busy' ? 'busy' : state.kind}`}>
|
||||
{state.kind === 'busy'
|
||||
? state.lessons.map((lesson, index) => <LessonCard key={index} lesson={lesson} />)
|
||||
: state.kind === 'missing' ? <span className="slot-unavailable">Нет слота</span>
|
||||
: state.kind === 'empty' ? <span className="slot-unavailable">Нет даты</span>
|
||||
: <span className="free-slot">Свободно</span>}
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<td className="room-combined-cell">
|
||||
{states.map(({ parity, label, state }) => (
|
||||
<div
|
||||
key={parity}
|
||||
className={`room-week-half room-week-half-${parity.toLowerCase()} room-week-half-${state.kind === 'busy' ? 'busy' : state.kind}`}
|
||||
>
|
||||
<span className="room-half-marker">{label}</span>
|
||||
{state.kind === 'busy'
|
||||
? state.lessons.map((lesson, index) => <LessonCard key={index} lesson={lesson} />)
|
||||
: state.kind === 'missing' ? <span className="slot-unavailable">Нет слота</span>
|
||||
: state.kind === 'empty' ? <span className="slot-unavailable">Нет даты</span>
|
||||
: <span className="free-slot">Свободно</span>}
|
||||
</div>
|
||||
))}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkloadMultiSelect({ options, selectedValues, emptyText, onToggle }) {
|
||||
const rootRef = useRef(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return undefined;
|
||||
function handlePointerDown(event) {
|
||||
if (rootRef.current && !rootRef.current.contains(event.target)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handlePointerDown);
|
||||
return () => document.removeEventListener('mousedown', handlePointerDown);
|
||||
}, [open]);
|
||||
|
||||
const selectedLabels = options
|
||||
.filter(option => selectedValues.includes(option.value))
|
||||
.map(option => option.label);
|
||||
const label = selectedLabels.length === 0
|
||||
? emptyText
|
||||
: selectedLabels.length === 1 ? selectedLabels[0] : `Выбрано: ${selectedLabels.length}`;
|
||||
|
||||
return (
|
||||
<div className="custom-multi-select" ref={rootRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={`select-box${open ? ' active' : ''}`}
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen(current => !current)}
|
||||
>
|
||||
<span className="select-text">{label}</span>
|
||||
<svg className="dropdown-icon" width="12" height="8" viewBox="0 0 12 8" fill="none" aria-hidden="true">
|
||||
<path d="M1 1.5L6 6.5L11 1.5" stroke="#9ca3af" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className={`dropdown-menu${open ? ' open' : ''}`}>
|
||||
<div className="checkbox-group-vertical">
|
||||
{options.length === 0 ? (
|
||||
<div className="workload-filter-empty">Нет данных</div>
|
||||
) : options.map(option => (
|
||||
<label className="checkbox-item" key={option.value}>
|
||||
<input
|
||||
type="checkbox"
|
||||
value={option.value}
|
||||
checked={selectedValues.includes(option.value)}
|
||||
onChange={() => onToggle(option.value)}
|
||||
/>
|
||||
<span className="checkmark"></span>
|
||||
<span className="checkbox-label">{option.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
632
frontend/react/admin/tabs/ClassroomsTab.jsx
Normal file
632
frontend/react/admin/tabs/ClassroomsTab.jsx
Normal file
@@ -0,0 +1,632 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { AlertDialog, ConfirmDialog } from '../../shared/ui/ConfirmDialog.jsx';
|
||||
import {
|
||||
archiveClassroom,
|
||||
classroomStatusLabel,
|
||||
createClassroom,
|
||||
createEquipment,
|
||||
deleteEquipment,
|
||||
equipmentIdSelection,
|
||||
equipmentNamesText,
|
||||
equipmentSelectLabel,
|
||||
fetchClassrooms,
|
||||
fetchEquipments,
|
||||
parseCapacity,
|
||||
restoreClassroom,
|
||||
toggleClassroomAvailability,
|
||||
updateClassroom
|
||||
} from '../classrooms-service.js';
|
||||
|
||||
export function ClassroomsTab() {
|
||||
const [equipments, setEquipments] = useState([]);
|
||||
const [classrooms, setClassrooms] = useState(null);
|
||||
const [loadError, setLoadError] = useState(false);
|
||||
|
||||
const [createName, setCreateName] = useState('');
|
||||
const [createCapacity, setCreateCapacity] = useState('');
|
||||
const [createEquipmentIds, setCreateEquipmentIds] = useState(() => new Set());
|
||||
const [createAlert, setCreateAlert] = useState({ text: '', isError: false });
|
||||
|
||||
const [editModal, setEditModal] = useState(null);
|
||||
const [equipmentModal, setEquipmentModal] = useState(null);
|
||||
const [confirmState, setConfirmState] = useState(null);
|
||||
const [alertState, setAlertState] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
(async () => {
|
||||
try {
|
||||
const [loadedEquipments, loadedClassrooms] = await Promise.all([
|
||||
fetchEquipments(),
|
||||
fetchClassrooms()
|
||||
]);
|
||||
if (!active) return;
|
||||
setEquipments(loadedEquipments || []);
|
||||
setClassrooms(loadedClassrooms || []);
|
||||
setLoadError(false);
|
||||
} catch {
|
||||
if (active) setLoadError(true);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function reloadClassrooms() {
|
||||
try {
|
||||
setClassrooms(await fetchClassrooms());
|
||||
setLoadError(false);
|
||||
} catch {
|
||||
setLoadError(true);
|
||||
}
|
||||
}
|
||||
|
||||
async function reloadEquipments() {
|
||||
const loaded = await fetchEquipments();
|
||||
setEquipments(loaded || []);
|
||||
return loaded || [];
|
||||
}
|
||||
|
||||
function toggleCreateEquipment(equipmentId) {
|
||||
setCreateEquipmentIds(current => {
|
||||
const next = new Set(current);
|
||||
if (next.has(equipmentId)) {
|
||||
next.delete(equipmentId);
|
||||
} else {
|
||||
next.add(equipmentId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function submitCreate(event) {
|
||||
event.preventDefault();
|
||||
const name = createName.trim();
|
||||
const capacity = parseCapacity(createCapacity);
|
||||
if (!name || Number.isNaN(capacity)) {
|
||||
setCreateAlert({ text: 'Заполните обязательные поля', isError: true });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const created = await createClassroom({
|
||||
name,
|
||||
capacity,
|
||||
equipmentIds: [...createEquipmentIds].map(Number)
|
||||
});
|
||||
setCreateAlert({ text: `Аудитория «${created.name}» добавлена`, isError: false });
|
||||
setCreateName('');
|
||||
setCreateCapacity('');
|
||||
setCreateEquipmentIds(new Set());
|
||||
await reloadClassrooms();
|
||||
} catch (error) {
|
||||
setCreateAlert({ text: error.message || 'Ошибка создания', isError: true });
|
||||
}
|
||||
}
|
||||
|
||||
function confirmArchive(classroom) {
|
||||
setConfirmState({
|
||||
title: 'Вывод аудитории из эксплуатации',
|
||||
message: 'Историческое расписание сохранится, но аудитория перестанет участвовать в планировании.',
|
||||
confirmLabel: 'Вывести из эксплуатации',
|
||||
danger: true,
|
||||
action: async () => {
|
||||
try {
|
||||
await archiveClassroom(classroom.id);
|
||||
await reloadClassrooms();
|
||||
} catch (error) {
|
||||
setAlertState({
|
||||
title: 'Ошибка архивирования',
|
||||
message: error.message || 'Не удалось архивировать аудиторию'
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function restore(classroom) {
|
||||
try {
|
||||
await restoreClassroom(classroom.id);
|
||||
await reloadClassrooms();
|
||||
} catch (error) {
|
||||
setAlertState({
|
||||
title: 'Ошибка восстановления',
|
||||
message: error.message || 'Не удалось восстановить аудиторию'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleAvailability(classroom) {
|
||||
try {
|
||||
await toggleClassroomAvailability(classroom.id, !classroom.isAvailable);
|
||||
await reloadClassrooms();
|
||||
} catch (error) {
|
||||
setAlertState({
|
||||
title: 'Ошибка изменения статуса',
|
||||
message: error.message || 'Не удалось изменить доступность аудитории'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function openEditModal(classroomId) {
|
||||
try {
|
||||
const fresh = await fetchClassrooms();
|
||||
const classroom = (fresh || []).find(item => String(item.id) === String(classroomId));
|
||||
if (!classroom) return;
|
||||
setEditModal({
|
||||
classroom,
|
||||
name: classroom.name,
|
||||
capacity: String(classroom.capacity),
|
||||
equipmentIds: new Set((classroom.equipments || []).map(equipment => String(equipment.id))),
|
||||
alert: { text: '', isError: false }
|
||||
});
|
||||
} catch (error) {
|
||||
setAlertState({
|
||||
title: 'Ошибка загрузки',
|
||||
message: error.message || 'Не удалось загрузить данные аудитории'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function toggleEditEquipment(equipmentId) {
|
||||
setEditModal(current => {
|
||||
if (!current) return current;
|
||||
const next = new Set(current.equipmentIds);
|
||||
if (next.has(equipmentId)) {
|
||||
next.delete(equipmentId);
|
||||
} else {
|
||||
next.add(equipmentId);
|
||||
}
|
||||
return { ...current, equipmentIds: next };
|
||||
});
|
||||
}
|
||||
|
||||
async function submitEdit(event) {
|
||||
event.preventDefault();
|
||||
const modal = editModal;
|
||||
const name = modal.name.trim();
|
||||
const capacity = parseCapacity(modal.capacity);
|
||||
if (!name || Number.isNaN(capacity)) {
|
||||
setEditModal({ ...modal, alert: { text: 'Заполните обязательные поля', isError: true } });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const updated = await updateClassroom(modal.classroom.id, {
|
||||
name,
|
||||
capacity,
|
||||
equipmentIds: [...modal.equipmentIds].map(Number),
|
||||
isAvailable: modal.classroom.isAvailable
|
||||
});
|
||||
setEditModal(null);
|
||||
setCreateAlert({ text: `Аудитория «${updated.name}» обновлена`, isError: false });
|
||||
await reloadClassrooms();
|
||||
} catch (error) {
|
||||
setEditModal({
|
||||
...modal,
|
||||
alert: { text: error.message || 'Ошибка обновления', isError: true }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function openEquipmentModal() {
|
||||
setEquipmentModal({
|
||||
name: '',
|
||||
list: null,
|
||||
alert: { text: '', isError: false }
|
||||
});
|
||||
await reloadEquipmentModalList();
|
||||
}
|
||||
|
||||
async function reloadEquipmentModalList() {
|
||||
try {
|
||||
const loaded = await reloadEquipments();
|
||||
setEquipmentModal(current => (current ? { ...current, list: loaded } : current));
|
||||
} catch {
|
||||
setEquipmentModal(current => (current ? { ...current, list: [] } : current));
|
||||
}
|
||||
}
|
||||
|
||||
async function submitEquipment(event) {
|
||||
event.preventDefault();
|
||||
const modal = equipmentModal;
|
||||
const name = modal.name.trim();
|
||||
if (!name) {
|
||||
setEquipmentModal({ ...modal, alert: { text: 'Введите название', isError: true } });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const created = await createEquipment(name);
|
||||
setEquipmentModal(current => (
|
||||
current ? { ...current, name: '', alert: { text: `Оборудование «${created.name}» добавлено`, isError: false } } : current
|
||||
));
|
||||
await reloadEquipmentModalList();
|
||||
} catch (error) {
|
||||
setEquipmentModal({
|
||||
...modal,
|
||||
alert: { text: error.message || 'Ошибка добавления', isError: true }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDeleteEquipment(equipment) {
|
||||
setConfirmState({
|
||||
title: 'Удаление оборудования',
|
||||
message: 'Удалить оборудование из каталога?',
|
||||
confirmLabel: 'Удалить',
|
||||
danger: true,
|
||||
action: async () => {
|
||||
try {
|
||||
await deleteEquipment(equipment.id);
|
||||
await reloadEquipmentModalList();
|
||||
} catch (error) {
|
||||
setAlertState({
|
||||
title: 'Ошибка удаления',
|
||||
message: error.message || 'Не удалось удалить оборудование'
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function runConfirm() {
|
||||
const confirm = confirmState;
|
||||
setConfirmState(null);
|
||||
await confirm.action();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="card create-card">
|
||||
<div className="card-toolbar">
|
||||
<h2>Новая аудитория</h2>
|
||||
<button type="button" className="btn btn-md btn-secondary" onClick={openEquipmentModal}>
|
||||
Справочник оборудования
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={submitCreate}>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label htmlFor="new-classroom-name">Номер / Название</label>
|
||||
<input
|
||||
type="text"
|
||||
id="new-classroom-name"
|
||||
placeholder="101 Ленинская"
|
||||
required
|
||||
value={createName}
|
||||
onChange={event => setCreateName(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="new-classroom-capacity">Вместимость (чел.)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="new-classroom-capacity"
|
||||
placeholder="30"
|
||||
min="1"
|
||||
required
|
||||
value={createCapacity}
|
||||
onChange={event => setCreateCapacity(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row form-row--spacing">
|
||||
<div className="form-group form-group--wide">
|
||||
<label>Оборудование</label>
|
||||
<EquipmentMultiSelect
|
||||
equipments={equipments}
|
||||
selectedIds={createEquipmentIds}
|
||||
onToggle={toggleCreateEquipment}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group form-group--submit">
|
||||
<button type="submit" className="btn btn-md btn-primary">Добавить</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`form-alert${createAlert.text ? (createAlert.isError ? ' error' : ' success') : ''}`}
|
||||
role="alert"
|
||||
>
|
||||
{createAlert.text}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h2>Список аудиторий</h2>
|
||||
<div className="table-wrap">
|
||||
<table id="classrooms-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Название</th>
|
||||
<th>Вместимость</th>
|
||||
<th>Оборудование</th>
|
||||
<th>Статус</th>
|
||||
<th className="th-actions">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loadError ? (
|
||||
<tr><td colSpan="6" className="loading-row">Ошибка загрузки данных</td></tr>
|
||||
) : classrooms === null ? (
|
||||
<tr><td colSpan="6" className="loading-row"><span className="state-spinner" aria-hidden="true" />Загрузка...</td></tr>
|
||||
) : classrooms.length === 0 ? (
|
||||
<tr><td colSpan="6" className="loading-row">Нет аудиторий</td></tr>
|
||||
) : classrooms.map(classroom => (
|
||||
<tr key={classroom.id}>
|
||||
<td>{classroom.id}</td>
|
||||
<td><strong>{classroom.name}</strong></td>
|
||||
<td>{classroom.capacity} чел.</td>
|
||||
<td><small>{equipmentNamesText(classroom) || '—'}</small></td>
|
||||
<td>
|
||||
<div className="status-cell">
|
||||
<span className={`badge ${classroom.isAvailable ? 'badge-available' : 'badge-unavailable'}`}>
|
||||
{classroomStatusLabel(classroom)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-icon-sm btn-secondary btn-icon-toggle"
|
||||
title="Сменить статус"
|
||||
aria-label="Сменить статус"
|
||||
onClick={() => void toggleAvailability(classroom)}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"></path></svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td className="table-cell-actions">
|
||||
<div className="table-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-secondary classroom-edit-action"
|
||||
onClick={() => void openEditModal(classroom.id)}
|
||||
>
|
||||
Изменить
|
||||
</button>
|
||||
{classroom.status === 'ARCHIVED' ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-secondary btn-restore-classroom"
|
||||
onClick={() => void restore(classroom)}
|
||||
>
|
||||
Восстановить
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-danger-subtle btn-archive-classroom"
|
||||
onClick={() => confirmArchive(classroom)}
|
||||
>
|
||||
Архивировать
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editModal && (
|
||||
<div
|
||||
className="modal-overlay open"
|
||||
onClick={event => {
|
||||
if (event.target === event.currentTarget) setEditModal(null);
|
||||
}}
|
||||
>
|
||||
<div className="modal-content card">
|
||||
<h2>Редактировать аудиторию</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-icon-md btn-ghost modal-close"
|
||||
aria-label="Закрыть окно"
|
||||
onClick={() => setEditModal(null)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<form onSubmit={submitEdit}>
|
||||
<input type="hidden" value={editModal.classroom.id} />
|
||||
<div className="form-row form-row--spacing">
|
||||
<div className="form-group">
|
||||
<label htmlFor="edit-classroom-name">Номер / Название</label>
|
||||
<input
|
||||
type="text"
|
||||
id="edit-classroom-name"
|
||||
required
|
||||
value={editModal.name}
|
||||
onChange={event => setEditModal({ ...editModal, name: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="edit-classroom-capacity">Вместимость (чел.)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="edit-classroom-capacity"
|
||||
min="1"
|
||||
required
|
||||
value={editModal.capacity}
|
||||
onChange={event => setEditModal({ ...editModal, capacity: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row form-row--spacing">
|
||||
<div className="form-group form-group--wide">
|
||||
<label>Оборудование</label>
|
||||
<EquipmentMultiSelect
|
||||
equipments={equipments}
|
||||
selectedIds={editModal.equipmentIds}
|
||||
onToggle={toggleEditEquipment}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group form-group--submit">
|
||||
<button type="submit" className="btn btn-md btn-primary btn-block">Сохранить</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`form-alert${editModal.alert.text ? (editModal.alert.isError ? ' error' : ' success') : ''}`}
|
||||
role="alert"
|
||||
>
|
||||
{editModal.alert.text}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{equipmentModal && (
|
||||
<div
|
||||
className="modal-overlay open"
|
||||
onClick={event => {
|
||||
if (event.target === event.currentTarget) setEquipmentModal(null);
|
||||
}}
|
||||
>
|
||||
<div className="modal-content card modal-content--medium">
|
||||
<div className="modal-header-row">
|
||||
<h2>Справочник оборудования</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-icon-md btn-ghost modal-close modal-close--static"
|
||||
aria-label="Закрыть окно"
|
||||
onClick={() => setEquipmentModal(null)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="card create-card modal-subcard">
|
||||
<h3>Добавить оборудование</h3>
|
||||
<form onSubmit={submitEquipment}>
|
||||
<div className="form-row form-row--bottom">
|
||||
<div className="form-group form-group--flush">
|
||||
<label htmlFor="manage-equipment-name">Название оборудования</label>
|
||||
<input
|
||||
type="text"
|
||||
id="manage-equipment-name"
|
||||
placeholder="Проектор"
|
||||
required
|
||||
value={equipmentModal.name}
|
||||
onChange={event => setEquipmentModal({ ...equipmentModal, name: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="btn btn-md btn-primary btn-inline">Добавить</button>
|
||||
</div>
|
||||
<div
|
||||
className={`form-alert${equipmentModal.alert.text ? (equipmentModal.alert.isError ? ' error' : ' success') : ''}`}
|
||||
role="alert"
|
||||
>
|
||||
{equipmentModal.alert.text}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div className="table-wrap">
|
||||
<table id="manage-equipments-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Название</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{equipmentModal.list === null ? (
|
||||
<tr><td colSpan="3" className="loading-row">Загрузка...</td></tr>
|
||||
) : equipmentModal.list.length === 0 ? (
|
||||
<tr><td colSpan="3" className="loading-row">Нет оборудования</td></tr>
|
||||
) : equipmentModal.list.map(equipment => (
|
||||
<tr key={equipment.id}>
|
||||
<td>{equipment.id}</td>
|
||||
<td>{equipment.name}</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-danger btn-delete-equipment"
|
||||
onClick={() => confirmDeleteEquipment(equipment)}
|
||||
>
|
||||
Удалить
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{confirmState && (
|
||||
<ConfirmDialog
|
||||
title={confirmState.title}
|
||||
message={confirmState.message}
|
||||
confirmLabel={confirmState.confirmLabel}
|
||||
danger={confirmState.danger}
|
||||
onConfirm={() => void runConfirm()}
|
||||
onClose={() => setConfirmState(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{alertState && (
|
||||
<AlertDialog
|
||||
title={alertState.title}
|
||||
message={alertState.message}
|
||||
onClose={() => setAlertState(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function EquipmentMultiSelect({ equipments, selectedIds, onToggle }) {
|
||||
const rootRef = useRef(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return undefined;
|
||||
function handlePointerDown(event) {
|
||||
if (rootRef.current && !rootRef.current.contains(event.target)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handlePointerDown);
|
||||
return () => document.removeEventListener('mousedown', handlePointerDown);
|
||||
}, [open]);
|
||||
|
||||
const selection = equipmentIdSelection(equipments, [...selectedIds]);
|
||||
const selectedNames = selection.filter(item => item.checked).map(item => item.name);
|
||||
|
||||
return (
|
||||
<div className="custom-multi-select" ref={rootRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={`select-box${open ? ' active' : ''}`}
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen(current => !current)}
|
||||
>
|
||||
<span className="select-text">{equipmentSelectLabel(selectedNames)}</span>
|
||||
<svg className="dropdown-icon" width="12" height="8" viewBox="0 0 12 8" fill="none" aria-hidden="true">
|
||||
<path d="M1 1.5L6 6.5L11 1.5" stroke="#9ca3af" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className={`dropdown-menu${open ? ' open' : ''}`}>
|
||||
<div className="checkbox-group-vertical">
|
||||
{equipments.length === 0 ? (
|
||||
<p className="text-muted"><small>Оборудование отсутствует</small></p>
|
||||
) : selection.map(item => (
|
||||
<label className="checkbox-item" key={item.id}>
|
||||
<input
|
||||
type="checkbox"
|
||||
value={item.id}
|
||||
checked={item.checked}
|
||||
onChange={() => onToggle(String(item.id))}
|
||||
/>
|
||||
<span className="checkmark"></span>
|
||||
<span>{item.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
354
frontend/react/admin/tabs/DashboardTab.jsx
Normal file
354
frontend/react/admin/tabs/DashboardTab.jsx
Normal file
@@ -0,0 +1,354 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import {
|
||||
CONFLICT_CHECK_STATE,
|
||||
classroomOccupancyLabel,
|
||||
conflictCheckPresentation,
|
||||
currentSlotLabel,
|
||||
currentWeekDateRange,
|
||||
detectScheduleConflicts,
|
||||
fetchDashboardReferenceData,
|
||||
fetchEffectiveTimeSlots,
|
||||
fetchTodayLessons,
|
||||
findCurrentSlot,
|
||||
formatLocalDate,
|
||||
freeClassroomsForSlot,
|
||||
lessonsForSlot,
|
||||
loadWeekDepartmentSchedules,
|
||||
sortTimeSlots,
|
||||
totalStudents
|
||||
} from '../dashboard-service.js';
|
||||
|
||||
const REALTIME_UPDATE_MS = 30_000;
|
||||
|
||||
export function DashboardTab() {
|
||||
const [reference, setReference] = useState(null);
|
||||
const [todayLessons, setTodayLessons] = useState([]);
|
||||
const [timeSlots, setTimeSlots] = useState([]);
|
||||
const [dataPhase, setDataPhase] = useState('loading');
|
||||
const [conflictPhase, setConflictPhase] = useState('checking');
|
||||
const [conflicts, setConflicts] = useState({ items: [], presentation: null });
|
||||
const [recheckBusy, setRecheckBusy] = useState(false);
|
||||
const [now, setNow] = useState(() => new Date());
|
||||
|
||||
useEffect(() => {
|
||||
void loadData();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => setNow(new Date()), REALTIME_UPDATE_MS);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
async function loadData() {
|
||||
setDataPhase('loading');
|
||||
setConflictPhase('checking');
|
||||
try {
|
||||
const loadedReference = await fetchDashboardReferenceData();
|
||||
const todayStr = formatLocalDate(new Date());
|
||||
const [lessons, slots] = await Promise.all([
|
||||
fetchTodayLessons(loadedReference.departments, todayStr),
|
||||
fetchEffectiveTimeSlots(todayStr)
|
||||
]);
|
||||
setReference(loadedReference);
|
||||
setTodayLessons(lessons);
|
||||
setTimeSlots(sortTimeSlots(slots));
|
||||
setDataPhase('ready');
|
||||
await checkConflicts(loadedReference);
|
||||
} catch {
|
||||
setDataPhase('error');
|
||||
setConflictPhase('error');
|
||||
console.error('Не удалось загрузить данные панели');
|
||||
}
|
||||
}
|
||||
|
||||
async function checkConflicts(referenceData) {
|
||||
setConflictPhase('checking');
|
||||
try {
|
||||
const range = currentWeekDateRange(new Date());
|
||||
const scheduleLoad = await loadWeekDepartmentSchedules(referenceData.departments, range);
|
||||
const items = detectScheduleConflicts(scheduleLoad.lessons, referenceData.groups, referenceData.classrooms);
|
||||
const presentation = scheduleLoad.state === CONFLICT_CHECK_STATE.NOT_RUN
|
||||
? conflictCheckPresentation(scheduleLoad, 0, range)
|
||||
: conflictCheckPresentation(scheduleLoad, items.length, range);
|
||||
setConflicts({ items, presentation });
|
||||
setConflictPhase('ready');
|
||||
} catch {
|
||||
setConflictPhase('error');
|
||||
console.error('Не удалось выполнить проверку конфликтов');
|
||||
}
|
||||
}
|
||||
|
||||
async function recheck() {
|
||||
setRecheckBusy(true);
|
||||
try {
|
||||
if (dataPhase === 'ready' && reference) {
|
||||
await checkConflicts(reference);
|
||||
} else {
|
||||
await loadData();
|
||||
}
|
||||
} finally {
|
||||
setRecheckBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const ready = dataPhase === 'ready' && reference !== null;
|
||||
const currentSlot = ready ? findCurrentSlot(timeSlots, now) : null;
|
||||
const currentLessons = currentSlot ? lessonsForSlot(todayLessons, currentSlot.orderNumber) : [];
|
||||
const freeRooms = ready ? freeClassroomsForSlot(reference.classrooms, todayLessons, currentSlot ? currentSlot.orderNumber : null) : [];
|
||||
|
||||
return (
|
||||
<div className="dashboard-container">
|
||||
<div className="dashboard-metrics-grid">
|
||||
<MetricCard
|
||||
tone="groups"
|
||||
label="Активные группы"
|
||||
value={ready ? String(reference.groups.length) : '—'}
|
||||
>
|
||||
<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" />
|
||||
</MetricCard>
|
||||
<MetricCard
|
||||
tone="students"
|
||||
label="Всего студентов"
|
||||
value={ready ? String(totalStudents(reference.groups)) : '—'}
|
||||
>
|
||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M22 21v-2a4 4 0 0 0-3-3.87" />
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||
</MetricCard>
|
||||
<MetricCard
|
||||
tone="teachers"
|
||||
label="Преподаватели"
|
||||
value={ready ? String(reference.teachers.length) : '—'}
|
||||
>
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="12" cy="7" r="4" />
|
||||
</MetricCard>
|
||||
<MetricCard
|
||||
tone="classrooms"
|
||||
label="Загрузка аудиторий сегодня"
|
||||
value={ready ? classroomOccupancyLabel(reference.classrooms, todayLessons) : '—'}
|
||||
>
|
||||
<rect width="18" height="18" x="3" y="3" rx="2" />
|
||||
<path d="M9 3v18M15 3v18M3 9h18M3 15h18" />
|
||||
</MetricCard>
|
||||
</div>
|
||||
|
||||
<div className="dashboard-middle">
|
||||
<div className="card dashboard-monitor-card">
|
||||
<div className="dashboard-card-header">
|
||||
<h2>Мониторинг пар (Идет сейчас)</h2>
|
||||
<div className="dashboard-time-slot">
|
||||
{ready ? currentSlotLabel(currentSlot) : '—'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="table-wrap dashboard-monitor-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Аудитория</th>
|
||||
<th>Дисциплина</th>
|
||||
<th>Преподаватель</th>
|
||||
<th>Группа</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{!ready ? (
|
||||
<tr><td colSpan="4" className="loading-row dashboard-state-row dashboard-state-row--wide">Определение текущего времени...</td></tr>
|
||||
) : !currentSlot ? (
|
||||
<tr><td colSpan="4" className="loading-row dashboard-state-row">В данный момент учебные пары не проводятся</td></tr>
|
||||
) : currentLessons.length === 0 ? (
|
||||
<tr><td colSpan="4" className="loading-row dashboard-state-row">В текущий слот занятий не запланировано</td></tr>
|
||||
) : currentLessons.map(lesson => (
|
||||
<tr key={`${lesson.classroomId}-${lesson.subjectId}-${lesson.teacherId}-${lesson.groupNames.join(',')}`}>
|
||||
<td><strong>{lesson.classroomName}</strong></td>
|
||||
<td>
|
||||
{lesson.subjectName}{' '}
|
||||
<span className="badge badge-ef badge--lesson-type">{lesson.lessonTypeName}</span>
|
||||
</td>
|
||||
<td>{lesson.teacherName}</td>
|
||||
<td><span className="badge badge-available">{lesson.groupNames.join(', ')}</span></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="dashboard-side-stack">
|
||||
<div className="card dashboard-compact-card">
|
||||
<h3 className="dashboard-card-title">Свободные аудитории (ближайшие)</h3>
|
||||
<div className="dashboard-badge-list" tabIndex="0" role="region" aria-label="Свободные аудитории">
|
||||
{!ready ? (
|
||||
<div className="dashboard-free-hint">Загрузка...</div>
|
||||
) : freeRooms.length === 0 ? (
|
||||
<span className="dashboard-free-busy">Все аудитории заняты</span>
|
||||
) : freeRooms.map(classroom => (
|
||||
<span className="badge badge-available dashboard-free-badge" key={classroom.id}>
|
||||
{classroom.name} ({classroom.capacity} м.)
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card dashboard-compact-card">
|
||||
<h3 className="dashboard-card-title">Быстрый переход</h3>
|
||||
<div className="dashboard-actions">
|
||||
<a href="#schedule" className="btn btn-md btn-primary dashboard-action-link">
|
||||
<svg width="18" height="18" 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"></rect>
|
||||
<line x1="16" y1="2" x2="16" y2="6"></line>
|
||||
<line x1="8" y1="2" x2="8" y2="6"></line>
|
||||
<line x1="3" y1="10" x2="21" y2="10"></line>
|
||||
</svg>
|
||||
Конструктор расписания
|
||||
</a>
|
||||
<a href="#schedule-view" className="btn btn-md btn-secondary dashboard-action-link">
|
||||
<svg width="18" height="18" 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>
|
||||
Точечные изменения / Просмотр
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card red-zone-card">
|
||||
<div className="red-zone-header">
|
||||
<div>
|
||||
<h2 className="red-zone-title">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ef4444" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polygon points="7.86 2 16.14 2 22 7.86 22 16.14 16.14 22 7.86 22 2 16.14 2 7.86 7.86 2" />
|
||||
<line x1="12" y1="8" x2="12" y2="12" />
|
||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
Центр предупреждений (Red Zone)
|
||||
</h2>
|
||||
<div className="red-zone-subtitle">Анализ накладок и коллизий расписания на текущую неделю</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-secondary"
|
||||
id="btn-recheck-conflicts"
|
||||
disabled={recheckBusy}
|
||||
aria-busy={recheckBusy ? 'true' : undefined}
|
||||
onClick={() => void recheck()}
|
||||
>
|
||||
{recheckBusy ? 'Проверка…' : 'Перепроверить'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="red-zone-conflicts"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
aria-busy={conflictPhase === 'checking' ? 'true' : undefined}
|
||||
>
|
||||
{conflictPhase === 'checking' ? (
|
||||
<div className="dashboard-check-loading">Анализ расписания и выявление конфликтов...</div>
|
||||
) : conflictPhase === 'error' ? (
|
||||
<CheckStateCard
|
||||
tone="error"
|
||||
title="Проверка не выполнена"
|
||||
description={dataPhase === 'error'
|
||||
? 'Не удалось загрузить данные панели. Нажмите «Перепроверить», чтобы повторить попытку.'
|
||||
: 'Во время анализа произошла ошибка. Повторите проверку позже.'}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{conflicts.presentation && (
|
||||
<CheckStateCard
|
||||
tone={conflicts.presentation.tone}
|
||||
title={conflicts.presentation.title}
|
||||
description={conflicts.presentation.description}
|
||||
meta={conflicts.presentation.meta}
|
||||
/>
|
||||
)}
|
||||
{conflicts.items.map((conflict, index) => (
|
||||
<ConflictCard conflict={conflict} key={`${conflict.type}-${index}`} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricCard({ tone, label, value, children }) {
|
||||
return (
|
||||
<div className={`card dashboard-metric-card dashboard-metric-card--${tone}`}>
|
||||
<div className="metric-icon">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
{children}
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div className="dashboard-metric-label">{label}</div>
|
||||
<h3 className="dashboard-metric-value">{value}</h3>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckStateCard({ tone, title, description, meta = '' }) {
|
||||
const icon = tone === 'success'
|
||||
? <path d="M20 6 9 17l-5-5" />
|
||||
: tone === 'warning'
|
||||
? (
|
||||
<>
|
||||
<path d="M12 9v4m0 4h.01" />
|
||||
<path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0Z" />
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M12 8v5m0 3h.01" />
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<section className={`dashboard-check-state dashboard-check-state--${tone}`}>
|
||||
<span className="dashboard-check-state__icon" aria-hidden="true">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round">{icon}</svg>
|
||||
</span>
|
||||
<span className="dashboard-check-state__body">
|
||||
<strong>{title}</strong>
|
||||
<span>{description}</span>
|
||||
{meta ? <small>{meta}</small> : null}
|
||||
</span>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ConflictCard({ conflict }) {
|
||||
const isError = conflict.severity === 'error';
|
||||
return (
|
||||
<div className={`dashboard-conflict-card dashboard-conflict-card--${isError ? 'error' : 'warning'}`}>
|
||||
<div className="dashboard-conflict-card__icon">
|
||||
{isError ? (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#ef4444" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10" /><line x1="15" y1="9" x2="9" y2="15" /><line x1="9" y1="9" x2="15" y2="15" /></svg>
|
||||
) : (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#f59e0b" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" /><line x1="12" y1="9" x2="12" y2="13" /><line x1="12" y1="17" x2="12.01" y2="17" /></svg>
|
||||
)}
|
||||
</div>
|
||||
<div className="dashboard-conflict-card__body">
|
||||
<h4>{conflict.title}</h4>
|
||||
<p>
|
||||
{(conflict.segments || []).map((segment, index) => (
|
||||
typeof segment === 'string'
|
||||
? segment
|
||||
: <strong key={index}>{segment.strong}</strong>
|
||||
))}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
774
frontend/react/admin/tabs/DepartmentWorkspaceTab.jsx
Normal file
774
frontend/react/admin/tabs/DepartmentWorkspaceTab.jsx
Normal file
@@ -0,0 +1,774 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { getSession } from '../../../admin/js/api.js';
|
||||
import { addDays, toIsoDate } from '../../shared/dates.js';
|
||||
import { requestCountLabel } from '../admin-service.js';
|
||||
import {
|
||||
DEPARTMENT_REQUEST_TABLE_COLUMNS,
|
||||
SUBJECT_TABLE_COLUMNS,
|
||||
assignTeacher,
|
||||
assignTeacherError,
|
||||
availableTeachers,
|
||||
defaultWorkloadPeriod,
|
||||
departmentOptionLabel,
|
||||
fetchAllTeachers,
|
||||
fetchDepartmentSubjects,
|
||||
fetchDepartmentTeacherRequests,
|
||||
fetchDepartmentTeachers,
|
||||
fetchDepartments,
|
||||
fetchSemesters,
|
||||
fetchSubjectComments,
|
||||
fetchTeacherWorkload,
|
||||
formatDateTime,
|
||||
importSubjects,
|
||||
importSubjectsPayload,
|
||||
requestStatusClass,
|
||||
requestStatusLabel,
|
||||
saveSubjectComment,
|
||||
subjectCountLabel,
|
||||
submitTeacherRequest,
|
||||
teacherMetrics,
|
||||
teacherOptionLabel,
|
||||
teacherRequestPayload,
|
||||
workloadLabel
|
||||
} from '../department-workspace-service.js';
|
||||
|
||||
const ROLE_DEPARTMENT = 'DEPARTMENT';
|
||||
|
||||
export function DepartmentWorkspaceTab() {
|
||||
const [session] = useState(() => getSession());
|
||||
const role = session?.role;
|
||||
const userDepartmentId = session?.departmentId;
|
||||
const isDepartmentRole = role === ROLE_DEPARTMENT;
|
||||
|
||||
const departmentIdRef = useRef('');
|
||||
const startDateRef = useRef(toIsoDate(new Date()));
|
||||
const endDateRef = useRef(toIsoDate(addDays(new Date(), 7)));
|
||||
|
||||
const [departments, setDepartments] = useState([]);
|
||||
const [departmentId, setDepartmentId] = useState('');
|
||||
const [startDate, setStartDate] = useState(startDateRef.current);
|
||||
const [endDate, setEndDate] = useState(endDateRef.current);
|
||||
const [workspaceAlert, setWorkspaceAlert] = useState({ text: '', isError: false });
|
||||
|
||||
const [subjects, setSubjects] = useState({ loading: true, items: [], error: '' });
|
||||
const [teachers, setTeachers] = useState({ items: null, error: '' });
|
||||
const [workload, setWorkload] = useState({ items: null, error: '' });
|
||||
const [requests, setRequests] = useState({ items: null, error: '' });
|
||||
const [allTeachers, setAllTeachers] = useState([]);
|
||||
const [allTeachersError, setAllTeachersError] = useState('');
|
||||
|
||||
const [importRows, setImportRows] = useState([{ code: '', name: '' }]);
|
||||
const [importAlert, setImportAlert] = useState({ text: '', isError: false });
|
||||
|
||||
const [assignTeacherId, setAssignTeacherId] = useState('');
|
||||
const [assignComment, setAssignComment] = useState('');
|
||||
const [assignAlert, setAssignAlert] = useState({ text: '', isError: false });
|
||||
|
||||
const [requestForm, setRequestForm] = useState({ username: '', fullName: '', jobTitle: '', comment: '' });
|
||||
const [requestAlert, setRequestAlert] = useState({ text: '', isError: false });
|
||||
|
||||
const [comments, setComments] = useState(null);
|
||||
const [commentText, setCommentText] = useState('');
|
||||
const [commentAlert, setCommentAlert] = useState({ text: '', isError: false });
|
||||
|
||||
function currentDepartmentId() {
|
||||
return isDepartmentRole ? userDepartmentId : departmentIdRef.current;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
(async () => {
|
||||
try {
|
||||
const loaded = await fetchDepartments();
|
||||
if (!active) return;
|
||||
const list = loaded || [];
|
||||
setDepartments(list);
|
||||
if (isDepartmentRole) {
|
||||
departmentIdRef.current = userDepartmentId || '';
|
||||
setDepartmentId(departmentIdRef.current);
|
||||
} else if (list.length) {
|
||||
departmentIdRef.current = String(list[0].id);
|
||||
setDepartmentId(departmentIdRef.current);
|
||||
}
|
||||
try {
|
||||
const semesters = await fetchSemesters();
|
||||
const period = defaultWorkloadPeriod(semesters, new Date());
|
||||
startDateRef.current = period.startDate;
|
||||
endDateRef.current = period.endDate;
|
||||
if (active) {
|
||||
setStartDate(period.startDate);
|
||||
setEndDate(period.endDate);
|
||||
}
|
||||
} catch {
|
||||
// Текущий диапазон остаётся безопасным fallback, если справочник
|
||||
// семестров недоступен.
|
||||
}
|
||||
try {
|
||||
const loadedTeachers = await fetchAllTeachers();
|
||||
if (active) setAllTeachers(Array.isArray(loadedTeachers) ? loadedTeachers : []);
|
||||
} catch (error) {
|
||||
if (active) setAllTeachersError(error.message || 'Ошибка загрузки преподавателей');
|
||||
}
|
||||
if (!active) return;
|
||||
await loadWorkspace();
|
||||
} catch (error) {
|
||||
if (!active) return;
|
||||
setWorkspaceAlert({ text: error.message || 'Ошибка загрузки кабинета кафедры', isError: true });
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function loadWorkspace() {
|
||||
setWorkspaceAlert({ text: '', isError: false });
|
||||
setTeachers({ items: null, error: '' });
|
||||
setWorkload({ items: null, error: '' });
|
||||
setRequests({ items: null, error: '' });
|
||||
await Promise.all([loadSubjects(), loadTeachers(), loadWorkload(), loadTeacherRequests()]);
|
||||
}
|
||||
|
||||
async function loadSubjects() {
|
||||
setSubjects({ loading: true, items: [], error: '' });
|
||||
try {
|
||||
const items = await fetchDepartmentSubjects(currentDepartmentId());
|
||||
setSubjects({ loading: false, items: Array.isArray(items) ? items : [], error: '' });
|
||||
} catch (error) {
|
||||
setSubjects({ loading: false, items: [], error: error.message || 'Ошибка загрузки дисциплин' });
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTeachers() {
|
||||
setTeachers({ items: null, error: '' });
|
||||
try {
|
||||
const items = await fetchDepartmentTeachers(currentDepartmentId());
|
||||
setTeachers({ items: Array.isArray(items) ? items : [], error: '' });
|
||||
} catch (error) {
|
||||
setTeachers({ items: [], error: error.message || 'Ошибка загрузки преподавателей' });
|
||||
}
|
||||
}
|
||||
|
||||
async function loadWorkload() {
|
||||
if (!startDateRef.current || !endDateRef.current) {
|
||||
setWorkload({ items: [], error: 'Выберите начало и окончание периода' });
|
||||
return;
|
||||
}
|
||||
setWorkload({ items: null, error: '' });
|
||||
try {
|
||||
const items = await fetchTeacherWorkload({
|
||||
startDate: startDateRef.current,
|
||||
endDate: endDateRef.current,
|
||||
departmentId: currentDepartmentId()
|
||||
});
|
||||
setWorkload({ items: Array.isArray(items) ? items : [], error: '' });
|
||||
} catch (error) {
|
||||
setWorkload({ items: [], error: error.message || 'Ошибка расчёта нагрузки' });
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTeacherRequests() {
|
||||
setRequests({ items: null, error: '' });
|
||||
try {
|
||||
const items = await fetchDepartmentTeacherRequests(currentDepartmentId());
|
||||
setRequests({ items: Array.isArray(items) ? items : [], error: '' });
|
||||
} catch (error) {
|
||||
setRequests({ items: [], error: error.message || 'Ошибка загрузки заявок' });
|
||||
}
|
||||
}
|
||||
|
||||
function handleDepartmentChange(value) {
|
||||
departmentIdRef.current = value;
|
||||
setDepartmentId(value);
|
||||
void loadWorkspace();
|
||||
}
|
||||
|
||||
function handleStartDateChange(value) {
|
||||
startDateRef.current = value;
|
||||
setStartDate(value);
|
||||
if (value && endDateRef.current && value > endDateRef.current) {
|
||||
endDateRef.current = '';
|
||||
setEndDate('');
|
||||
}
|
||||
void loadWorkload();
|
||||
}
|
||||
|
||||
function handleEndDateChange(value) {
|
||||
endDateRef.current = value;
|
||||
setEndDate(value);
|
||||
void loadWorkload();
|
||||
}
|
||||
|
||||
// --- Загрузка дисциплин ---
|
||||
|
||||
function addImportRow() {
|
||||
setImportRows(current => [...current, { code: '', name: '' }]);
|
||||
}
|
||||
|
||||
function updateImportRow(index, patch) {
|
||||
setImportRows(current => current.map((row, rowIndex) => (
|
||||
rowIndex === index ? { ...row, ...patch } : row
|
||||
)));
|
||||
}
|
||||
|
||||
function removeImportRow(index) {
|
||||
setImportRows(current => {
|
||||
const rows = current.filter((_, rowIndex) => rowIndex !== index);
|
||||
return rows.length ? rows : [{ code: '', name: '' }];
|
||||
});
|
||||
}
|
||||
|
||||
async function submitImport(event) {
|
||||
event.preventDefault();
|
||||
setImportAlert({ text: '', isError: false });
|
||||
const rows = importRows.map(row => ({
|
||||
code: (row.code || '').trim(),
|
||||
name: (row.name || '').trim()
|
||||
}));
|
||||
const { payload, error } = importSubjectsPayload(rows, currentDepartmentId());
|
||||
if (error) {
|
||||
setImportAlert({ text: error, isError: true });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await importSubjects(payload);
|
||||
setImportAlert({ text: result?.message || 'Дисциплины загружены', isError: false });
|
||||
setImportRows([{ code: '', name: '' }]);
|
||||
await loadSubjects();
|
||||
} catch (err) {
|
||||
setImportAlert({ text: err.message || 'Ошибка загрузки дисциплин', isError: true });
|
||||
}
|
||||
}
|
||||
|
||||
// --- Привязка преподавателя ---
|
||||
|
||||
async function submitAssign(event) {
|
||||
event.preventDefault();
|
||||
setAssignAlert({ text: '', isError: false });
|
||||
const error = assignTeacherError(currentDepartmentId(), assignTeacherId);
|
||||
if (error) {
|
||||
setAssignAlert({ text: error, isError: true });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await assignTeacher(assignTeacherId, currentDepartmentId(), assignComment.trim());
|
||||
setAssignAlert({ text: 'Преподаватель привязан к кафедре', isError: false });
|
||||
setAssignComment('');
|
||||
await Promise.all([loadTeachers(), loadWorkload()]);
|
||||
} catch (err) {
|
||||
setAssignAlert({ text: err.message || 'Ошибка привязки преподавателя', isError: true });
|
||||
}
|
||||
}
|
||||
|
||||
// --- Заявка на преподавателя ---
|
||||
|
||||
async function submitRequest(event) {
|
||||
event.preventDefault();
|
||||
setRequestAlert({ text: '', isError: false });
|
||||
const { payload, error } = teacherRequestPayload(requestForm, currentDepartmentId());
|
||||
if (error) {
|
||||
setRequestAlert({ text: error, isError: true });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await submitTeacherRequest(payload);
|
||||
setRequestAlert({ text: 'Заявка отправлена администратору', isError: false });
|
||||
setRequestForm({ username: '', fullName: '', jobTitle: '', comment: '' });
|
||||
await loadTeacherRequests();
|
||||
} catch (err) {
|
||||
setRequestAlert({ text: err.message || 'Ошибка отправки заявки', isError: true });
|
||||
}
|
||||
}
|
||||
|
||||
// --- Комментарии к дисциплинам ---
|
||||
|
||||
async function openComments(subjectId, subjectName) {
|
||||
setComments({ subjectId, subjectName, items: null, error: '' });
|
||||
setCommentText('');
|
||||
setCommentAlert({ text: '', isError: false });
|
||||
try {
|
||||
const items = await fetchSubjectComments(subjectId);
|
||||
setComments(current => (
|
||||
current && current.subjectId === subjectId
|
||||
? { ...current, items: Array.isArray(items) ? items : [], error: '' }
|
||||
: current
|
||||
));
|
||||
} catch (error) {
|
||||
setComments(current => (
|
||||
current && current.subjectId === subjectId
|
||||
? { ...current, items: [], error: error.message || 'Ошибка загрузки комментариев' }
|
||||
: current
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
async function submitComment(event) {
|
||||
event.preventDefault();
|
||||
const subjectId = comments?.subjectId;
|
||||
const comment = commentText.trim();
|
||||
if (!subjectId) {
|
||||
setCommentAlert({ text: 'Выберите дисциплину', isError: true });
|
||||
return;
|
||||
}
|
||||
if (!comment) {
|
||||
setCommentAlert({ text: 'Комментарий не должен быть пустым', isError: true });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await saveSubjectComment(subjectId, comment);
|
||||
setCommentAlert({ text: 'Комментарий сохранён', isError: false });
|
||||
await openComments(subjectId, comments.subjectName);
|
||||
} catch (error) {
|
||||
setCommentAlert({ text: error.message || 'Ошибка сохранения комментария', isError: true });
|
||||
}
|
||||
}
|
||||
|
||||
const metricRows = teachers.items === null || workload.items === null
|
||||
? null
|
||||
: teacherMetrics(teachers.items, workload.items);
|
||||
const freeTeachers = teachers.items === null
|
||||
? null
|
||||
: availableTeachers(allTeachers, teachers.items);
|
||||
const requestsCountLabel = requests.error
|
||||
? '0 заявок'
|
||||
: (requests.items === null ? 'Загрузка...' : requestCountLabel(requests.items.length));
|
||||
const subjectsCountLabel = subjects.error
|
||||
? '0 дисциплин'
|
||||
: (subjects.loading ? 'Загрузка...' : subjectCountLabel(subjects.items.length));
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="card department-workspace-control-card">
|
||||
<div className="card-header-row">
|
||||
<h2>Рабочая область кафедры</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-md btn-secondary"
|
||||
id="department-workspace-refresh"
|
||||
onClick={() => void loadWorkspace()}
|
||||
>
|
||||
Обновить
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="department-workspace-filter-grid">
|
||||
{!isDepartmentRole && (
|
||||
<div className="form-group" id="department-workspace-department-field">
|
||||
<label htmlFor="department-workspace-department">Кафедра</label>
|
||||
<select
|
||||
id="department-workspace-department"
|
||||
value={departmentId}
|
||||
onChange={event => handleDepartmentChange(event.target.value)}
|
||||
>
|
||||
<option value="">Выберите кафедру</option>
|
||||
{departments.map(department => (
|
||||
<option key={department.id} value={department.id}>
|
||||
{departmentOptionLabel(department)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div className="form-group">
|
||||
<label htmlFor="department-workspace-start">Начало периода</label>
|
||||
<input
|
||||
type="date"
|
||||
id="department-workspace-start"
|
||||
value={startDate}
|
||||
onChange={event => handleStartDateChange(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="department-workspace-end">Окончание периода</label>
|
||||
<input
|
||||
type="date"
|
||||
id="department-workspace-end"
|
||||
value={endDate}
|
||||
onChange={event => handleEndDateChange(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`form-alert${workspaceAlert.text ? (workspaceAlert.isError ? ' error' : ' success') : ''}`}
|
||||
id="department-workspace-alert"
|
||||
role="alert"
|
||||
>
|
||||
{workspaceAlert.text}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="department-workspace-grid department-workspace-grid-wide department-workspace-actions-grid">
|
||||
<div className="card create-card department-subject-import-card">
|
||||
<div className="card-header-row">
|
||||
<h2>Загрузка дисциплин</h2>
|
||||
</div>
|
||||
<form id="department-subject-import-form" className="stack-form" onSubmit={submitImport}>
|
||||
<div className="subject-import-slots-panel">
|
||||
<div className="card-header-row">
|
||||
<label className="schedule-section-label schedule-section-label--flush">
|
||||
Дисциплины для загрузки
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-secondary"
|
||||
id="subject-import-add"
|
||||
onClick={addImportRow}
|
||||
>
|
||||
Добавить строку
|
||||
</button>
|
||||
</div>
|
||||
<div id="subject-import-rows" className="subject-import-rows-list">
|
||||
{importRows.map((row, index) => (
|
||||
<div className="subject-import-row" key={index}>
|
||||
<div className="form-group">
|
||||
<label>Код</label>
|
||||
<input
|
||||
type="text"
|
||||
className="subject-import-code"
|
||||
placeholder="DB201"
|
||||
value={row.code}
|
||||
onChange={event => updateImportRow(index, { code: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Название</label>
|
||||
<input
|
||||
type="text"
|
||||
className="subject-import-name"
|
||||
placeholder="Базы данных"
|
||||
value={row.name}
|
||||
onChange={event => updateImportRow(index, { name: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-danger subject-import-remove"
|
||||
onClick={() => removeImportRow(index)}
|
||||
>
|
||||
Удалить
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" className="btn btn-md btn-secondary">Загрузить</button>
|
||||
<div
|
||||
className={`form-alert${importAlert.text ? (importAlert.isError ? ' error' : ' success') : ''}`}
|
||||
id="department-subject-import-alert"
|
||||
role="alert"
|
||||
>
|
||||
{importAlert.text}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="card create-card department-teacher-assign-card">
|
||||
<div className="card-header-row">
|
||||
<h2>Добавить преподавателя</h2>
|
||||
</div>
|
||||
<form id="department-teacher-assign-form" className="stack-form" onSubmit={submitAssign}>
|
||||
<div className="form-group">
|
||||
<label htmlFor="department-teacher-select">Преподаватель</label>
|
||||
<select
|
||||
id="department-teacher-select"
|
||||
value={assignTeacherId}
|
||||
onChange={event => setAssignTeacherId(event.target.value)}
|
||||
>
|
||||
{freeTeachers === null ? (
|
||||
<option value="">Загрузка...</option>
|
||||
) : allTeachersError ? (
|
||||
<option value="">{allTeachersError}</option>
|
||||
) : freeTeachers.length === 0 ? (
|
||||
<option value="">Нет доступных преподавателей</option>
|
||||
) : (
|
||||
<>
|
||||
<option value="">Выберите преподавателя</option>
|
||||
{freeTeachers.map(teacher => (
|
||||
<option key={teacher.id} value={teacher.id}>
|
||||
{teacherOptionLabel(teacher)}
|
||||
</option>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="department-teacher-assign-comment">Комментарий</label>
|
||||
<input
|
||||
type="text"
|
||||
id="department-teacher-assign-comment"
|
||||
placeholder="Например, ведет лабораторные работы"
|
||||
value={assignComment}
|
||||
onChange={event => setAssignComment(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="btn btn-md btn-secondary">Привязать к кафедре</button>
|
||||
<div
|
||||
className={`form-alert${assignAlert.text ? (assignAlert.isError ? ' error' : ' success') : ''}`}
|
||||
id="department-teacher-assign-alert"
|
||||
role="alert"
|
||||
>
|
||||
{assignAlert.text}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="card create-card department-teacher-request-card">
|
||||
<div className="card-header-row">
|
||||
<h2>Заявка на преподавателя</h2>
|
||||
</div>
|
||||
<form id="department-teacher-request-form" className="stack-form" onSubmit={submitRequest}>
|
||||
<div className="form-group">
|
||||
<label htmlFor="teacher-request-username">Логин</label>
|
||||
<input
|
||||
type="text"
|
||||
id="teacher-request-username"
|
||||
placeholder="ivanov_teacher"
|
||||
maxLength={50}
|
||||
required
|
||||
value={requestForm.username}
|
||||
onChange={event => setRequestForm(current => (
|
||||
{ ...current, username: event.target.value }
|
||||
))}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="teacher-request-fullname">ФИО</label>
|
||||
<input
|
||||
type="text"
|
||||
id="teacher-request-fullname"
|
||||
placeholder="Иванов Иван Иванович"
|
||||
maxLength={255}
|
||||
required
|
||||
value={requestForm.fullName}
|
||||
onChange={event => setRequestForm(current => (
|
||||
{ ...current, fullName: event.target.value }
|
||||
))}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="teacher-request-jobtitle">Должность</label>
|
||||
<input
|
||||
type="text"
|
||||
id="teacher-request-jobtitle"
|
||||
placeholder="Доцент"
|
||||
maxLength={255}
|
||||
value={requestForm.jobTitle}
|
||||
onChange={event => setRequestForm(current => (
|
||||
{ ...current, jobTitle: event.target.value }
|
||||
))}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="teacher-request-comment">Комментарий</label>
|
||||
<input
|
||||
type="text"
|
||||
id="teacher-request-comment"
|
||||
placeholder="Что важно проверить администратору"
|
||||
value={requestForm.comment}
|
||||
onChange={event => setRequestForm(current => (
|
||||
{ ...current, comment: event.target.value }
|
||||
))}
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="btn btn-md btn-primary">Отправить заявку</button>
|
||||
<div
|
||||
className={`form-alert${requestAlert.text ? (requestAlert.isError ? ' error' : ' success') : ''}`}
|
||||
id="department-teacher-request-alert"
|
||||
role="alert"
|
||||
>
|
||||
{requestAlert.text}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="card department-teachers-card">
|
||||
<div className="card-header-row">
|
||||
<h2>Преподаватели и нагрузка</h2>
|
||||
</div>
|
||||
<div className="metric-grid" id="department-teachers-list">
|
||||
{metricRows === null ? (
|
||||
'Загрузка...'
|
||||
) : metricRows.length === 0 ? (
|
||||
<div className="loading-row">
|
||||
{teachers.error || 'Преподаватели не найдены'}
|
||||
</div>
|
||||
) : metricRows.map((row, index) => (
|
||||
<article className="metric-card" key={index}>
|
||||
<strong>{row.name || '-'}</strong>
|
||||
<span>{row.meta || 'Должность не указана'}</span>
|
||||
<small>{workloadLabel(row.workload, workload.error)}</small>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card department-workspace-table-card">
|
||||
<div className="card-header-row">
|
||||
<h2>Заявки кафедры</h2>
|
||||
<span className="muted" id="department-teacher-request-count">{requestsCountLabel}</span>
|
||||
</div>
|
||||
<div className="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Статус</th>
|
||||
<th>Логин</th>
|
||||
<th>ФИО</th>
|
||||
<th>Должность</th>
|
||||
<th>Комментарий</th>
|
||||
<th>Рассмотрение</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="department-teacher-requests-tbody">
|
||||
{requests.items === null && !requests.error ? (
|
||||
<tr>
|
||||
<td colSpan={DEPARTMENT_REQUEST_TABLE_COLUMNS} className="loading-row">
|
||||
Загрузка...
|
||||
</td>
|
||||
</tr>
|
||||
) : requests.error ? (
|
||||
<tr>
|
||||
<td colSpan={DEPARTMENT_REQUEST_TABLE_COLUMNS} className="loading-row">
|
||||
{requests.error}
|
||||
</td>
|
||||
</tr>
|
||||
) : requests.items.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={DEPARTMENT_REQUEST_TABLE_COLUMNS} className="loading-row">
|
||||
Заявок пока нет
|
||||
</td>
|
||||
</tr>
|
||||
) : requests.items.map(request => (
|
||||
<tr key={request.id}>
|
||||
<td>
|
||||
<span className={`badge ${requestStatusClass(request.status)}`}>
|
||||
{requestStatusLabel(request.status)}
|
||||
</span>
|
||||
</td>
|
||||
<td>{request.username || '-'}</td>
|
||||
<td>{request.fullName || '-'}</td>
|
||||
<td>{request.jobTitle || '-'}</td>
|
||||
<td>{request.comment || '-'}</td>
|
||||
<td>{request.reviewComment || formatDateTime(request.reviewedAt) || '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card department-workspace-table-card">
|
||||
<div className="card-header-row">
|
||||
<h2>Дисциплины кафедры</h2>
|
||||
<span className="muted" id="department-subject-count">{subjectsCountLabel}</span>
|
||||
</div>
|
||||
<div className="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Код</th>
|
||||
<th>Название</th>
|
||||
<th>Описание</th>
|
||||
<th>Комментарии</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="department-subjects-tbody">
|
||||
{subjects.loading ? (
|
||||
<tr>
|
||||
<td colSpan={SUBJECT_TABLE_COLUMNS} className="loading-row">
|
||||
Загрузка...
|
||||
</td>
|
||||
</tr>
|
||||
) : subjects.error ? (
|
||||
<tr>
|
||||
<td colSpan={SUBJECT_TABLE_COLUMNS} className="loading-row">
|
||||
{subjects.error}
|
||||
</td>
|
||||
</tr>
|
||||
) : subjects.items.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={SUBJECT_TABLE_COLUMNS} className="loading-row">
|
||||
Дисциплины не найдены
|
||||
</td>
|
||||
</tr>
|
||||
) : subjects.items.map(subject => (
|
||||
<tr key={subject.id}>
|
||||
<td>{subject.code || '-'}</td>
|
||||
<td>{subject.name || '-'}</td>
|
||||
<td>{subject.description || '-'}</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-secondary department-comments-open"
|
||||
onClick={() => void openComments(subject.id, subject.name || '')}
|
||||
>
|
||||
Открыть
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{comments && (
|
||||
<div className="card department-workspace-table-card" id="department-comments-card">
|
||||
<div className="card-header-row">
|
||||
<h2 id="department-comments-title">
|
||||
Комментарии: {comments.subjectName || 'дисциплина'}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-ghost"
|
||||
id="department-comments-close"
|
||||
onClick={() => setComments(null)}
|
||||
>
|
||||
Скрыть
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form id="department-comment-form" className="form-row" onSubmit={submitComment}>
|
||||
<div className="form-group">
|
||||
<label htmlFor="department-comment-text">Новый комментарий</label>
|
||||
<input
|
||||
type="text"
|
||||
id="department-comment-text"
|
||||
placeholder="Добавьте примечание по дисциплине"
|
||||
value={commentText}
|
||||
onChange={event => setCommentText(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="btn btn-md btn-primary">Сохранить</button>
|
||||
</form>
|
||||
<div
|
||||
className={`form-alert${commentAlert.text ? (commentAlert.isError ? ' error' : ' success') : ''}`}
|
||||
id="department-comment-alert"
|
||||
role="alert"
|
||||
>
|
||||
{commentAlert.text}
|
||||
</div>
|
||||
<div className="department-comment-list" id="department-comment-list">
|
||||
{comments.items === null ? (
|
||||
'Загрузка...'
|
||||
) : comments.error ? (
|
||||
<div className="loading-row">{comments.error}</div>
|
||||
) : comments.items.length === 0 ? (
|
||||
<div className="loading-row">Комментариев пока нет</div>
|
||||
) : comments.items.map((comment, index) => (
|
||||
<article className="department-comment-item" key={index}>
|
||||
<strong>{comment.authorName || 'Автор не указан'}</strong>
|
||||
<span>{formatDateTime(comment.createdAt)}</span>
|
||||
<p>{comment.comment}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
1279
frontend/react/admin/tabs/GroupsTab.jsx
Normal file
1279
frontend/react/admin/tabs/GroupsTab.jsx
Normal file
File diff suppressed because it is too large
Load Diff
468
frontend/react/admin/tabs/ScheduleQualityTab.jsx
Normal file
468
frontend/react/admin/tabs/ScheduleQualityTab.jsx
Normal file
@@ -0,0 +1,468 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
academicYearsFromSemesters,
|
||||
resolveAcademicPeriod,
|
||||
semestersForAcademicYear
|
||||
} from '../../../admin/js/academic-period.js';
|
||||
import { trackUxEvent } from '../../../telemetry.js';
|
||||
import {
|
||||
QUALITY_SEMESTER_STORAGE_KEY,
|
||||
QUALITY_VERSION_STORAGE_KEY,
|
||||
QUALITY_YEAR_STORAGE_KEY,
|
||||
SCHEDULE_OPEN_RULE_STORAGE_KEY,
|
||||
SCHEDULE_OPEN_VERSION_STORAGE_KEY,
|
||||
SCHEDULE_SEMESTER_STORAGE_KEY
|
||||
} from '../schedule-versions-service.js';
|
||||
import {
|
||||
fetchScheduleQualityAnalysis,
|
||||
fetchScheduleSemesters,
|
||||
fetchScheduleVersions,
|
||||
filterQualityProblems,
|
||||
formatLocalDate,
|
||||
formatMetricValue,
|
||||
formatQualityDate,
|
||||
lessonWord,
|
||||
problemTotalLabel,
|
||||
qualityBand,
|
||||
qualitySemesterLabel,
|
||||
severityLabel,
|
||||
versionStatusLabel
|
||||
} from '../schedule-quality-service.js';
|
||||
|
||||
const INITIAL_STATUS = Object.freeze({ message: 'Выберите семестр, чтобы получить объяснимую оценку.', tone: '' });
|
||||
|
||||
export function ScheduleQualityTab() {
|
||||
const [semesters, setSemesters] = useState([]);
|
||||
const [semestersPhase, setSemestersPhase] = useState('loading');
|
||||
const [academicYearId, setAcademicYearId] = useState('');
|
||||
const [semesterId, setSemesterId] = useState('');
|
||||
const [versions, setVersions] = useState([]);
|
||||
const [versionPhase, setVersionPhase] = useState('idle');
|
||||
const [versionId, setVersionId] = useState('');
|
||||
const [analysis, setAnalysis] = useState(null);
|
||||
const [resultsHidden, setResultsHidden] = useState(true);
|
||||
const [analyzing, setAnalyzing] = useState(false);
|
||||
const [status, setStatus] = useState(INITIAL_STATUS);
|
||||
const [severityFilter, setSeverityFilter] = useState('');
|
||||
const [categoryFilter, setCategoryFilter] = useState('');
|
||||
const [selectedProblemId, setSelectedProblemId] = useState(null);
|
||||
|
||||
const latestRef = useRef(null);
|
||||
latestRef.current = { academicYearId, semesterId, versionId };
|
||||
|
||||
const years = useMemo(() => academicYearsFromSemesters(semesters), [semesters]);
|
||||
const yearSemesters = useMemo(
|
||||
() => semestersForAcademicYear(semesters, academicYearId),
|
||||
[semesters, academicYearId]
|
||||
);
|
||||
const visibleProblems = useMemo(
|
||||
() => analysis ? filterQualityProblems(analysis.problems || [], severityFilter, categoryFilter) : [],
|
||||
[analysis, severityFilter, categoryFilter]
|
||||
);
|
||||
const band = analysis ? qualityBand(analysis.score) : null;
|
||||
const selectedProblem = analysis
|
||||
? (analysis.problems || []).find(problem => problem.id === selectedProblemId)
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
async function initialize() {
|
||||
try {
|
||||
const loadedSemesters = await fetchScheduleSemesters();
|
||||
setSemesters(loadedSemesters);
|
||||
if (!loadedSemesters.length) {
|
||||
setSemestersPhase('empty');
|
||||
setStatus({ message: 'Сначала создайте учебный год и семестр.', tone: 'warning' });
|
||||
return;
|
||||
}
|
||||
setSemestersPhase('ready');
|
||||
const selected = resolveAcademicPeriod(loadedSemesters, {
|
||||
academicYearId: window.localStorage.getItem(QUALITY_YEAR_STORAGE_KEY),
|
||||
semesterId: window.localStorage.getItem(QUALITY_SEMESTER_STORAGE_KEY),
|
||||
date: formatLocalDate(new Date())
|
||||
});
|
||||
setAcademicYearId(selected.academicYearId);
|
||||
setSemesterId(selected.semesterId);
|
||||
window.localStorage.setItem(QUALITY_YEAR_STORAGE_KEY, selected.academicYearId);
|
||||
await loadVersions(selected.semesterId);
|
||||
} catch (error) {
|
||||
setSemestersPhase('error');
|
||||
setStatus({ message: error.message || 'Не удалось загрузить семестры.', tone: 'error' });
|
||||
}
|
||||
}
|
||||
void initialize();
|
||||
}, []);
|
||||
|
||||
async function loadVersions(overrideSemesterId) {
|
||||
const nextSemesterId = overrideSemesterId ?? latestRef.current.semesterId;
|
||||
if (!nextSemesterId) return;
|
||||
setVersionPhase('loading');
|
||||
try {
|
||||
const loadedVersions = await fetchScheduleVersions(nextSemesterId);
|
||||
setVersions(loadedVersions);
|
||||
if (!loadedVersions.length) {
|
||||
setVersionPhase('empty');
|
||||
return;
|
||||
}
|
||||
setVersionPhase('ready');
|
||||
const savedVersionId = window.localStorage.getItem(QUALITY_VERSION_STORAGE_KEY);
|
||||
const saved = loadedVersions.find(version => String(version.id) === savedVersionId);
|
||||
const published = loadedVersions.find(version => version.status === 'PUBLISHED');
|
||||
setVersionId(String((saved || published || loadedVersions[0]).id));
|
||||
window.localStorage.setItem(QUALITY_YEAR_STORAGE_KEY, latestRef.current.academicYearId);
|
||||
window.localStorage.setItem(QUALITY_SEMESTER_STORAGE_KEY, nextSemesterId);
|
||||
} catch (error) {
|
||||
setVersionPhase('error');
|
||||
setStatus({ message: error.message || 'Не удалось загрузить версии расписания.', tone: 'error' });
|
||||
}
|
||||
}
|
||||
|
||||
function handleYearChange(event) {
|
||||
const nextYearId = event.target.value || '';
|
||||
const selected = resolveAcademicPeriod(semesters, {
|
||||
academicYearId: nextYearId,
|
||||
date: formatLocalDate(new Date())
|
||||
});
|
||||
setAcademicYearId(nextYearId);
|
||||
setSemesterId(selected.semesterId);
|
||||
window.localStorage.setItem(QUALITY_YEAR_STORAGE_KEY, nextYearId);
|
||||
void loadVersions(selected.semesterId);
|
||||
}
|
||||
|
||||
function handleSemesterChange(event) {
|
||||
const nextSemesterId = event.target.value || '';
|
||||
setSemesterId(nextSemesterId);
|
||||
void loadVersions(nextSemesterId);
|
||||
}
|
||||
|
||||
async function runAnalysis() {
|
||||
const currentSemesterId = latestRef.current.semesterId;
|
||||
const currentVersionId = latestRef.current.versionId;
|
||||
if (!currentSemesterId) {
|
||||
setStatus({ message: 'Выберите семестр для анализа.', tone: 'warning' });
|
||||
return;
|
||||
}
|
||||
if (!currentVersionId) {
|
||||
setStatus({ message: 'Выберите версию расписания для анализа.', tone: 'warning' });
|
||||
return;
|
||||
}
|
||||
window.localStorage.setItem(QUALITY_SEMESTER_STORAGE_KEY, currentSemesterId);
|
||||
window.localStorage.setItem(QUALITY_VERSION_STORAGE_KEY, currentVersionId);
|
||||
setAnalyzing(true);
|
||||
setResultsHidden(true);
|
||||
setStatus({ message: 'Строим фактическое расписание и рассчитываем показатели…', tone: 'loading' });
|
||||
try {
|
||||
const nextAnalysis = await fetchScheduleQualityAnalysis(currentSemesterId, currentVersionId);
|
||||
setAnalysis(nextAnalysis);
|
||||
setSelectedProblemId(null);
|
||||
setResultsHidden(false);
|
||||
trackUxEvent('качество_расписания_проверено', {
|
||||
semesterId: currentSemesterId,
|
||||
versionId: currentVersionId,
|
||||
score: Number(nextAnalysis.score || 0)
|
||||
});
|
||||
setStatus({
|
||||
message: nextAnalysis.scheduleVersionStatus === 'DRAFT'
|
||||
? 'Черновик проверен. Проблемные правила можно открыть в конструкторе до публикации.'
|
||||
: 'Анализ завершён. Проблемные правила можно открыть в конструкторе расписания.',
|
||||
tone: 'success'
|
||||
});
|
||||
} catch (error) {
|
||||
setStatus({ message: error.message || 'Не удалось выполнить анализ качества.', tone: 'error' });
|
||||
} finally {
|
||||
setAnalyzing(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleProblemClick(problem, action) {
|
||||
if (action === 'edit-rule') {
|
||||
openRuleInConstructor(problem);
|
||||
return;
|
||||
}
|
||||
setSelectedProblemId(problem.id);
|
||||
}
|
||||
|
||||
function openRuleInConstructor(problem) {
|
||||
if (!problem?.scheduleRuleId || analysis?.scheduleVersionStatus === 'ARCHIVED') return;
|
||||
window.localStorage.setItem(SCHEDULE_SEMESTER_STORAGE_KEY, String(analysis.semesterId));
|
||||
window.localStorage.setItem(SCHEDULE_OPEN_VERSION_STORAGE_KEY, String(analysis.scheduleVersionId));
|
||||
window.localStorage.setItem(SCHEDULE_OPEN_RULE_STORAGE_KEY, String(problem.scheduleRuleId));
|
||||
window.location.hash = 'schedule';
|
||||
}
|
||||
|
||||
const yearOptions = semestersPhase === 'ready' || semestersPhase === 'empty'
|
||||
? (years.length
|
||||
? years.map(year => <option key={String(year.id)} value={String(year.id)}>{year.title}</option>)
|
||||
: <option value="">Учебные годы не настроены</option>)
|
||||
: <option value="">Загрузка годов…</option>;
|
||||
|
||||
const semesterOptions = semestersPhase === 'error'
|
||||
? <option value="">Ошибка загрузки</option>
|
||||
: (semestersPhase === 'ready' || semestersPhase === 'empty'
|
||||
? (yearSemesters.length
|
||||
? yearSemesters.map(semester => (
|
||||
<option key={String(semester.id)} value={String(semester.id)}>
|
||||
{qualitySemesterLabel(semester)}
|
||||
</option>
|
||||
))
|
||||
: <option value="">Семестры не настроены</option>)
|
||||
: <option value="">Сначала выберите год</option>);
|
||||
|
||||
const versionPlaceholder = {
|
||||
idle: 'Сначала выберите семестр',
|
||||
loading: 'Загрузка версий…',
|
||||
empty: 'Версии не созданы',
|
||||
error: 'Ошибка загрузки'
|
||||
}[versionPhase];
|
||||
const versionOptions = versionPhase === 'ready'
|
||||
? versions.map(version => (
|
||||
<option key={String(version.id)} value={String(version.id)}>
|
||||
v{version.versionNumber} · {version.name} · {versionStatusLabel(version.status)}
|
||||
</option>
|
||||
))
|
||||
: <option value="">{versionPlaceholder}</option>;
|
||||
|
||||
const archived = analysis?.scheduleVersionStatus === 'ARCHIVED';
|
||||
|
||||
return (
|
||||
<section className="quality-lab" aria-labelledby="quality-lab-title">
|
||||
<header className="quality-hero">
|
||||
<div className="quality-hero-copy">
|
||||
<span className="quality-kicker">Диагностика · публикация или черновик</span>
|
||||
<h2 id="quality-lab-title">Качество видно до перестановок</h2>
|
||||
<p>
|
||||
Анализатор находит окна, перегруженные дни, неудачные аудитории и нарушения
|
||||
пожеланий. Все проблемные занятия объединяются по правилу, а их штрафы
|
||||
суммируются. Черновик можно оценить до публикации.
|
||||
</p>
|
||||
</div>
|
||||
<div className="quality-run-panel">
|
||||
<label htmlFor="quality-year">Учебный год</label>
|
||||
<select id="quality-year" value={academicYearId} onChange={handleYearChange}>
|
||||
{yearOptions}
|
||||
</select>
|
||||
<label htmlFor="quality-semester">Семестр</label>
|
||||
<select id="quality-semester" value={semesterId} onChange={handleSemesterChange}>
|
||||
{semesterOptions}
|
||||
</select>
|
||||
<label htmlFor="quality-version">Версия</label>
|
||||
<select id="quality-version" value={versionId} onChange={event => setVersionId(event.target.value)}>
|
||||
{versionOptions}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-md btn-primary"
|
||||
id="quality-run"
|
||||
disabled={analyzing}
|
||||
onClick={() => void runAnalysis()}
|
||||
>
|
||||
{analyzing ? 'Анализируем…' : 'Выполнить анализ'}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div
|
||||
className="quality-status"
|
||||
id="quality-status"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-tone={status.tone || undefined}
|
||||
>
|
||||
{status.message}
|
||||
</div>
|
||||
|
||||
<div className="quality-results" id="quality-results" hidden={resultsHidden}>
|
||||
{analysis ? (
|
||||
<>
|
||||
<section className="quality-scoreboard" aria-label="Итог анализа">
|
||||
<div className="quality-score-orbit" id="quality-score-orbit" data-band={band.code}>
|
||||
<svg className="quality-score-ring" viewBox="0 0 36 36" aria-hidden="true">
|
||||
<circle className="quality-score-ring-track" cx="18" cy="18" r="15.9155"></circle>
|
||||
<circle
|
||||
className="quality-score-ring-value"
|
||||
id="quality-score-ring-value"
|
||||
cx="18"
|
||||
cy="18"
|
||||
r="15.9155"
|
||||
pathLength="100"
|
||||
strokeDasharray={`${Math.max(0, Math.min(100, analysis.score))} 100`}
|
||||
></circle>
|
||||
</svg>
|
||||
<div className="quality-score-inner">
|
||||
<strong id="quality-score">{analysis.score}</strong>
|
||||
<span>из 100</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="quality-score-copy">
|
||||
<span className="quality-score-band" id="quality-score-band" data-band={band.code}>
|
||||
{band.label}
|
||||
</span>
|
||||
<h3 id="quality-semester-label">{analysis.semesterLabel}</h3>
|
||||
<p id="quality-score-summary">{band.summary}</p>
|
||||
<div className="quality-score-meta" id="quality-score-meta">
|
||||
<span>{versionStatusLabel(analysis.scheduleVersionStatus)} · v{analysis.scheduleVersionNumber || '—'}</span>
|
||||
<span>{analysis.lessonCount} занятий</span>
|
||||
<span>правил с проблемами: {analysis.totalProblemCount}</span>
|
||||
<span>штраф {analysis.rawPenalty}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="quality-metrics" id="quality-metrics" aria-label="Метрики качества">
|
||||
{(analysis.metrics || []).map((metric, index) => (
|
||||
<article className={`quality-metric quality-metric-${index + 1}`} key={index}>
|
||||
<span className="quality-metric-index">{String(index + 1).padStart(2, '0')}</span>
|
||||
<strong>{formatMetricValue(metric)}</strong>
|
||||
<h4>{metric.label}</h4>
|
||||
<p>{metric.explanation}</p>
|
||||
<span className="quality-metric-penalty">штраф {metric.penalty}</span>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section className="quality-workbench">
|
||||
<div className="quality-problems-column">
|
||||
<div className="quality-section-heading">
|
||||
<div>
|
||||
<span className="quality-kicker">Карта правил</span>
|
||||
<h3>Правила, снижающие оценку</h3>
|
||||
</div>
|
||||
<span className="quality-problem-total" id="quality-problem-total">
|
||||
{problemTotalLabel(analysis)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="quality-filters">
|
||||
<label>
|
||||
Важность
|
||||
<select
|
||||
id="quality-severity-filter"
|
||||
value={severityFilter}
|
||||
onChange={event => setSeverityFilter(event.target.value)}
|
||||
>
|
||||
<option value="">Все уровни</option>
|
||||
<option value="CRITICAL">Критические</option>
|
||||
<option value="HIGH">Высокие</option>
|
||||
<option value="MEDIUM">Средние</option>
|
||||
<option value="LOW">Низкие</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Категория
|
||||
<select
|
||||
id="quality-type-filter"
|
||||
value={categoryFilter}
|
||||
onChange={event => setCategoryFilter(event.target.value)}
|
||||
>
|
||||
<option value="">Все категории</option>
|
||||
<option value="GAPS">Окна и компактность</option>
|
||||
<option value="LOAD">Дневная нагрузка</option>
|
||||
<option value="ROOM">Аудитории</option>
|
||||
<option value="PREFERENCE">Пожелания</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="quality-problems" id="quality-problems">
|
||||
{visibleProblems.length ? visibleProblems.map(problem => {
|
||||
const selected = problem.id === selectedProblemId;
|
||||
const affectedLessonCount = Number(problem.affectedLessonCount || 1);
|
||||
const issueLabels = problem.issueLabels?.length ? problem.issueLabels : [problem.title];
|
||||
const canEdit = analysis.scheduleVersionStatus !== 'ARCHIVED' && problem.scheduleRuleId;
|
||||
return (
|
||||
<article
|
||||
className={`quality-problem${selected ? ' is-selected' : ''}`}
|
||||
key={String(problem.id)}
|
||||
data-severity={problem.severity}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="quality-problem-main"
|
||||
onClick={() => handleProblemClick(problem)}
|
||||
>
|
||||
<span className="quality-severity">{severityLabel(problem.severity)}</span>
|
||||
<span className="quality-problem-copy">
|
||||
<strong>{problem.title}</strong>
|
||||
<span>{problem.description}</span>
|
||||
<span className="quality-rule-issues">
|
||||
{issueLabels.map((label, index) => <span key={index}>{label}</span>)}
|
||||
</span>
|
||||
<small>
|
||||
Правило #{problem.scheduleRuleId || '—'}
|
||||
· затронуто {affectedLessonCount} {lessonWord(affectedLessonCount)}
|
||||
· суммарный штраф
|
||||
</small>
|
||||
</span>
|
||||
<span className="quality-penalty">−{problem.penalty}</span>
|
||||
</button>
|
||||
<div className="quality-problem-action">
|
||||
{canEdit ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-secondary"
|
||||
onClick={() => handleProblemClick(problem, 'edit-rule')}
|
||||
>
|
||||
Изменить правило
|
||||
</button>
|
||||
) : <span>Только просмотр</span>}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}) : (
|
||||
<div className="quality-empty-state">
|
||||
<strong>По выбранному фильтру проблем нет</strong>
|
||||
<span>Измените уровень важности или категорию.</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside className="quality-recommendations" id="quality-recommendations" aria-live="polite">
|
||||
{selectedProblem ? (
|
||||
<RuleDetails problem={selectedProblem} archived={archived} onEdit={openRuleInConstructor} />
|
||||
) : (
|
||||
<div className="quality-recommendations-empty">
|
||||
<span className="quality-recommendations-mark">↗</span>
|
||||
<h3>{archived ? 'Архивная версия' : 'Работа с правилом'}</h3>
|
||||
<p>
|
||||
{archived
|
||||
? 'Правила этой версии доступны только для просмотра. Для правок создайте новый черновик.'
|
||||
: 'Выберите правило, чтобы увидеть его суммарное влияние и открыть его в конструкторе.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
</section>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function RuleDetails({ problem, archived, onEdit }) {
|
||||
const affectedLessonCount = Number(problem.affectedLessonCount || 1);
|
||||
const issueLabels = problem.issueLabels?.length ? problem.issueLabels : [problem.title];
|
||||
const canEdit = !archived && problem.scheduleRuleId;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="quality-recommendations-head">
|
||||
<span className="quality-kicker">Правило #{problem.scheduleRuleId || '—'}</span>
|
||||
<h3>{problem.subjectName || problem.title}</h3>
|
||||
<p>Затронуто {affectedLessonCount} {lessonWord(affectedLessonCount)}; суммарный штраф −{problem.penalty}.</p>
|
||||
</div>
|
||||
<div className="quality-rule-detail-list">
|
||||
{issueLabels.map((label, index) => <span key={index}>{label}</span>)}
|
||||
</div>
|
||||
{canEdit ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-md btn-primary quality-edit-rule"
|
||||
onClick={() => onEdit(problem)}
|
||||
>
|
||||
Открыть правило в конструкторе
|
||||
</button>
|
||||
) : <p className="quality-recommendations-note">Архивное расписание нельзя изменить.</p>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
2368
frontend/react/admin/tabs/ScheduleTab.jsx
Normal file
2368
frontend/react/admin/tabs/ScheduleTab.jsx
Normal file
File diff suppressed because it is too large
Load Diff
622
frontend/react/admin/tabs/ScheduleVersionsTab.jsx
Normal file
622
frontend/react/admin/tabs/ScheduleVersionsTab.jsx
Normal file
@@ -0,0 +1,622 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
academicYearsFromSemesters,
|
||||
resolveAcademicPeriod,
|
||||
semestersForAcademicYear
|
||||
} from '../../../admin/js/academic-period.js';
|
||||
import { trackUxEvent } from '../../../telemetry.js';
|
||||
import {
|
||||
QUALITY_SEMESTER_STORAGE_KEY,
|
||||
QUALITY_VERSION_STORAGE_KEY,
|
||||
SCHEDULE_OPEN_VERSION_STORAGE_KEY,
|
||||
SCHEDULE_SEMESTER_STORAGE_KEY,
|
||||
SCHEDULE_VERSION_STORAGE_KEY,
|
||||
SCHEDULE_YEAR_STORAGE_KEY,
|
||||
createScheduleDraftVersion,
|
||||
draftBasisText,
|
||||
fetchScheduleSemesters,
|
||||
fetchScheduleVersionDiff,
|
||||
fetchScheduleVersionHistory,
|
||||
fetchScheduleVersionValidation,
|
||||
fetchScheduleVersions,
|
||||
formatLocalDate,
|
||||
formatVersionDateTime,
|
||||
historyActionLabel,
|
||||
publishValidationSummary,
|
||||
ruleChangeTypeLabel,
|
||||
runScheduleVersionAction,
|
||||
splitScheduleVersions,
|
||||
versionSemesterLabel
|
||||
} from '../schedule-versions-service.js';
|
||||
|
||||
const INITIAL_STATUS = Object.freeze({ message: 'Загружаем контур версий…', tone: '' });
|
||||
|
||||
export function ScheduleVersionsTab() {
|
||||
const [semesters, setSemesters] = useState([]);
|
||||
const [baseLoaded, setBaseLoaded] = useState(false);
|
||||
const [academicYearId, setAcademicYearId] = useState('');
|
||||
const [semesterId, setSemesterId] = useState('');
|
||||
const [versions, setVersions] = useState([]);
|
||||
const [history, setHistory] = useState([]);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [status, setStatus] = useState(INITIAL_STATUS);
|
||||
const [diffState, setDiffState] = useState(null);
|
||||
const [modal, setModal] = useState(null);
|
||||
const [modalValue, setModalValue] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const latestRef = useRef(null);
|
||||
latestRef.current = { academicYearId, semesterId, loading };
|
||||
|
||||
const years = useMemo(() => academicYearsFromSemesters(semesters), [semesters]);
|
||||
const yearSemesters = useMemo(
|
||||
() => semestersForAcademicYear(semesters, academicYearId),
|
||||
[semesters, academicYearId]
|
||||
);
|
||||
const { published, drafts, archive } = useMemo(() => splitScheduleVersions(versions), [versions]);
|
||||
|
||||
useEffect(() => {
|
||||
async function initialize() {
|
||||
try {
|
||||
const loadedSemesters = await fetchScheduleSemesters();
|
||||
setSemesters(loadedSemesters);
|
||||
setBaseLoaded(true);
|
||||
if (!loadedSemesters.length) {
|
||||
setStatus({ message: 'Сначала создайте учебный год и семестр.', tone: 'error' });
|
||||
return;
|
||||
}
|
||||
const selected = resolveAcademicPeriod(loadedSemesters, {
|
||||
academicYearId: window.localStorage.getItem(SCHEDULE_YEAR_STORAGE_KEY),
|
||||
semesterId: window.localStorage.getItem(SCHEDULE_SEMESTER_STORAGE_KEY),
|
||||
date: formatLocalDate(new Date())
|
||||
});
|
||||
setAcademicYearId(selected.academicYearId);
|
||||
setSemesterId(selected.semesterId);
|
||||
window.localStorage.setItem(SCHEDULE_YEAR_STORAGE_KEY, selected.academicYearId);
|
||||
await loadWorkspace(selected.semesterId);
|
||||
} catch (error) {
|
||||
setStatus({ message: error.message || 'Не удалось загрузить семестры.', tone: 'error' });
|
||||
}
|
||||
}
|
||||
void initialize();
|
||||
}, []);
|
||||
|
||||
// Escape закрывает окно операции (в legacy — слушатель на document)
|
||||
useEffect(() => {
|
||||
if (!modal) return;
|
||||
function handleKeydown(event) {
|
||||
if (event.key === 'Escape') closeModal();
|
||||
}
|
||||
document.addEventListener('keydown', handleKeydown);
|
||||
return () => document.removeEventListener('keydown', handleKeydown);
|
||||
}, [modal]);
|
||||
|
||||
async function loadWorkspace(overrideSemesterId) {
|
||||
const nextSemesterId = overrideSemesterId ?? latestRef.current.semesterId;
|
||||
if (!nextSemesterId || latestRef.current.loading) return;
|
||||
setLoading(true);
|
||||
window.localStorage.setItem(SCHEDULE_YEAR_STORAGE_KEY, latestRef.current.academicYearId);
|
||||
window.localStorage.setItem(SCHEDULE_SEMESTER_STORAGE_KEY, nextSemesterId);
|
||||
setStatus({ message: 'Собираем версии, правила и журнал публикаций…', tone: 'loading' });
|
||||
try {
|
||||
const [nextVersions, nextHistory] = await Promise.all([
|
||||
fetchScheduleVersions(nextSemesterId),
|
||||
fetchScheduleVersionHistory(nextSemesterId)
|
||||
]);
|
||||
setVersions(nextVersions);
|
||||
setHistory(nextHistory);
|
||||
setLoaded(true);
|
||||
trackUxEvent('версии_расписания_загружены', { semesterId: nextSemesterId, total: nextVersions.length });
|
||||
setStatus({ message: 'Контур версий синхронизирован.', tone: 'success' });
|
||||
} catch (error) {
|
||||
setStatus({ message: error.message || 'Не удалось загрузить версии расписания.', tone: 'error' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleYearChange(event) {
|
||||
const nextYearId = event.target.value || '';
|
||||
const selected = resolveAcademicPeriod(semesters, {
|
||||
academicYearId: nextYearId,
|
||||
date: formatLocalDate(new Date())
|
||||
});
|
||||
setAcademicYearId(nextYearId);
|
||||
setSemesterId(selected.semesterId);
|
||||
window.localStorage.setItem(SCHEDULE_YEAR_STORAGE_KEY, nextYearId);
|
||||
void loadWorkspace(selected.semesterId);
|
||||
}
|
||||
|
||||
function handleSemesterChange(event) {
|
||||
const nextSemesterId = event.target.value || '';
|
||||
setSemesterId(nextSemesterId);
|
||||
void loadWorkspace(nextSemesterId);
|
||||
}
|
||||
|
||||
function handleVersionAction(version, action) {
|
||||
if (action === 'edit') {
|
||||
window.localStorage.setItem(SCHEDULE_SEMESTER_STORAGE_KEY, String(version.semesterId));
|
||||
window.localStorage.setItem(SCHEDULE_OPEN_VERSION_STORAGE_KEY, String(version.id));
|
||||
window.location.hash = 'schedule';
|
||||
return;
|
||||
}
|
||||
if (action === 'quality') {
|
||||
window.localStorage.setItem(QUALITY_SEMESTER_STORAGE_KEY, String(version.semesterId));
|
||||
window.localStorage.setItem(QUALITY_VERSION_STORAGE_KEY, String(version.id));
|
||||
window.location.hash = 'schedule-quality';
|
||||
return;
|
||||
}
|
||||
if (action === 'diff') {
|
||||
void loadDiff(version);
|
||||
return;
|
||||
}
|
||||
if (action === 'publish') {
|
||||
void openPublishModal(version);
|
||||
return;
|
||||
}
|
||||
if (action === 'restore') {
|
||||
openRestoreModal(version);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDiff(version) {
|
||||
setDiffState({ phase: 'loading' });
|
||||
try {
|
||||
const diff = await fetchScheduleVersionDiff(version.id);
|
||||
setDiffState({ phase: 'ready', diff });
|
||||
} catch (error) {
|
||||
setDiffState({ phase: 'error', message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateModal() {
|
||||
openModal({
|
||||
mode: 'create',
|
||||
version: published,
|
||||
kicker: 'Изолированная рабочая копия',
|
||||
title: 'Создать новый черновик',
|
||||
copy: published
|
||||
? `Правила версии ${published.versionNumber} будут скопированы. Пользователи продолжат видеть текущую публикацию.`
|
||||
: 'Будет создан пустой черновик для первого расписания семестра.',
|
||||
label: 'Название черновика',
|
||||
submit: 'Создать черновик'
|
||||
});
|
||||
}
|
||||
|
||||
async function openPublishModal(version) {
|
||||
setStatus({ message: 'Проверяем целостность черновика перед публикацией…', tone: 'loading' });
|
||||
try {
|
||||
const [validation, diff] = await Promise.all([
|
||||
fetchScheduleVersionValidation(version.id),
|
||||
fetchScheduleVersionDiff(version.id)
|
||||
]);
|
||||
openModal({
|
||||
mode: 'publish',
|
||||
version,
|
||||
valid: validation.valid,
|
||||
kicker: 'Атомарная публикация',
|
||||
title: `Опубликовать версию ${version.versionNumber}`,
|
||||
copy: 'После подтверждения текущая версия целиком уйдёт в архив, а этот черновик станет виден студентам и преподавателям.',
|
||||
label: 'Причина изменений',
|
||||
submit: 'Опубликовать',
|
||||
summary: publishValidationSummary(validation, diff)
|
||||
});
|
||||
setStatus(validation.valid
|
||||
? { message: 'Черновик готов к публикации.', tone: 'success' }
|
||||
: { message: 'Черновик требует исправлений.', tone: 'error' });
|
||||
} catch (error) {
|
||||
setStatus({ message: error.message || 'Не удалось проверить черновик.', tone: 'error' });
|
||||
}
|
||||
}
|
||||
|
||||
function openRestoreModal(version) {
|
||||
openModal({
|
||||
mode: 'restore',
|
||||
version,
|
||||
kicker: 'Контролируемый возврат',
|
||||
title: `Восстановить версию ${version.versionNumber}`,
|
||||
copy: 'Текущая публикация будет архивирована, а выбранная версия снова станет активной одной транзакцией.',
|
||||
label: 'Причина восстановления',
|
||||
submit: 'Восстановить'
|
||||
});
|
||||
}
|
||||
|
||||
function openModal(config) {
|
||||
setModal(config);
|
||||
setModalValue(config.value || '');
|
||||
setSubmitting(false);
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
setModal(null);
|
||||
setModalValue('');
|
||||
setSubmitting(false);
|
||||
}
|
||||
|
||||
async function handleSubmit(event) {
|
||||
event.preventDefault();
|
||||
if (!modal || modal.valid === false || submitting) return;
|
||||
const value = modalValue.trim();
|
||||
if (!value) {
|
||||
setStatus({
|
||||
message: modal.mode === 'create' ? 'Укажите название черновика.' : 'Укажите причину операции.',
|
||||
tone: 'error'
|
||||
});
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
if (modal.mode === 'create') {
|
||||
const created = await createScheduleDraftVersion({
|
||||
semesterId: Number(latestRef.current.semesterId),
|
||||
name: value,
|
||||
basedOnVersionId: modal.version?.id || null
|
||||
});
|
||||
window.localStorage.setItem(SCHEDULE_VERSION_STORAGE_KEY, String(created.id));
|
||||
setStatus({ message: `Черновик «${created.name}» создан.`, tone: 'success' });
|
||||
} else {
|
||||
await runScheduleVersionAction(modal.version.id, modal.mode, value);
|
||||
setStatus({
|
||||
message: modal.mode === 'publish'
|
||||
? 'Версия опубликована атомарно.'
|
||||
: 'Предыдущая версия восстановлена.',
|
||||
tone: 'success'
|
||||
});
|
||||
}
|
||||
closeModal();
|
||||
await loadWorkspace();
|
||||
} catch (error) {
|
||||
setStatus({ message: error.message || 'Операцию не удалось выполнить.', tone: 'error' });
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const yearOptions = !baseLoaded
|
||||
? <option value="">Загрузка годов…</option>
|
||||
: (years.length
|
||||
? years.map(year => <option key={String(year.id)} value={String(year.id)}>{year.title}</option>)
|
||||
: <option value="">Учебные годы не настроены</option>);
|
||||
|
||||
const semesterOptions = !baseLoaded
|
||||
? <option value="">Сначала выберите год</option>
|
||||
: (yearSemesters.length
|
||||
? yearSemesters.map(semester => (
|
||||
<option key={String(semester.id)} value={String(semester.id)}>
|
||||
{versionSemesterLabel(semester)}
|
||||
</option>
|
||||
))
|
||||
: <option value="">Семестры не настроены</option>);
|
||||
|
||||
return (
|
||||
<section className="version-studio" aria-labelledby="version-studio-title">
|
||||
<header className="version-hero">
|
||||
<div>
|
||||
<span className="version-eyebrow">Контур публикации</span>
|
||||
<h2 id="version-studio-title">Расписание меняется за кулисами</h2>
|
||||
<p>Черновики изолированы от студентов и преподавателей. Сравните правила и занятия, проверьте целостность и опубликуйте весь пакет одной операцией.</p>
|
||||
</div>
|
||||
<div className="version-hero-controls">
|
||||
<div className="version-hero-period-grid">
|
||||
<div>
|
||||
<label htmlFor="version-year">Учебный год</label>
|
||||
<select id="version-year" value={academicYearId} onChange={handleYearChange}>
|
||||
{yearOptions}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="version-semester">Рабочий семестр</label>
|
||||
<select id="version-semester" value={semesterId} onChange={handleSemesterChange}>
|
||||
{semesterOptions}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" className="btn btn-md btn-primary" id="version-create" onClick={openCreateModal}>
|
||||
Новый черновик
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div
|
||||
className="version-status"
|
||||
id="version-status"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-tone={status.tone || undefined}
|
||||
>
|
||||
{status.message}
|
||||
</div>
|
||||
|
||||
<section className="version-current" id="version-current" aria-label="Опубликованная версия">
|
||||
{loaded ? (published ? (
|
||||
<>
|
||||
<div className="version-live-mark">LIVE</div>
|
||||
<div>
|
||||
<span className="version-eyebrow">Сейчас видят пользователи</span>
|
||||
<h3>{published.name} · версия {published.versionNumber}</h3>
|
||||
<p>{published.changeReason || 'Причина первой публикации не указана'}</p>
|
||||
<div className="version-stats">
|
||||
<span>{published.ruleCount} правил</span>
|
||||
<span>{published.slotCount} слотов</span>
|
||||
<span>{formatVersionDateTime(published.publishedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="version-card-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-primary"
|
||||
onClick={() => handleVersionAction(published, 'edit')}
|
||||
>
|
||||
Редактировать
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-secondary"
|
||||
onClick={() => handleVersionAction(published, 'quality')}
|
||||
>
|
||||
Проверить качество
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="version-live-mark">ПУСТО</div>
|
||||
<div>
|
||||
<h3>Опубликованной версии нет</h3>
|
||||
<p>Создайте первый черновик и опубликуйте его после проверки.</p>
|
||||
</div>
|
||||
</>
|
||||
)) : null}
|
||||
</section>
|
||||
|
||||
<div className="version-workbench">
|
||||
<section className="version-drafts-panel">
|
||||
<div className="version-section-head">
|
||||
<div>
|
||||
<span className="version-eyebrow">Подготовка</span>
|
||||
<h3>Черновики</h3>
|
||||
</div>
|
||||
<span id="version-draft-count">{drafts.length}</span>
|
||||
</div>
|
||||
<div className="version-draft-list" id="version-drafts">
|
||||
{loaded ? (drafts.length ? drafts.map(version => (
|
||||
<article
|
||||
className="version-card"
|
||||
key={String(version.id)}
|
||||
data-number={String(version.versionNumber).padStart(2, '0')}
|
||||
>
|
||||
<div className="version-card-top">
|
||||
<div>
|
||||
<span className="version-status-chip">Черновик · v{version.versionNumber}</span>
|
||||
<h4>{version.name}</h4>
|
||||
</div>
|
||||
</div>
|
||||
<p>{draftBasisText(version)}</p>
|
||||
<small>
|
||||
{version.ruleCount} правил · {version.slotCount} слотов · {formatVersionDateTime(version.createdAt)}
|
||||
</small>
|
||||
<div className="version-card-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-primary"
|
||||
onClick={() => handleVersionAction(version, 'edit')}
|
||||
>
|
||||
Редактировать
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-secondary"
|
||||
onClick={() => handleVersionAction(version, 'diff')}
|
||||
>
|
||||
Сравнить
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-secondary"
|
||||
onClick={() => handleVersionAction(version, 'quality')}
|
||||
>
|
||||
Качество
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-secondary"
|
||||
onClick={() => handleVersionAction(version, 'publish')}
|
||||
>
|
||||
Опубликовать
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
)) : (
|
||||
<div className="version-empty">
|
||||
<span>+</span>
|
||||
<h3>Нет активных черновиков</h3>
|
||||
<p>Создайте копию опубликованной версии — её изменения останутся невидимыми до публикации.</p>
|
||||
</div>
|
||||
)) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside className="version-diff-panel" id="version-diff">
|
||||
{diffState === null ? (
|
||||
<div className="version-empty">
|
||||
<span>Δ</span>
|
||||
<h3>Сравнение версий</h3>
|
||||
<p>Выберите «Сравнить» у черновика, чтобы увидеть изменения правил и сформированных занятий.</p>
|
||||
</div>
|
||||
) : diffState.phase === 'loading' ? (
|
||||
<div className="version-empty">
|
||||
<span>…</span>
|
||||
<h3>Сравниваем расписания</h3>
|
||||
<p>Генерируем занятия обеих версий по всему семестру.</p>
|
||||
</div>
|
||||
) : diffState.phase === 'error' ? (
|
||||
<div className="version-empty">
|
||||
<span>!</span>
|
||||
<h3>Сравнение недоступно</h3>
|
||||
<p>{diffState.message}</p>
|
||||
</div>
|
||||
) : (
|
||||
<VersionDiff diff={diffState.diff} />
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<section className="version-archive-panel">
|
||||
<div className="version-section-head">
|
||||
<div>
|
||||
<span className="version-eyebrow">Возврат без паники</span>
|
||||
<h3>Архив публикаций</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className="version-archive-list" id="version-archive">
|
||||
{loaded ? (archive.length ? archive.map(version => (
|
||||
<article
|
||||
className="version-card"
|
||||
key={String(version.id)}
|
||||
data-number={String(version.versionNumber).padStart(2, '0')}
|
||||
>
|
||||
<span className="version-status-chip">Архив · v{version.versionNumber}</span>
|
||||
<h4>{version.name}</h4>
|
||||
<p>{version.changeReason || 'Причина не указана'}</p>
|
||||
<small>{version.ruleCount} правил · публикация {formatVersionDateTime(version.publishedAt)}</small>
|
||||
<div className="version-card-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-secondary"
|
||||
onClick={() => handleVersionAction(version, 'diff')}
|
||||
>
|
||||
Сравнить
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-secondary"
|
||||
onClick={() => handleVersionAction(version, 'quality')}
|
||||
>
|
||||
Качество
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-danger"
|
||||
onClick={() => handleVersionAction(version, 'restore')}
|
||||
>
|
||||
Восстановить
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
)) : <p className="version-empty-note">Предыдущих публикаций пока нет.</p>) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="version-history-panel">
|
||||
<div className="version-section-head">
|
||||
<div>
|
||||
<span className="version-eyebrow">Неизменяемый след</span>
|
||||
<h3>Журнал действий</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className="version-history" id="version-history">
|
||||
{loaded ? (history.length ? history.map((item, index) => (
|
||||
<article className="version-history-row" key={index}>
|
||||
<time>{formatVersionDateTime(item.createdAt)}</time>
|
||||
<div>
|
||||
<strong>
|
||||
{historyActionLabel(item.action)}: v{item.versionNumber} · {item.versionName}
|
||||
</strong>
|
||||
<p>{item.reason || 'Без комментария'}</p>
|
||||
</div>
|
||||
<span className="version-status-chip">{item.actorName || 'Система'}</span>
|
||||
</article>
|
||||
)) : <p className="version-empty-note">Действия ещё не зафиксированы.</p>) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{modal ? (
|
||||
<div
|
||||
className="version-modal"
|
||||
id="version-modal"
|
||||
onClick={event => {
|
||||
if (event.target === event.currentTarget) closeModal();
|
||||
}}
|
||||
>
|
||||
<div className="version-modal-card" role="dialog" aria-modal="true" aria-labelledby="version-modal-title">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-icon-md btn-ghost version-modal-close"
|
||||
id="version-modal-close"
|
||||
aria-label="Закрыть окно"
|
||||
onClick={closeModal}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<span className="version-eyebrow" id="version-modal-kicker">{modal.kicker}</span>
|
||||
<h3 id="version-modal-title">{modal.title}</h3>
|
||||
<p id="version-modal-copy">{modal.copy}</p>
|
||||
<form id="version-modal-form" onSubmit={handleSubmit}>
|
||||
<label htmlFor="version-modal-value" id="version-modal-label">{modal.label}</label>
|
||||
<textarea
|
||||
id="version-modal-value"
|
||||
rows="3"
|
||||
maxLength="2000"
|
||||
required
|
||||
autoFocus
|
||||
value={modalValue}
|
||||
onChange={event => setModalValue(event.target.value)}
|
||||
></textarea>
|
||||
<div className="version-modal-summary" id="version-modal-summary" hidden={!modal.summary}>
|
||||
{modal.summary || ''}
|
||||
</div>
|
||||
<div className="version-modal-actions">
|
||||
<button type="button" className="btn btn-md btn-ghost" id="version-modal-cancel" onClick={closeModal}>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-md btn-primary"
|
||||
id="version-modal-submit"
|
||||
disabled={modal.valid === false || submitting}
|
||||
>
|
||||
{submitting ? 'Выполняем…' : modal.submit}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function VersionDiff({ diff }) {
|
||||
return (
|
||||
<>
|
||||
<span className="version-eyebrow">{diff.baseVersionLabel} → {diff.targetVersionLabel}</span>
|
||||
<h3>Что изменится после публикации</h3>
|
||||
<div className="version-diff-score">
|
||||
<div><strong>+{diff.addedLessonCount}</strong><span>занятий</span></div>
|
||||
<div><strong>{diff.changedLessonCount}</strong><span>изменено</span></div>
|
||||
<div><strong>−{diff.removedLessonCount}</strong><span>удалено</span></div>
|
||||
</div>
|
||||
<p className="version-diff-caption">
|
||||
Правила: +{diff.addedRuleCount} · ~{diff.changedRuleCount} · −{diff.removedRuleCount} · без изменений {diff.unchangedRuleCount}
|
||||
</p>
|
||||
<div className="version-change-list">
|
||||
{(diff.ruleChanges || []).length
|
||||
? diff.ruleChanges.map((change, index) => (
|
||||
<article className="version-change" key={index} data-type={change.type}>
|
||||
<strong>{ruleChangeTypeLabel(change.type)} · {change.subjectName}</strong>
|
||||
<span>{(change.groupNames || []).join(', ') || 'Группы не указаны'}</span>
|
||||
{change.beforeSummary ? <span>Было: {change.beforeSummary}</span> : null}
|
||||
{change.afterSummary ? <span>Стало: {change.afterSummary}</span> : null}
|
||||
</article>
|
||||
))
|
||||
: (
|
||||
<div className="version-empty">
|
||||
<h3>Правила совпадают</h3>
|
||||
<p>Структурных изменений между версиями нет.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
1771
frontend/react/admin/tabs/ScheduleViewTab.jsx
Normal file
1771
frontend/react/admin/tabs/ScheduleViewTab.jsx
Normal file
File diff suppressed because it is too large
Load Diff
535
frontend/react/admin/tabs/SubjectsTab.jsx
Normal file
535
frontend/react/admin/tabs/SubjectsTab.jsx
Normal file
@@ -0,0 +1,535 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { readUrlState, writeUrlState } from '../../../admin/js/url-state.js';
|
||||
import { trackUxEvent } from '../../../telemetry.js';
|
||||
import { AsyncCombobox } from '../../shared/ui/AsyncCombobox.jsx';
|
||||
import { AlertDialog, ConfirmDialog } from '../../shared/ui/ConfirmDialog.jsx';
|
||||
import { Pagination } from '../../shared/ui/Pagination.jsx';
|
||||
import {
|
||||
SUBJECT_SORT_OPTIONS,
|
||||
SUBJECT_TABLE_COLUMNS,
|
||||
archiveSubject,
|
||||
assignTeacher,
|
||||
createSubject,
|
||||
departmentLabel,
|
||||
departmentOptionLabel,
|
||||
fetchDepartments,
|
||||
fetchSubjectsPage,
|
||||
fetchTeacherAssignments,
|
||||
groupTeachersBySubject,
|
||||
initialSubjectsState,
|
||||
removeTeacherAssignment,
|
||||
subjectsEmptyMessage,
|
||||
subjectsUrlState,
|
||||
teachersSearchEndpoint
|
||||
} from '../subjects-service.js';
|
||||
import { apiClient } from '../../shared/api-client.js';
|
||||
|
||||
const SEARCH_DEBOUNCE_MS = 320;
|
||||
|
||||
export function SubjectsTab() {
|
||||
const [departments, setDepartments] = useState(null);
|
||||
const [referenceError, setReferenceError] = useState('');
|
||||
|
||||
const [state, setState] = useState(() => initialSubjectsState(
|
||||
typeof window === 'undefined' ? {} : readUrlState({
|
||||
subjectQuery: '',
|
||||
subjectDepartment: '',
|
||||
subjectPage: '0',
|
||||
subjectSize: '25',
|
||||
subjectSort: 'name,asc'
|
||||
})
|
||||
));
|
||||
const [searchInput, setSearchInput] = useState(() => state.query);
|
||||
const [pageData, setPageData] = useState(null);
|
||||
const [assignments, setAssignments] = useState([]);
|
||||
const [tableError, setTableError] = useState('');
|
||||
const [tableLoading, setTableLoading] = useState(true);
|
||||
|
||||
const [createName, setCreateName] = useState('');
|
||||
const [createCode, setCreateCode] = useState('');
|
||||
const [createDepartmentId, setCreateDepartmentId] = useState('');
|
||||
const [createAlert, setCreateAlert] = useState({ text: '', isError: false });
|
||||
|
||||
const [teachersModal, setTeachersModal] = useState(null);
|
||||
const [confirmState, setConfirmState] = useState(null);
|
||||
const [alertState, setAlertState] = useState(null);
|
||||
|
||||
const searchTimer = useRef(null);
|
||||
const requestNumber = useRef(0);
|
||||
const stateRef = useRef(state);
|
||||
stateRef.current = state;
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
(async () => {
|
||||
try {
|
||||
const loaded = await fetchDepartments();
|
||||
if (!active) return;
|
||||
setDepartments(loaded || []);
|
||||
await loadSubjects(stateRef.current);
|
||||
} catch (error) {
|
||||
if (active) setReferenceError(error.message || 'Не удалось загрузить справочники');
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
active = false;
|
||||
requestNumber.current += 1;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => () => {
|
||||
window.clearTimeout(searchTimer.current);
|
||||
}, []);
|
||||
|
||||
async function loadSubjects(nextState) {
|
||||
const currentRequest = requestNumber.current + 1;
|
||||
requestNumber.current = currentRequest;
|
||||
setTableLoading(true);
|
||||
setTableError('');
|
||||
try {
|
||||
let page = await fetchSubjectsPage(nextState);
|
||||
if (!(page.items || []).length && page.totalItems > 0 && nextState.page > 0) {
|
||||
nextState = { ...nextState, page: Math.max(0, page.totalPages - 1) };
|
||||
page = await fetchSubjectsPage(nextState);
|
||||
}
|
||||
const loadedAssignments = await fetchTeacherAssignments((page.items || []).map(subject => subject.id));
|
||||
if (requestNumber.current !== currentRequest) return;
|
||||
setState(nextState);
|
||||
setPageData(page);
|
||||
setAssignments(loadedAssignments || []);
|
||||
setTableLoading(false);
|
||||
writeUrlState(subjectsUrlState(nextState));
|
||||
trackUxEvent('реестр_дисциплин_загружен', {
|
||||
total: Number(page.totalItems || 0),
|
||||
page: nextState.page
|
||||
});
|
||||
} catch (error) {
|
||||
if (requestNumber.current !== currentRequest) return;
|
||||
setState(nextState);
|
||||
setTableLoading(false);
|
||||
setTableError(error.message || 'Не удалось загрузить дисциплины');
|
||||
}
|
||||
}
|
||||
|
||||
function applyState(patch) {
|
||||
const nextState = { ...stateRef.current, ...patch };
|
||||
void loadSubjects(nextState);
|
||||
}
|
||||
|
||||
function handleSearchInput(value) {
|
||||
setSearchInput(value);
|
||||
window.clearTimeout(searchTimer.current);
|
||||
searchTimer.current = window.setTimeout(() => {
|
||||
applyState({ query: value.trim(), page: 0 });
|
||||
}, SEARCH_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
async function submitCreate(event) {
|
||||
event.preventDefault();
|
||||
const name = createName.trim();
|
||||
const code = createCode.trim();
|
||||
if (!name || !code || !createDepartmentId) {
|
||||
setCreateAlert({ text: 'Заполните все поля', isError: true });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const created = await createSubject({ name, code, departmentId: createDepartmentId });
|
||||
setCreateAlert({ text: `Дисциплина «${created.name || name}» создана`, isError: false });
|
||||
setCreateName('');
|
||||
setCreateCode('');
|
||||
await loadSubjects(stateRef.current);
|
||||
} catch (error) {
|
||||
setCreateAlert({ text: error.message || 'Ошибка создания', isError: true });
|
||||
}
|
||||
}
|
||||
|
||||
function confirmArchiveSubject(subject) {
|
||||
setConfirmState({
|
||||
title: 'Архивировать дисциплину?',
|
||||
message: 'Дисциплина станет недоступна для новых назначений. Исторические данные сохранятся.',
|
||||
confirmLabel: 'Архивировать',
|
||||
danger: true,
|
||||
action: async () => {
|
||||
try {
|
||||
await archiveSubject(subject.id);
|
||||
await loadSubjects(stateRef.current);
|
||||
} catch (error) {
|
||||
setAlertState({ title: 'Ошибка архивирования', message: error.message || 'Ошибка архивирования' });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function openTeachersModal(subject) {
|
||||
setTeachersModal({
|
||||
subject,
|
||||
alert: { text: '', isError: false },
|
||||
selectedTeacher: null
|
||||
});
|
||||
}
|
||||
|
||||
async function reloadAssignments() {
|
||||
const subjectIds = (pageData?.items || []).map(subject => subject.id);
|
||||
const loaded = await fetchTeacherAssignments(subjectIds);
|
||||
setAssignments(loaded || []);
|
||||
return loaded || [];
|
||||
}
|
||||
|
||||
async function searchTeachers(query, selectedId) {
|
||||
const teachers = await apiClient.get(teachersSearchEndpoint(query, selectedId));
|
||||
const modalSubjectId = teachersModal?.subject?.id;
|
||||
const assigned = new Set(
|
||||
(assignments || [])
|
||||
.filter(item => String(item.subjectId) === String(modalSubjectId))
|
||||
.map(item => String(item.userId))
|
||||
);
|
||||
return (teachers || []).filter(teacher =>
|
||||
!assigned.has(String(teacher.id)) || String(teacher.id) === String(selectedId));
|
||||
}
|
||||
|
||||
async function submitTeacherAssignment(event) {
|
||||
event.preventDefault();
|
||||
const modal = teachersModal;
|
||||
if (!modal?.selectedTeacher) {
|
||||
setTeachersModal({ ...modal, alert: { text: 'Выберите преподавателя', isError: true } });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await assignTeacher({ userId: modal.selectedTeacher.id, subjectId: modal.subject.id });
|
||||
setTeachersModal(current => (
|
||||
current ? { ...current, alert: { text: 'Преподаватель успешно привязан', isError: false }, selectedTeacher: null } : current
|
||||
));
|
||||
await reloadAssignments();
|
||||
} catch (error) {
|
||||
setTeachersModal({ ...modal, alert: { text: error.message || 'Ошибка привязки', isError: true } });
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDeleteAssignment(assignment) {
|
||||
setConfirmState({
|
||||
title: 'Удалить привязку преподавателя?',
|
||||
message: 'Преподаватель больше не будет доступен для этой дисциплины в новых правилах.',
|
||||
confirmLabel: 'Удалить привязку',
|
||||
danger: true,
|
||||
action: async () => {
|
||||
try {
|
||||
await removeTeacherAssignment({
|
||||
userId: assignment.userId,
|
||||
subjectId: assignment.subjectId
|
||||
});
|
||||
await reloadAssignments();
|
||||
} catch (error) {
|
||||
setTeachersModal(current => (
|
||||
current ? { ...current, alert: { text: error.message || 'Ошибка удаления привязки', isError: true } } : current
|
||||
));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function runConfirm() {
|
||||
const confirm = confirmState;
|
||||
setConfirmState(null);
|
||||
await confirm.action();
|
||||
}
|
||||
|
||||
const teachersBySubject = groupTeachersBySubject(assignments);
|
||||
const subjects = pageData?.items || [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="card create-card">
|
||||
<h2>Новая дисциплина</h2>
|
||||
<form onSubmit={submitCreate}>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label htmlFor="new-subject-name">Название дисциплины</label>
|
||||
<input
|
||||
type="text"
|
||||
id="new-subject-name"
|
||||
placeholder="Высшая математика"
|
||||
required
|
||||
value={createName}
|
||||
onChange={event => setCreateName(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="new-subject-code">Код предмета</label>
|
||||
<input
|
||||
type="text"
|
||||
id="new-subject-code"
|
||||
placeholder="Например: MATH101"
|
||||
required
|
||||
value={createCode}
|
||||
onChange={event => setCreateCode(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="new-subject-department">Кафедра</label>
|
||||
<select
|
||||
id="new-subject-department"
|
||||
required
|
||||
value={createDepartmentId}
|
||||
onChange={event => setCreateDepartmentId(event.target.value)}
|
||||
>
|
||||
<option value="">Выберите кафедру</option>
|
||||
{(departments || []).map(department => (
|
||||
<option key={department.id} value={department.id}>
|
||||
{departmentOptionLabel(department)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" className="btn btn-md btn-primary">Добавить</button>
|
||||
</div>
|
||||
<div
|
||||
className={`form-alert${createAlert.text ? (createAlert.isError ? ' error' : ' success') : ''}`}
|
||||
role="alert"
|
||||
>
|
||||
{createAlert.text}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h2>Все дисциплины</h2>
|
||||
<div className="registry-toolbar">
|
||||
<div className="form-group registry-search">
|
||||
<label htmlFor="subjects-search">Поиск</label>
|
||||
<input
|
||||
type="search"
|
||||
id="subjects-search"
|
||||
placeholder="Название или код дисциплины"
|
||||
value={searchInput}
|
||||
onChange={event => handleSearchInput(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="subjects-department-filter">Кафедра</label>
|
||||
<select
|
||||
id="subjects-department-filter"
|
||||
value={state.departmentId}
|
||||
onChange={event => applyState({ departmentId: event.target.value, page: 0 })}
|
||||
>
|
||||
<option value="">Все кафедры</option>
|
||||
{(departments || []).map(department => (
|
||||
<option key={department.id} value={department.id}>
|
||||
{department.departmentName || department.name || department.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="subjects-sort">Сортировка</label>
|
||||
<select
|
||||
id="subjects-sort"
|
||||
value={state.sort}
|
||||
onChange={event => applyState({ sort: event.target.value, page: 0 })}
|
||||
>
|
||||
{SUBJECT_SORT_OPTIONS.map(option => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="table-wrap">
|
||||
<table id="subjects-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Название</th>
|
||||
<th>Код предмета</th>
|
||||
<th>Кафедра</th>
|
||||
<th>Преподаватели</th>
|
||||
<th className="th-actions">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{referenceError ? (
|
||||
<tr><td colSpan={SUBJECT_TABLE_COLUMNS} className="loading-row">Ошибка загрузки справочников: {referenceError}</td></tr>
|
||||
) : tableError ? (
|
||||
<tr>
|
||||
<td colSpan={SUBJECT_TABLE_COLUMNS} className="loading-row view-state-error">
|
||||
<span>{tableError}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-secondary"
|
||||
onClick={() => void loadSubjects(stateRef.current)}
|
||||
>
|
||||
Повторить
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
) : tableLoading ? (
|
||||
<tr><td colSpan={SUBJECT_TABLE_COLUMNS} className="loading-row"><span className="state-spinner" aria-hidden="true" />Загрузка дисциплин...</td></tr>
|
||||
) : subjects.length === 0 ? (
|
||||
<tr><td colSpan={SUBJECT_TABLE_COLUMNS} className="loading-row view-state-empty">{subjectsEmptyMessage(state)}</td></tr>
|
||||
) : subjects.map(subject => {
|
||||
const teachers = teachersBySubject.get(String(subject.id)) || [];
|
||||
return (
|
||||
<tr key={subject.id}>
|
||||
<td>{subject.id}</td>
|
||||
<td><strong>{subject.name}</strong></td>
|
||||
<td>{subject.code || '-'}</td>
|
||||
<td>{departmentLabel(departments || [], subject.departmentId)}</td>
|
||||
<td>
|
||||
<div className="badge-list">
|
||||
{teachers.length === 0 ? (
|
||||
<span className="text-muted"><small>Нет преподавателей</small></span>
|
||||
) : teachers.map(teacher => (
|
||||
<span className="badge badge-ef badge--tight" key={teacher.userId}>
|
||||
{teacher.fullName || teacher.username}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td className="table-cell-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-secondary btn-manage-teachers"
|
||||
onClick={() => openTeachersModal(subject)}
|
||||
>
|
||||
Преподаватели
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-danger btn-delete-subject"
|
||||
onClick={() => confirmArchiveSubject(subject)}
|
||||
>
|
||||
Удалить
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{pageData && (
|
||||
<Pagination
|
||||
page={pageData.page}
|
||||
size={pageData.size}
|
||||
totalItems={pageData.totalItems}
|
||||
totalPages={pageData.totalPages}
|
||||
label="дисциплин"
|
||||
onPage={page => applyState({ page })}
|
||||
onSize={size => applyState({ size, page: 0 })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{teachersModal && (
|
||||
<div
|
||||
className="modal-overlay open"
|
||||
onClick={event => {
|
||||
if (event.target === event.currentTarget) setTeachersModal(null);
|
||||
}}
|
||||
>
|
||||
<div className="modal-content card modal-content--medium">
|
||||
<div className="modal-header-row">
|
||||
<div>
|
||||
<h2>Преподаватели дисциплины</h2>
|
||||
<div className="modal-subtitle">
|
||||
{teachersModal.subject.code ? `${teachersModal.subject.code} · ` : ''}{teachersModal.subject.name}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-icon-md btn-ghost modal-close modal-close--static"
|
||||
aria-label="Закрыть окно"
|
||||
onClick={() => setTeachersModal(null)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="card create-card modal-subcard">
|
||||
<h3>Добавить привязку</h3>
|
||||
<form onSubmit={submitTeacherAssignment}>
|
||||
<div className="form-row form-row--bottom">
|
||||
<div className="form-group form-group--flush">
|
||||
<label htmlFor="manage-ts-teacher-select">Преподаватель</label>
|
||||
<AsyncCombobox
|
||||
ariaLabel="Поиск преподавателя"
|
||||
placeholder="Найдите преподавателя по ФИО"
|
||||
emptyLabel="Преподаватели не найдены"
|
||||
load={(query, selectedId) => searchTeachers(query, selectedId)}
|
||||
optionLabel={teacher => teacher.fullName || teacher.username}
|
||||
optionDescription={teacher => [teacher.jobTitle, teacher.departmentName].filter(Boolean).join(' · ')}
|
||||
onSelect={teacher => setTeachersModal(current => (
|
||||
current ? { ...current, selectedTeacher: teacher } : current
|
||||
))}
|
||||
selectedLabel={teachersModal.selectedTeacher
|
||||
? (teachersModal.selectedTeacher.fullName || teachersModal.selectedTeacher.username)
|
||||
: ''}
|
||||
selectedValue={teachersModal.selectedTeacher
|
||||
? String(teachersModal.selectedTeacher.id)
|
||||
: ''}
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="btn btn-md btn-primary btn-inline">Привязать</button>
|
||||
</div>
|
||||
<div
|
||||
className={`form-alert${teachersModal.alert.text ? (teachersModal.alert.isError ? ' error' : ' success') : ''}`}
|
||||
role="alert"
|
||||
>
|
||||
{teachersModal.alert.text}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div className="table-wrap">
|
||||
<table id="manage-ts-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Преподаватель</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(teachersBySubject.get(String(teachersModal.subject.id)) || []).length === 0 ? (
|
||||
<tr><td colSpan="2" className="loading-row">Преподаватели не привязаны</td></tr>
|
||||
) : (teachersBySubject.get(String(teachersModal.subject.id)) || []).map(assignment => (
|
||||
<tr key={assignment.userId}>
|
||||
<td>
|
||||
<strong>{assignment.fullName || assignment.username}</strong>{' '}
|
||||
<span className="text-muted">({assignment.username})</span>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-danger btn-delete-ts"
|
||||
onClick={() => confirmDeleteAssignment(assignment)}
|
||||
>
|
||||
Удалить
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{confirmState && (
|
||||
<ConfirmDialog
|
||||
title={confirmState.title}
|
||||
message={confirmState.message}
|
||||
confirmLabel={confirmState.confirmLabel}
|
||||
danger={confirmState.danger}
|
||||
onConfirm={() => void runConfirm()}
|
||||
onClose={() => setConfirmState(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{alertState && (
|
||||
<AlertDialog
|
||||
title={alertState.title}
|
||||
message={alertState.message}
|
||||
onClose={() => setAlertState(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
940
frontend/react/admin/tabs/TeacherAbsencesTab.jsx
Normal file
940
frontend/react/admin/tabs/TeacherAbsencesTab.jsx
Normal file
@@ -0,0 +1,940 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { getSession } from '../../../admin/js/api.js';
|
||||
import { AsyncCombobox } from '../../shared/ui/AsyncCombobox.jsx';
|
||||
import { FormAlert } from '../../shared/ui/FormAlert.jsx';
|
||||
import {
|
||||
absenceSemesterLabel,
|
||||
absenceStats,
|
||||
changeReviewTarget,
|
||||
changeTypeLabel,
|
||||
createTeacherAbsence,
|
||||
decisionCountLabel,
|
||||
decisionOptions,
|
||||
decisionStatusLabel,
|
||||
deleteTeacherAbsence,
|
||||
fetchAbsenceTeacherOptions,
|
||||
fetchTeacherAbsenceWizard,
|
||||
fetchTeacherAbsences,
|
||||
fetchTeacherChangeRequests,
|
||||
fetchTeacherPreferenceMeta,
|
||||
fetchTeacherPreferences,
|
||||
formatAbsenceDate,
|
||||
formatLocalDate,
|
||||
dayOfMonth,
|
||||
genericStatusLabel,
|
||||
monthShort,
|
||||
parseDecisionOption,
|
||||
preferenceDescription,
|
||||
resolutionLabel,
|
||||
resolveTeacherAbsence,
|
||||
reviewTeacherAbsence,
|
||||
reviewTeacherChangeRequest,
|
||||
reviewTeacherPreference,
|
||||
statusClass,
|
||||
statusLabel,
|
||||
trimTime
|
||||
} from '../teacher-absences-service.js';
|
||||
|
||||
function teacherOptionLabel(teacher) {
|
||||
return teacher.fullName || teacher.username || `ID ${teacher.id}`;
|
||||
}
|
||||
|
||||
export function TeacherAbsencesTab() {
|
||||
const session = getSession();
|
||||
const role = session?.role;
|
||||
|
||||
const [teacherSelection, setTeacherSelection] = useState({ id: '', label: '' });
|
||||
const [startDate, setStartDate] = useState(() => formatLocalDate(new Date()));
|
||||
const [endDate, setEndDate] = useState(() => formatLocalDate(new Date()));
|
||||
const [reason, setReason] = useState('');
|
||||
const [formAlert, setFormAlert] = useState(null);
|
||||
|
||||
const [absences, setAbsences] = useState([]);
|
||||
const [absencesPhase, setAbsencesPhase] = useState('loading');
|
||||
const [absencesError, setAbsencesError] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [registryBusy, setRegistryBusy] = useState(null);
|
||||
|
||||
const [wizardOpen, setWizardOpen] = useState(false);
|
||||
const [wizardData, setWizardData] = useState(null);
|
||||
const [wizardLoading, setWizardLoading] = useState(false);
|
||||
const [wizardAlert, setWizardAlert] = useState(null);
|
||||
const [wizardApplying, setWizardApplying] = useState(false);
|
||||
const [decisions, setDecisions] = useState({});
|
||||
|
||||
const [preferenceSemesters, setPreferenceSemesters] = useState([]);
|
||||
const [preferenceSemesterId, setPreferenceSemesterId] = useState('');
|
||||
const [preferenceStatusFilter, setPreferenceStatusFilter] = useState('PENDING');
|
||||
const [preferenceItems, setPreferenceItems] = useState([]);
|
||||
const [preferencePhase, setPreferencePhase] = useState('idle');
|
||||
const [preferenceError, setPreferenceError] = useState('');
|
||||
const [preferenceComments, setPreferenceComments] = useState({});
|
||||
const [preferenceBusy, setPreferenceBusy] = useState(null);
|
||||
|
||||
const [changeStatusFilter, setChangeStatusFilter] = useState('PENDING');
|
||||
const [changeItems, setChangeItems] = useState([]);
|
||||
const [changePhase, setChangePhase] = useState('idle');
|
||||
const [changeError, setChangeError] = useState('');
|
||||
const [changeComments, setChangeComments] = useState({});
|
||||
const [changeBusy, setChangeBusy] = useState(null);
|
||||
|
||||
const [alert, setAlert] = useState(null);
|
||||
|
||||
const latestRef = useRef(null);
|
||||
latestRef.current = { statusFilter, preferenceSemesterId, preferenceStatusFilter, changeStatusFilter };
|
||||
|
||||
const stats = absenceStats(absences);
|
||||
const canApplyChanges = role === 'ADMIN' || role === 'EDUCATION_OFFICE';
|
||||
|
||||
useEffect(() => {
|
||||
void loadAbsences();
|
||||
void loadPreferenceMeta();
|
||||
void loadChangeReviews();
|
||||
}, []);
|
||||
|
||||
// Мастер замены блокирует прокрутку страницы (в legacy — класс на body)
|
||||
useEffect(() => {
|
||||
if (!wizardOpen) return;
|
||||
document.body.classList.add('absence-wizard-open');
|
||||
return () => document.body.classList.remove('absence-wizard-open');
|
||||
}, [wizardOpen]);
|
||||
|
||||
// Escape закрывает системное окно сообщения (в legacy — диалог из dialog.js)
|
||||
useEffect(() => {
|
||||
if (!alert) return;
|
||||
function handleKeydown(event) {
|
||||
if (event.key === 'Escape') setAlert(null);
|
||||
}
|
||||
document.addEventListener('keydown', handleKeydown);
|
||||
return () => document.removeEventListener('keydown', handleKeydown);
|
||||
}, [alert]);
|
||||
|
||||
async function loadAbsences(overrideStatus) {
|
||||
const nextStatus = overrideStatus ?? latestRef.current.statusFilter;
|
||||
setAbsencesPhase('loading');
|
||||
try {
|
||||
const items = await fetchTeacherAbsences(nextStatus);
|
||||
setAbsences(items);
|
||||
setAbsencesPhase('ready');
|
||||
} catch (error) {
|
||||
setAbsencesError(error.message || 'Не удалось загрузить отсутствия');
|
||||
setAbsencesPhase('error');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(event) {
|
||||
event.preventDefault();
|
||||
setFormAlert(null);
|
||||
if (!teacherSelection.id || !startDate || !endDate || !reason.trim()) {
|
||||
setFormAlert({ type: 'error', message: 'Заполните преподавателя, период и причину' });
|
||||
return;
|
||||
}
|
||||
if (endDate < startDate) {
|
||||
setFormAlert({ type: 'error', message: 'Дата окончания не может быть раньше даты начала' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await createTeacherAbsence({
|
||||
teacherId: Number(teacherSelection.id),
|
||||
startDate,
|
||||
endDate,
|
||||
reason: reason.trim()
|
||||
});
|
||||
setFormAlert({ type: 'success', message: 'Отсутствие зарегистрировано' });
|
||||
setReason('');
|
||||
await loadAbsences();
|
||||
} catch (error) {
|
||||
setFormAlert({ type: 'error', message: error.message || 'Не удалось зарегистрировать отсутствие' });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRegistryAction(item, action) {
|
||||
setRegistryBusy({ id: item.id, action });
|
||||
try {
|
||||
if (action === 'approve' || action === 'reject') {
|
||||
await reviewTeacherAbsence(item.id, {
|
||||
approved: action === 'approve',
|
||||
comment: action === 'approve'
|
||||
? 'Подтверждено кафедрой или ответственным сотрудником'
|
||||
: 'Заявка отклонена ответственным сотрудником'
|
||||
});
|
||||
await loadAbsences();
|
||||
} else if (action === 'cancel') {
|
||||
await deleteTeacherAbsence(item.id);
|
||||
await loadAbsences();
|
||||
} else if (action === 'wizard') {
|
||||
await openWizard(item.id);
|
||||
}
|
||||
} catch (error) {
|
||||
setAlert({ title: 'Действие не выполнено', message: error.message || 'Не удалось выполнить действие' });
|
||||
} finally {
|
||||
setRegistryBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function openWizard(id) {
|
||||
setWizardOpen(true);
|
||||
setWizardLoading(true);
|
||||
setWizardData(null);
|
||||
setWizardAlert(null);
|
||||
setDecisions({});
|
||||
try {
|
||||
const data = await fetchTeacherAbsenceWizard(id);
|
||||
setWizardData(data);
|
||||
setWizardLoading(false);
|
||||
} catch (error) {
|
||||
setWizardAlert({ type: 'error', message: error.message || 'Не удалось открыть мастер замены' });
|
||||
}
|
||||
}
|
||||
|
||||
function closeWizard() {
|
||||
setWizardOpen(false);
|
||||
setWizardData(null);
|
||||
setWizardLoading(false);
|
||||
setWizardAlert(null);
|
||||
setWizardApplying(false);
|
||||
setDecisions({});
|
||||
}
|
||||
|
||||
async function applyWizard() {
|
||||
if (!wizardData) return;
|
||||
const nextDecisions = (wizardData.lessons || []).map((lesson, index) => {
|
||||
const value = decisions[index] || '';
|
||||
if (!value) return null;
|
||||
return {
|
||||
baseRuleSlotId: Number(lesson.baseRuleSlotId),
|
||||
lessonDate: lesson.lessonDate,
|
||||
...parseDecisionOption(value)
|
||||
};
|
||||
}).filter(Boolean);
|
||||
if (!nextDecisions.length && (wizardData.lessons || []).length) {
|
||||
setWizardAlert({ type: 'error', message: 'Выберите хотя бы одно решение' });
|
||||
return;
|
||||
}
|
||||
setWizardApplying(true);
|
||||
setWizardAlert(null);
|
||||
try {
|
||||
const data = await resolveTeacherAbsence(wizardData.absence.id, nextDecisions);
|
||||
setWizardData(data);
|
||||
setDecisions({});
|
||||
setWizardAlert({ type: 'success', message: 'Выбранные решения применены к расписанию' });
|
||||
await loadAbsences();
|
||||
} catch (error) {
|
||||
setWizardAlert({ type: 'error', message: error.message || 'Не удалось применить решения' });
|
||||
} finally {
|
||||
setWizardApplying(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPreferenceMeta() {
|
||||
setPreferencePhase('meta-loading');
|
||||
try {
|
||||
const meta = await fetchTeacherPreferenceMeta();
|
||||
setPreferenceSemesters(meta.semesters || []);
|
||||
const today = formatLocalDate(new Date());
|
||||
const current = (meta.semesters || []).find(item => item.startDate <= today && item.endDate >= today);
|
||||
const nextSemesterId = current ? String(current.id) : '';
|
||||
setPreferenceSemesterId(nextSemesterId);
|
||||
await loadPreferenceReviews({ semesterId: nextSemesterId });
|
||||
} catch (error) {
|
||||
setPreferenceError(error.message || 'Не удалось загрузить семестры');
|
||||
setPreferencePhase('error');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPreferenceReviews(overrides = {}) {
|
||||
const semesterId = overrides.semesterId ?? latestRef.current.preferenceSemesterId;
|
||||
const status = overrides.status ?? latestRef.current.preferenceStatusFilter;
|
||||
setPreferencePhase('loading');
|
||||
try {
|
||||
const items = await fetchTeacherPreferences({ semesterId, status });
|
||||
setPreferenceItems(items);
|
||||
setPreferencePhase('ready');
|
||||
} catch (error) {
|
||||
setPreferenceError(error.message || 'Не удалось загрузить пожелания');
|
||||
setPreferencePhase('error');
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePreferenceReview(item, approved) {
|
||||
const comment = (preferenceComments[item.id] || '').trim();
|
||||
if (!approved && !comment) {
|
||||
setAlert({ title: 'Нужен комментарий', message: 'При отклонении укажите комментарий.' });
|
||||
return;
|
||||
}
|
||||
setPreferenceBusy({ id: item.id, action: approved ? 'approve' : 'reject' });
|
||||
try {
|
||||
await reviewTeacherPreference(item.id, {
|
||||
approved,
|
||||
comment: comment || 'Согласовано ответственным сотрудником'
|
||||
});
|
||||
await loadPreferenceReviews();
|
||||
} catch (error) {
|
||||
setAlert({ title: 'Ошибка рассмотрения', message: error.message || 'Не удалось рассмотреть пожелание' });
|
||||
} finally {
|
||||
setPreferenceBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadChangeReviews(overrideStatus) {
|
||||
const nextStatus = overrideStatus ?? latestRef.current.changeStatusFilter;
|
||||
setChangePhase('loading');
|
||||
try {
|
||||
const items = await fetchTeacherChangeRequests(nextStatus);
|
||||
setChangeItems(items);
|
||||
setChangePhase('ready');
|
||||
} catch (error) {
|
||||
setChangeError(error.message || 'Не удалось загрузить заявки');
|
||||
setChangePhase('error');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleChangeReview(item, approved) {
|
||||
const comment = (changeComments[item.id] || '').trim();
|
||||
if (!approved && !comment) {
|
||||
setAlert({ title: 'Нужен комментарий', message: 'При отклонении укажите комментарий.' });
|
||||
return;
|
||||
}
|
||||
setChangeBusy({ id: item.id, action: approved ? 'approve' : 'reject' });
|
||||
try {
|
||||
await reviewTeacherChangeRequest(item.id, { approved, comment });
|
||||
await Promise.all([loadChangeReviews(), loadAbsences()]);
|
||||
} catch (error) {
|
||||
setAlert({ title: 'Решение не применено', message: error.message || 'Не удалось применить решение' });
|
||||
} finally {
|
||||
setChangeBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
const registryContent = absencesPhase === 'loading'
|
||||
? <div className="loading-row">Загрузка...</div>
|
||||
: absencesPhase === 'error'
|
||||
? <div className="loading-row">{absencesError}</div>
|
||||
: (absences.length ? absences.map(item => (
|
||||
<article className="absence-registry-item" key={String(item.id)} data-status={item.status}>
|
||||
<div className="absence-date-rail">
|
||||
<strong>{dayOfMonth(item.startDate)}</strong>
|
||||
<span>{monthShort(item.startDate)}</span>
|
||||
<i></i>
|
||||
<strong>{dayOfMonth(item.endDate)}</strong>
|
||||
<span>{monthShort(item.endDate)}</span>
|
||||
</div>
|
||||
<div className="absence-registry-main">
|
||||
<div className="absence-registry-title">
|
||||
<div>
|
||||
<h3>{item.teacherName || 'Преподаватель не указан'}</h3>
|
||||
<p>{item.reason || 'Причина не указана'}</p>
|
||||
</div>
|
||||
<span className={`absence-status ${statusClass(item.status)}`}>{statusLabel(item.status)}</span>
|
||||
</div>
|
||||
<div className="absence-meta-row">
|
||||
<span>{formatAbsenceDate(item.startDate)} — {formatAbsenceDate(item.endDate)}</span>
|
||||
<span>{decisionCountLabel(item.decisionCount || 0)}</span>
|
||||
{item.reviewComment ? <span>{item.reviewComment}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="absence-registry-buttons">
|
||||
{item.status === 'PENDING' ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-primary"
|
||||
disabled={registryBusy?.id === item.id && registryBusy?.action === 'approve'}
|
||||
onClick={() => void handleRegistryAction(item, 'approve')}
|
||||
>
|
||||
Подтвердить
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-danger-subtle"
|
||||
disabled={registryBusy?.id === item.id && registryBusy?.action === 'reject'}
|
||||
onClick={() => void handleRegistryAction(item, 'reject')}
|
||||
>
|
||||
Отклонить
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
{['APPROVED', 'RESOLVED'].includes(item.status) ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-secondary"
|
||||
disabled={registryBusy?.id === item.id && registryBusy?.action === 'wizard'}
|
||||
onClick={() => void handleRegistryAction(item, 'wizard')}
|
||||
>
|
||||
{item.status === 'APPROVED' ? 'Открыть мастер' : 'Открыть журнал'}
|
||||
</button>
|
||||
) : null}
|
||||
{['PENDING', 'APPROVED'].includes(item.status) ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-ghost"
|
||||
disabled={registryBusy?.id === item.id && registryBusy?.action === 'cancel'}
|
||||
onClick={() => void handleRegistryAction(item, 'cancel')}
|
||||
>
|
||||
Отменить
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</article>
|
||||
)) : (
|
||||
<div className="absence-empty">
|
||||
<strong>Инцидентов не найдено</strong>
|
||||
<span>Измените фильтр или зарегистрируйте отсутствие.</span>
|
||||
</div>
|
||||
));
|
||||
|
||||
const preferenceContent = preferencePhase === 'idle'
|
||||
? null
|
||||
: preferencePhase === 'meta-loading'
|
||||
? <div className="loading-row">Загрузка семестров...</div>
|
||||
: preferencePhase === 'loading'
|
||||
? <div className="loading-row">Загрузка пожеланий...</div>
|
||||
: preferencePhase === 'error'
|
||||
? <div className="loading-row">{preferenceError}</div>
|
||||
: (preferenceItems.length ? preferenceItems.map(item => (
|
||||
<article className="teacher-dialogue-item" key={String(item.id)}>
|
||||
<span className={`teacher-dialogue-mark preference-${item.preferenceType.toLowerCase()}`}></span>
|
||||
<div className="teacher-dialogue-copy">
|
||||
<small>
|
||||
{item.academicYearTitle} · {item.semesterType === 'AUTUMN' ? 'осенний семестр' : 'весенний семестр'}
|
||||
</small>
|
||||
<h3>{item.teacherName}</h3>
|
||||
<p>{preferenceDescription(item)}</p>
|
||||
{item.comment ? <blockquote>{item.comment}</blockquote> : null}
|
||||
{item.reviewComment
|
||||
? <span className="teacher-dialogue-review">Решение: {item.reviewComment}</span>
|
||||
: null}
|
||||
</div>
|
||||
<div className="teacher-dialogue-action">
|
||||
<span className={`absence-status ${statusClass(item.status)}`}>{genericStatusLabel(item.status)}</span>
|
||||
{item.status === 'PENDING' ? (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
className="teacher-dialogue-comment"
|
||||
maxLength="2000"
|
||||
placeholder="Комментарий к решению"
|
||||
value={preferenceComments[item.id] || ''}
|
||||
onChange={event => setPreferenceComments(previous => ({
|
||||
...previous,
|
||||
[item.id]: event.target.value
|
||||
}))}
|
||||
/>
|
||||
<div>
|
||||
<button
|
||||
className="btn btn-sm btn-primary"
|
||||
type="button"
|
||||
disabled={preferenceBusy?.id === item.id}
|
||||
onClick={() => void handlePreferenceReview(item, true)}
|
||||
>
|
||||
Согласовать
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm btn-danger-subtle"
|
||||
type="button"
|
||||
disabled={preferenceBusy?.id === item.id}
|
||||
onClick={() => void handlePreferenceReview(item, false)}
|
||||
>
|
||||
Отклонить
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</article>
|
||||
)) : (
|
||||
<div className="absence-empty">
|
||||
<strong>Пожеланий не найдено</strong>
|
||||
<span>Измените фильтры или дождитесь новых отметок преподавателей.</span>
|
||||
</div>
|
||||
));
|
||||
|
||||
const changeContent = changePhase === 'idle'
|
||||
? null
|
||||
: changePhase === 'loading'
|
||||
? <div className="loading-row">Загрузка заявок...</div>
|
||||
: changePhase === 'error'
|
||||
? <div className="loading-row">{changeError}</div>
|
||||
: (changeItems.length ? changeItems.map(item => (
|
||||
<article className="teacher-dialogue-item change-item" key={String(item.id)}>
|
||||
<span className={`teacher-dialogue-mark change-${item.requestType.toLowerCase()}`}></span>
|
||||
<div className="teacher-dialogue-copy">
|
||||
<small>
|
||||
{formatAbsenceDate(item.lessonDate)} · {trimTime(item.sourceStartTime)}–{trimTime(item.sourceEndTime)} · {item.sourceClassroomName}
|
||||
</small>
|
||||
<h3>{item.teacherName} · {item.subjectName}</h3>
|
||||
<p>{changeTypeLabel(item.requestType)} → <strong>{changeReviewTarget(item)}</strong></p>
|
||||
<blockquote>{item.reason}</blockquote>
|
||||
{item.reviewComment
|
||||
? <span className="teacher-dialogue-review">Решение: {item.reviewComment}</span>
|
||||
: null}
|
||||
<details>
|
||||
<summary>История · {(item.history || []).length}</summary>
|
||||
<ol>
|
||||
{(item.history || []).map((entry, index) => (
|
||||
<li key={index}>
|
||||
<b>{genericStatusLabel(entry.status)}</b> · {entry.comment || 'Без комментария'}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</details>
|
||||
</div>
|
||||
<div className="teacher-dialogue-action">
|
||||
<span className={`absence-status ${statusClass(item.status)}`}>{genericStatusLabel(item.status)}</span>
|
||||
{item.status === 'PENDING' && canApplyChanges ? (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
className="teacher-dialogue-comment"
|
||||
maxLength="2000"
|
||||
placeholder="Комментарий к решению"
|
||||
value={changeComments[item.id] || ''}
|
||||
onChange={event => setChangeComments(previous => ({
|
||||
...previous,
|
||||
[item.id]: event.target.value
|
||||
}))}
|
||||
/>
|
||||
<div>
|
||||
<button
|
||||
className="btn btn-sm btn-primary"
|
||||
type="button"
|
||||
disabled={changeBusy?.id === item.id}
|
||||
onClick={() => void handleChangeReview(item, true)}
|
||||
>
|
||||
Применить
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm btn-danger-subtle"
|
||||
type="button"
|
||||
disabled={changeBusy?.id === item.id}
|
||||
onClick={() => void handleChangeReview(item, false)}
|
||||
>
|
||||
Отклонить
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : item.status === 'PENDING'
|
||||
? <small>Применение доступно учебному отделу</small>
|
||||
: null}
|
||||
</div>
|
||||
</article>
|
||||
)) : (
|
||||
<div className="absence-empty">
|
||||
<strong>Заявок не найдено</strong>
|
||||
<span>Очередь изменений сейчас пуста.</span>
|
||||
</div>
|
||||
));
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="absence-command-card">
|
||||
<div className="absence-command-copy">
|
||||
<span className="absence-kicker">Оперативный контур расписания</span>
|
||||
<h2>От регистрации инцидента до готовой замены</h2>
|
||||
<p>Зафиксируйте период отсутствия, проверьте затронутые пары и примените только выбранные решения.</p>
|
||||
</div>
|
||||
<div className="absence-command-stats" aria-label="Сводка отсутствий">
|
||||
<article><strong id="absence-stat-active">{stats.active}</strong><span>требуют внимания</span></article>
|
||||
<article><strong id="absence-stat-pending">{stats.pending}</strong><span>ждут подтверждения</span></article>
|
||||
<article><strong id="absence-stat-resolved">{stats.resolved}</strong><span>разрешены</span></article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card absence-create-card">
|
||||
<div className="card-header-row">
|
||||
<div>
|
||||
<span className="absence-section-number">01</span>
|
||||
<h2>Зарегистрировать отсутствие</h2>
|
||||
</div>
|
||||
<span className="muted">Ответственный сотрудник создаёт согласованную запись сразу</span>
|
||||
</div>
|
||||
<form id="teacher-absence-form" noValidate onSubmit={handleSubmit}>
|
||||
<div className="absence-form-grid">
|
||||
<div className="form-group absence-teacher-field">
|
||||
<label htmlFor="teacher-absence-teacher">Преподаватель</label>
|
||||
<AsyncCombobox
|
||||
ariaLabel="Преподаватель"
|
||||
placeholder="Найдите преподавателя по ФИО"
|
||||
selectedValue={teacherSelection.id}
|
||||
selectedLabel={teacherSelection.label}
|
||||
optionLabel={teacherOptionLabel}
|
||||
optionDescription={teacher => [
|
||||
teacher.jobTitle,
|
||||
teacher.departmentName
|
||||
].filter(Boolean).join(' · ')}
|
||||
load={(query, selectedId) => fetchAbsenceTeacherOptions({
|
||||
query,
|
||||
selectedId,
|
||||
role,
|
||||
departmentId: session?.departmentId
|
||||
})}
|
||||
onSelect={item => setTeacherSelection(item
|
||||
? { id: String(item.id), label: teacherOptionLabel(item) }
|
||||
: { id: '', label: '' })}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="teacher-absence-start">Начало</label>
|
||||
<input
|
||||
type="date"
|
||||
id="teacher-absence-start"
|
||||
required
|
||||
value={startDate}
|
||||
onChange={event => setStartDate(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="teacher-absence-end">Окончание</label>
|
||||
<input
|
||||
type="date"
|
||||
id="teacher-absence-end"
|
||||
required
|
||||
value={endDate}
|
||||
onChange={event => setEndDate(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group absence-reason-field">
|
||||
<label htmlFor="teacher-absence-reason">Причина</label>
|
||||
<input
|
||||
type="text"
|
||||
id="teacher-absence-reason"
|
||||
maxLength="2000"
|
||||
placeholder="Болезнь, командировка или другая причина"
|
||||
required
|
||||
value={reason}
|
||||
onChange={event => setReason(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="btn btn-md btn-primary absence-create-submit">
|
||||
Зарегистрировать
|
||||
</button>
|
||||
</div>
|
||||
<FormAlert id="teacher-absence-form-alert" alert={formAlert} />
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="card absence-registry-card">
|
||||
<div className="card-header-row absence-registry-head">
|
||||
<div>
|
||||
<span className="absence-section-number">02</span>
|
||||
<h2>Реестр инцидентов</h2>
|
||||
</div>
|
||||
<div className="absence-registry-actions">
|
||||
<select
|
||||
id="teacher-absence-status-filter"
|
||||
aria-label="Фильтр по статусу"
|
||||
value={statusFilter}
|
||||
onChange={event => {
|
||||
setStatusFilter(event.target.value || '');
|
||||
void loadAbsences(event.target.value || '');
|
||||
}}
|
||||
>
|
||||
<option value="">Все статусы</option>
|
||||
<option value="PENDING">На подтверждении</option>
|
||||
<option value="APPROVED">Согласованы</option>
|
||||
<option value="RESOLVED">Разрешены</option>
|
||||
<option value="REJECTED">Отклонены</option>
|
||||
<option value="CANCELLED">Отменены</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-md btn-secondary"
|
||||
id="teacher-absence-refresh"
|
||||
onClick={() => void loadAbsences()}
|
||||
>
|
||||
Обновить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absence-registry" id="teacher-absence-registry" aria-live="polite">
|
||||
{registryContent}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="teacher-dialogue-grid">
|
||||
<article className="card teacher-dialogue-card">
|
||||
<header className="teacher-dialogue-head">
|
||||
<div>
|
||||
<span className="absence-section-number">03</span>
|
||||
<h2>Пожелания на семестр</h2>
|
||||
<p>Строгие ограничения после согласования участвуют в проверке правил. Мягкие отметки остаются видимой подсказкой.</p>
|
||||
</div>
|
||||
<div className="teacher-dialogue-counter">
|
||||
<strong id="teacher-preference-pending-count">
|
||||
{preferenceItems.filter(item => item.status === 'PENDING').length}
|
||||
</strong>
|
||||
<span>ожидают</span>
|
||||
</div>
|
||||
</header>
|
||||
<div className="teacher-dialogue-toolbar">
|
||||
<select
|
||||
id="teacher-preference-semester-filter"
|
||||
aria-label="Семестр пожеланий"
|
||||
value={preferenceSemesterId}
|
||||
onChange={event => {
|
||||
setPreferenceSemesterId(event.target.value || '');
|
||||
void loadPreferenceReviews({ semesterId: event.target.value || '' });
|
||||
}}
|
||||
>
|
||||
<option value="">Все семестры</option>
|
||||
{preferenceSemesters.map(semester => (
|
||||
<option key={String(semester.id)} value={String(semester.id)}>
|
||||
{absenceSemesterLabel(semester)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
id="teacher-preference-status-filter"
|
||||
aria-label="Статус пожеланий"
|
||||
value={preferenceStatusFilter}
|
||||
onChange={event => {
|
||||
setPreferenceStatusFilter(event.target.value || '');
|
||||
void loadPreferenceReviews({ status: event.target.value || '' });
|
||||
}}
|
||||
>
|
||||
<option value="PENDING">На рассмотрении</option>
|
||||
<option value="APPROVED">Согласованные</option>
|
||||
<option value="">Все статусы</option>
|
||||
</select>
|
||||
<button
|
||||
className="btn btn-md btn-secondary"
|
||||
id="teacher-preference-refresh"
|
||||
type="button"
|
||||
onClick={() => void loadPreferenceReviews()}
|
||||
>
|
||||
Обновить
|
||||
</button>
|
||||
</div>
|
||||
<div className="teacher-dialogue-list" id="teacher-preference-review-list" aria-live="polite">
|
||||
{preferenceContent}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="card teacher-dialogue-card change-review-card">
|
||||
<header className="teacher-dialogue-head">
|
||||
<div>
|
||||
<span className="absence-section-number">04</span>
|
||||
<h2>Заявки на изменение</h2>
|
||||
<p>Каждое одобрение проходит повторную проверку конфликтов и применяется через точечное изменение расписания.</p>
|
||||
</div>
|
||||
<div className="teacher-dialogue-counter">
|
||||
<strong id="teacher-change-pending-count">
|
||||
{changeItems.filter(item => item.status === 'PENDING').length}
|
||||
</strong>
|
||||
<span>в очереди</span>
|
||||
</div>
|
||||
</header>
|
||||
<div className="teacher-dialogue-toolbar">
|
||||
<select
|
||||
id="teacher-change-status-filter"
|
||||
aria-label="Статус заявок"
|
||||
value={changeStatusFilter}
|
||||
onChange={event => {
|
||||
setChangeStatusFilter(event.target.value || '');
|
||||
void loadChangeReviews(event.target.value || '');
|
||||
}}
|
||||
>
|
||||
<option value="PENDING">Ожидают решения</option>
|
||||
<option value="APPROVED">Одобренные</option>
|
||||
<option value="REJECTED">Отклонённые</option>
|
||||
<option value="">Все статусы</option>
|
||||
</select>
|
||||
<button
|
||||
className="btn btn-md btn-secondary"
|
||||
id="teacher-change-refresh"
|
||||
type="button"
|
||||
onClick={() => void loadChangeReviews()}
|
||||
>
|
||||
Обновить
|
||||
</button>
|
||||
</div>
|
||||
<div className="teacher-dialogue-list" id="teacher-change-review-list" aria-live="polite">
|
||||
{changeContent}
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
{wizardOpen ? (
|
||||
<div
|
||||
className="absence-wizard-overlay"
|
||||
id="absence-wizard-overlay"
|
||||
onClick={event => {
|
||||
if (event.target === event.currentTarget) closeWizard();
|
||||
}}
|
||||
>
|
||||
<section className="absence-wizard" role="dialog" aria-modal="true" aria-labelledby="absence-wizard-title">
|
||||
<header className="absence-wizard-header">
|
||||
<div>
|
||||
<span className="absence-kicker">Мастер замены</span>
|
||||
<h2 id="absence-wizard-title">
|
||||
{wizardData ? (wizardData.absence.teacherName || 'Мастер замены') : 'Затронутые занятия'}
|
||||
</h2>
|
||||
<p id="absence-wizard-summary">
|
||||
{wizardData
|
||||
? `${formatAbsenceDate(wizardData.absence.startDate)} — ${formatAbsenceDate(wizardData.absence.endDate)} · ${wizardData.absence.reason}`
|
||||
: 'Загрузка...'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-icon-md btn-ghost"
|
||||
id="absence-wizard-close"
|
||||
aria-label="Закрыть"
|
||||
onClick={closeWizard}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
<div className="absence-wizard-progress" aria-hidden="true">
|
||||
<span className="done">Инцидент</span><i></i><span className="done">Занятия</span><i></i><span>Решения</span>
|
||||
</div>
|
||||
<FormAlert id="absence-wizard-alert" alert={wizardAlert} />
|
||||
<div className="absence-wizard-lessons" id="absence-wizard-lessons">
|
||||
{wizardLoading
|
||||
? <div className="loading-row">Подбираем свободные ресурсы...</div>
|
||||
: (wizardData
|
||||
? (wizardData.lessons.length
|
||||
? wizardData.lessons.map((lesson, index) => (
|
||||
<LessonDecision
|
||||
key={index}
|
||||
lesson={lesson}
|
||||
index={index}
|
||||
locked={wizardData.absence.status !== 'APPROVED' || Boolean(lesson.decisionStatus)}
|
||||
value={decisions[index] || ''}
|
||||
onChange={value => setDecisions(previous => ({ ...previous, [index]: value }))}
|
||||
/>
|
||||
))
|
||||
: (
|
||||
<div className="absence-empty">
|
||||
<strong>Затронутых занятий нет</strong>
|
||||
<span>Инцидент можно завершить без точечных изменений.</span>
|
||||
</div>
|
||||
))
|
||||
: null)}
|
||||
</div>
|
||||
<section className="absence-journal">
|
||||
<div className="card-header-row">
|
||||
<h3>Журнал решений</h3>
|
||||
<span className="muted" id="absence-journal-count">
|
||||
{wizardData ? decisionCountLabel((wizardData.journal || []).length) : '0 записей'}
|
||||
</span>
|
||||
</div>
|
||||
<div id="absence-journal-list" className="absence-journal-list">
|
||||
{wizardData ? (
|
||||
(wizardData.journal || []).length ? (wizardData.journal || []).map((item, index) => (
|
||||
<article key={index}>
|
||||
<span className={`absence-journal-mark ${item.status === 'APPLIED' ? 'applied' : 'rejected'}`}></span>
|
||||
<div>
|
||||
<strong>{resolutionLabel(item.resolution)}</strong>
|
||||
<small>{formatAbsenceDate(item.lessonDate)}</small>
|
||||
</div>
|
||||
<span>
|
||||
{decisionStatusLabel(item.status)}{item.scheduleOverrideId ? ` · override №${item.scheduleOverrideId}` : ''}
|
||||
</span>
|
||||
</article>
|
||||
)) : <div className="loading-row">Решений пока нет</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
<footer className="absence-wizard-footer">
|
||||
<span className="muted">Пустые строки останутся без изменений</span>
|
||||
<div>
|
||||
<button type="button" className="btn btn-md btn-ghost" id="absence-wizard-cancel" onClick={closeWizard}>
|
||||
Закрыть
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-md btn-primary"
|
||||
id="absence-wizard-apply"
|
||||
hidden={wizardData?.absence.status !== 'APPROVED'}
|
||||
disabled={wizardApplying}
|
||||
onClick={() => void applyWizard()}
|
||||
>
|
||||
Применить выбранные
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{alert ? (
|
||||
<div
|
||||
className="project-dialog-backdrop"
|
||||
onClick={event => {
|
||||
if (event.target === event.currentTarget) setAlert(null);
|
||||
}}
|
||||
>
|
||||
<section
|
||||
className="project-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="project-dialog-title"
|
||||
aria-describedby="project-dialog-message"
|
||||
>
|
||||
<header>
|
||||
<h2 id="project-dialog-title">{alert.title}</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-icon-md btn-ghost"
|
||||
aria-label="Закрыть"
|
||||
onClick={() => setAlert(null)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
<p id="project-dialog-message">{alert.message}</p>
|
||||
<footer>
|
||||
<button type="button" className="btn btn-md btn-primary" onClick={() => setAlert(null)}>
|
||||
Понятно
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function LessonDecision({ lesson, index, locked, value, onChange }) {
|
||||
const options = decisionOptions(lesson);
|
||||
|
||||
return (
|
||||
<article className="absence-lesson">
|
||||
<div className="absence-lesson-time">
|
||||
<strong>{trimTime(lesson.startTime)}</strong>
|
||||
<span>{trimTime(lesson.endTime)}</span>
|
||||
<small>{formatAbsenceDate(lesson.lessonDate)}</small>
|
||||
</div>
|
||||
<div className="absence-lesson-copy">
|
||||
<span className="absence-lesson-type">{lesson.lessonTypeName || 'Занятие'}</span>
|
||||
<h3>{lesson.subjectName || 'Дисциплина не указана'}</h3>
|
||||
<p>
|
||||
{(lesson.groupNames || []).join(', ') || 'Группы не указаны'} · {lesson.classroomName || 'Без аудитории'}
|
||||
</p>
|
||||
{lesson.existingOverrideId
|
||||
? <small className="absence-warning">У пары уже есть точечное изменение — доступна только запись отклонения.</small>
|
||||
: null}
|
||||
</div>
|
||||
<div className="absence-lesson-decision">
|
||||
<label htmlFor={`absence-decision-${index}`}>Решение</label>
|
||||
<select
|
||||
id={`absence-decision-${index}`}
|
||||
className="absence-decision-select"
|
||||
disabled={locked}
|
||||
value={value}
|
||||
onChange={event => onChange(event.target.value)}
|
||||
>
|
||||
<option value="">Оставить без изменений</option>
|
||||
{options.map(option => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
{lesson.decisionStatus
|
||||
? <span className="absence-decision-fixed">{decisionStatusLabel(lesson.decisionStatus)}</span>
|
||||
: null}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
372
frontend/react/admin/tabs/TeacherRequestsTab.jsx
Normal file
372
frontend/react/admin/tabs/TeacherRequestsTab.jsx
Normal file
@@ -0,0 +1,372 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { readUrlState, writeUrlState } from '../../../admin/js/url-state.js';
|
||||
import { trackUxEvent } from '../../../telemetry.js';
|
||||
import { PromptDialog } from '../../shared/ui/ConfirmDialog.jsx';
|
||||
import { Pagination } from '../../shared/ui/Pagination.jsx';
|
||||
import { requestCountLabel } from '../admin-service.js';
|
||||
import {
|
||||
REQUEST_STATUS_OPTIONS,
|
||||
REQUEST_TABLE_COLUMNS,
|
||||
approvePayloadFromFields,
|
||||
approveRequest,
|
||||
fetchDepartments,
|
||||
fetchTeacherRequestsPage,
|
||||
initialTeacherRequestsState,
|
||||
pendingCountDetail,
|
||||
rejectRequest,
|
||||
requestDepartmentLabel,
|
||||
reviewedStatusLabel,
|
||||
teacherRequestsEmptyMessage,
|
||||
teacherRequestsUrlState
|
||||
} from '../teacher-requests-service.js';
|
||||
|
||||
const SEARCH_DEBOUNCE_MS = 320;
|
||||
|
||||
export function TeacherRequestsTab() {
|
||||
const [state, setState] = useState(() => initialTeacherRequestsState(
|
||||
typeof window === 'undefined' ? {} : readUrlState({
|
||||
requestQuery: '',
|
||||
requestStatus: 'PENDING',
|
||||
requestPage: '0',
|
||||
requestSize: '25'
|
||||
})
|
||||
));
|
||||
const [searchInput, setSearchInput] = useState(() => state.query);
|
||||
const [departments, setDepartments] = useState([]);
|
||||
const [departmentsError, setDepartmentsError] = useState(false);
|
||||
const [pageData, setPageData] = useState(null);
|
||||
const [tableError, setTableError] = useState('');
|
||||
const [tableLoading, setTableLoading] = useState(true);
|
||||
const [alert, setAlert] = useState({ text: '', isError: false });
|
||||
const [rejectState, setRejectState] = useState(null);
|
||||
|
||||
const searchTimer = useRef(null);
|
||||
const requestNumber = useRef(0);
|
||||
const stateRef = useRef(state);
|
||||
stateRef.current = state;
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
(async () => {
|
||||
try {
|
||||
const loadedDepartments = await fetchDepartments();
|
||||
if (!active) return;
|
||||
setDepartments(loadedDepartments || []);
|
||||
await loadRequests(stateRef.current);
|
||||
} catch {
|
||||
if (!active) return;
|
||||
setTableLoading(false);
|
||||
setDepartmentsError(true);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
active = false;
|
||||
requestNumber.current += 1;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => () => {
|
||||
window.clearTimeout(searchTimer.current);
|
||||
}, []);
|
||||
|
||||
async function loadRequests(nextState) {
|
||||
const currentRequest = requestNumber.current + 1;
|
||||
requestNumber.current = currentRequest;
|
||||
setTableLoading(true);
|
||||
setTableError('');
|
||||
setAlert(current => (current.text ? { text: '', isError: false } : current));
|
||||
try {
|
||||
let page = await fetchTeacherRequestsPage(nextState);
|
||||
if (!(page.items || []).length && page.totalItems > 0 && nextState.page > 0) {
|
||||
nextState = { ...nextState, page: Math.max(0, page.totalPages - 1) };
|
||||
page = await fetchTeacherRequestsPage(nextState);
|
||||
}
|
||||
if (requestNumber.current !== currentRequest) return;
|
||||
setState(nextState);
|
||||
setPageData(page);
|
||||
setTableLoading(false);
|
||||
writeUrlState(teacherRequestsUrlState(nextState));
|
||||
document.dispatchEvent(new CustomEvent('teacherRequestsChanged', {
|
||||
detail: pendingCountDetail(nextState, page.totalItems)
|
||||
}));
|
||||
trackUxEvent('реестр_заявок_загружен', {
|
||||
total: Number(page.totalItems || 0),
|
||||
page: nextState.page
|
||||
});
|
||||
} catch {
|
||||
if (requestNumber.current !== currentRequest) return;
|
||||
setState(nextState);
|
||||
setPageData(null);
|
||||
setTableLoading(false);
|
||||
setTableError('Не удалось загрузить заявки преподавателей');
|
||||
}
|
||||
}
|
||||
|
||||
function applyState(patch) {
|
||||
void loadRequests({ ...stateRef.current, ...patch });
|
||||
}
|
||||
|
||||
function handleSearchInput(value) {
|
||||
setSearchInput(value);
|
||||
window.clearTimeout(searchTimer.current);
|
||||
searchTimer.current = window.setTimeout(() => {
|
||||
applyState({ query: value.trim(), page: 0 });
|
||||
}, SEARCH_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
async function approve(requestId, fields) {
|
||||
const { payload, error } = approvePayloadFromFields(fields);
|
||||
if (error) {
|
||||
setAlert({ text: error, isError: true });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await approveRequest(requestId, payload);
|
||||
setAlert({ text: 'Преподаватель создан, заявка одобрена', isError: false });
|
||||
await loadRequests(stateRef.current);
|
||||
} catch (err) {
|
||||
setAlert({ text: err.message || 'Ошибка одобрения заявки', isError: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmReject(reviewComment) {
|
||||
const requestId = rejectState?.requestId;
|
||||
setRejectState(null);
|
||||
if (!requestId) return;
|
||||
try {
|
||||
await rejectRequest(requestId, reviewComment);
|
||||
setAlert({ text: 'Заявка отклонена', isError: false });
|
||||
await loadRequests(stateRef.current);
|
||||
} catch (err) {
|
||||
setAlert({ text: err.message || 'Ошибка отклонения заявки', isError: true });
|
||||
}
|
||||
}
|
||||
|
||||
const requests = pageData?.items || [];
|
||||
const countLabel = requestCountLabel(pageData ? Number(pageData.totalItems || 0) : 0);
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-header-row">
|
||||
<h2>Заявки на преподавателей</h2>
|
||||
<div className="teacher-request-toolbar">
|
||||
<span className="muted">{countLabel}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-secondary"
|
||||
onClick={() => void loadRequests(stateRef.current)}
|
||||
>
|
||||
Обновить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`form-alert${alert.text ? (alert.isError ? ' error' : ' success') : ''}`}
|
||||
role="alert"
|
||||
>
|
||||
{alert.text}
|
||||
</div>
|
||||
<div className="registry-toolbar">
|
||||
<div className="form-group registry-search">
|
||||
<label htmlFor="teacher-requests-search">Поиск</label>
|
||||
<input
|
||||
type="search"
|
||||
id="teacher-requests-search"
|
||||
placeholder="ФИО, логин, должность или кафедра"
|
||||
value={searchInput}
|
||||
onChange={event => handleSearchInput(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="teacher-requests-status">Статус</label>
|
||||
<select
|
||||
id="teacher-requests-status"
|
||||
value={state.status}
|
||||
onChange={event => applyState({ status: event.target.value, page: 0 })}
|
||||
>
|
||||
{REQUEST_STATUS_OPTIONS.map(option => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Кафедра</th>
|
||||
<th>Логин</th>
|
||||
<th>ФИО</th>
|
||||
<th>Должность</th>
|
||||
<th>Пароль</th>
|
||||
<th>Комментарий</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="teacher-requests-tbody">
|
||||
{departmentsError ? (
|
||||
<tr>
|
||||
<td colSpan={REQUEST_TABLE_COLUMNS} className="loading-row">
|
||||
Не удалось загрузить заявки преподавателей
|
||||
</td>
|
||||
</tr>
|
||||
) : tableError ? (
|
||||
<tr>
|
||||
<td colSpan={REQUEST_TABLE_COLUMNS} className="loading-row view-state-error">
|
||||
<span>{tableError}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-secondary"
|
||||
onClick={() => void loadRequests(stateRef.current)}
|
||||
>
|
||||
Повторить
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
) : tableLoading ? (
|
||||
<tr>
|
||||
<td colSpan={REQUEST_TABLE_COLUMNS} className="loading-row">
|
||||
<span className="state-spinner" aria-hidden="true" />Загрузка заявок...
|
||||
</td>
|
||||
</tr>
|
||||
) : requests.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={REQUEST_TABLE_COLUMNS} className="loading-row view-state-empty">
|
||||
{teacherRequestsEmptyMessage(state)}
|
||||
</td>
|
||||
</tr>
|
||||
) : requests.map(request => (
|
||||
<TeacherRequestRow
|
||||
key={request.id}
|
||||
request={request}
|
||||
departments={departments}
|
||||
onApprove={approve}
|
||||
onReject={requestId => setRejectState({ requestId })}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{pageData && (
|
||||
<Pagination
|
||||
page={pageData.page}
|
||||
size={pageData.size}
|
||||
totalItems={pageData.totalItems}
|
||||
totalPages={pageData.totalPages}
|
||||
label="заявок"
|
||||
onPage={page => applyState({ page })}
|
||||
onSize={size => applyState({ size, page: 0 })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{rejectState && (
|
||||
<PromptDialog
|
||||
title="Отклонить заявку?"
|
||||
message="Комментарий увидит кафедра, отправившая заявку."
|
||||
confirmLabel="Отклонить"
|
||||
danger
|
||||
input={{
|
||||
label: 'Причина отклонения',
|
||||
placeholder: 'Укажите, что нужно исправить',
|
||||
required: true,
|
||||
requiredMessage: 'Укажите причину отклонения'
|
||||
}}
|
||||
onConfirm={value => void confirmReject(value)}
|
||||
onClose={() => setRejectState(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Строка заявки хранит черновик полей локально: значения инпутов строки
|
||||
// отправляются при одобрении — как в legacy-реестре.
|
||||
function TeacherRequestRow({ request, departments, onApprove, onReject }) {
|
||||
const [departmentId, setDepartmentId] = useState(String(request.departmentId ?? ''));
|
||||
const [username, setUsername] = useState(request.username || '');
|
||||
const [fullName, setFullName] = useState(request.fullName || '');
|
||||
const [jobTitle, setJobTitle] = useState(request.jobTitle || '');
|
||||
const [password, setPassword] = useState('');
|
||||
|
||||
function approve() {
|
||||
onApprove(request.id, { departmentId, username, fullName, jobTitle, password });
|
||||
}
|
||||
|
||||
return (
|
||||
<tr>
|
||||
<td>
|
||||
<select
|
||||
value={departmentId}
|
||||
onChange={event => setDepartmentId(event.target.value)}
|
||||
>
|
||||
{departments.map(department => (
|
||||
<option key={department.id} value={department.id}>
|
||||
{requestDepartmentLabel(department)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type="text"
|
||||
maxLength={50}
|
||||
autoComplete="username"
|
||||
value={username}
|
||||
onChange={event => setUsername(event.target.value)}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type="text"
|
||||
maxLength={255}
|
||||
value={fullName}
|
||||
onChange={event => setFullName(event.target.value)}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type="text"
|
||||
maxLength={255}
|
||||
value={jobTitle}
|
||||
onChange={event => setJobTitle(event.target.value)}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type="password"
|
||||
className="teacher-request-password"
|
||||
placeholder="Минимум 8 символов"
|
||||
autoComplete="new-password"
|
||||
value={password}
|
||||
onChange={event => setPassword(event.target.value)}
|
||||
/>
|
||||
</td>
|
||||
<td>{request.comment || '-'}</td>
|
||||
<td>
|
||||
<div className="teacher-request-actions">
|
||||
{request.status === 'PENDING' ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-primary teacher-request-approve"
|
||||
onClick={approve}
|
||||
>
|
||||
Одобрить
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-danger-subtle teacher-request-reject"
|
||||
onClick={() => onReject(request.id)}
|
||||
>
|
||||
Отклонить
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<span className="badge">{reviewedStatusLabel(request.status)}</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
1094
frontend/react/admin/tabs/UniversityStructureTab.jsx
Normal file
1094
frontend/react/admin/tabs/UniversityStructureTab.jsx
Normal file
File diff suppressed because it is too large
Load Diff
399
frontend/react/admin/tabs/UsersTab.jsx
Normal file
399
frontend/react/admin/tabs/UsersTab.jsx
Normal file
@@ -0,0 +1,399 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { readUrlState, writeUrlState } from '../../../admin/js/url-state.js';
|
||||
import { trackUxEvent } from '../../../telemetry.js';
|
||||
import { AlertDialog, ConfirmDialog } from '../../shared/ui/ConfirmDialog.jsx';
|
||||
import { Pagination } from '../../shared/ui/Pagination.jsx';
|
||||
import {
|
||||
ROLE_FILTER_OPTIONS,
|
||||
USER_SORT_OPTIONS,
|
||||
USER_TABLE_COLUMNS,
|
||||
archiveUser,
|
||||
createUser,
|
||||
fetchUsersPage,
|
||||
initialUsersState,
|
||||
restoreUser,
|
||||
roleBadgeClass,
|
||||
roleLabel,
|
||||
usersEmptyMessage,
|
||||
usersUrlState
|
||||
} from '../users-service.js';
|
||||
|
||||
const SEARCH_DEBOUNCE_MS = 320;
|
||||
|
||||
export function UsersTab() {
|
||||
const [state, setState] = useState(() => initialUsersState(
|
||||
typeof window === 'undefined' ? {} : readUrlState({
|
||||
userQuery: '',
|
||||
userRole: '',
|
||||
userPage: '0',
|
||||
userSize: '25',
|
||||
userSort: 'fullName,asc'
|
||||
})
|
||||
));
|
||||
const [searchInput, setSearchInput] = useState(() => state.query);
|
||||
const [pageData, setPageData] = useState(null);
|
||||
const [tableError, setTableError] = useState('');
|
||||
const [tableLoading, setTableLoading] = useState(true);
|
||||
|
||||
const [createUsername, setCreateUsername] = useState('');
|
||||
const [createPassword, setCreatePassword] = useState('');
|
||||
const [createRole, setCreateRole] = useState('STUDENT');
|
||||
const [createFullName, setCreateFullName] = useState('');
|
||||
const [createJobTitle, setCreateJobTitle] = useState('');
|
||||
const [createDepartmentId, setCreateDepartmentId] = useState('');
|
||||
const [createAlert, setCreateAlert] = useState({ text: '', isError: false });
|
||||
|
||||
const [confirmState, setConfirmState] = useState(null);
|
||||
const [alertState, setAlertState] = useState(null);
|
||||
|
||||
const searchTimer = useRef(null);
|
||||
const requestNumber = useRef(0);
|
||||
const stateRef = useRef(state);
|
||||
stateRef.current = state;
|
||||
|
||||
useEffect(() => {
|
||||
void loadUsers(stateRef.current);
|
||||
return () => {
|
||||
requestNumber.current += 1;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => () => {
|
||||
window.clearTimeout(searchTimer.current);
|
||||
}, []);
|
||||
|
||||
async function loadUsers(nextState) {
|
||||
const currentRequest = requestNumber.current + 1;
|
||||
requestNumber.current = currentRequest;
|
||||
setTableLoading(true);
|
||||
setTableError('');
|
||||
try {
|
||||
let page = await fetchUsersPage(nextState);
|
||||
if (!(page.items || []).length && page.totalItems > 0 && nextState.page > 0) {
|
||||
nextState = { ...nextState, page: Math.max(0, page.totalPages - 1) };
|
||||
page = await fetchUsersPage(nextState);
|
||||
}
|
||||
if (requestNumber.current !== currentRequest) return;
|
||||
setState(nextState);
|
||||
setPageData(page);
|
||||
setTableLoading(false);
|
||||
writeUrlState(usersUrlState(nextState));
|
||||
trackUxEvent('реестр_пользователей_загружен', {
|
||||
total: Number(page.totalItems || 0),
|
||||
page: nextState.page
|
||||
});
|
||||
} catch (error) {
|
||||
if (requestNumber.current !== currentRequest) return;
|
||||
setState(nextState);
|
||||
setTableLoading(false);
|
||||
setTableError(error.message || 'Не удалось загрузить пользователей');
|
||||
}
|
||||
}
|
||||
|
||||
function applyState(patch) {
|
||||
void loadUsers({ ...stateRef.current, ...patch });
|
||||
}
|
||||
|
||||
function handleSearchInput(value) {
|
||||
setSearchInput(value);
|
||||
window.clearTimeout(searchTimer.current);
|
||||
searchTimer.current = window.setTimeout(() => {
|
||||
applyState({ query: value.trim(), page: 0 });
|
||||
}, SEARCH_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
async function submitCreate(event) {
|
||||
event.preventDefault();
|
||||
const username = createUsername.trim();
|
||||
const password = createPassword;
|
||||
const fullName = createFullName.trim();
|
||||
const jobTitle = createJobTitle.trim();
|
||||
if (!username || !password || !fullName || !jobTitle || !createDepartmentId) {
|
||||
setCreateAlert({ text: 'Заполните все поля', isError: true });
|
||||
return;
|
||||
}
|
||||
if (password.length < 8) {
|
||||
setCreateAlert({ text: 'Пароль должен быть не короче 8 символов', isError: true });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const created = await createUser({
|
||||
username,
|
||||
password,
|
||||
role: createRole,
|
||||
fullName,
|
||||
jobTitle,
|
||||
departmentId: createDepartmentId
|
||||
});
|
||||
setCreateAlert({ text: `Пользователь «${created.username}» создан`, isError: false });
|
||||
setCreateUsername('');
|
||||
setCreatePassword('');
|
||||
setCreateFullName('');
|
||||
setCreateJobTitle('');
|
||||
setCreateDepartmentId('');
|
||||
await loadUsers(stateRef.current);
|
||||
} catch (error) {
|
||||
setCreateAlert({ text: error.message || 'Ошибка соединения', isError: true });
|
||||
}
|
||||
}
|
||||
|
||||
function confirmArchiveUser(user) {
|
||||
setConfirmState({
|
||||
title: 'Архивировать пользователя?',
|
||||
message: 'Доступ будет отключён, а история расписания и связей сохранится.',
|
||||
confirmLabel: 'Архивировать',
|
||||
danger: true,
|
||||
action: async () => {
|
||||
try {
|
||||
await archiveUser(user.id);
|
||||
await loadUsers(stateRef.current);
|
||||
} catch (error) {
|
||||
setAlertState({ title: 'Ошибка архивирования', message: error.message || 'Ошибка архивирования' });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function restore(user) {
|
||||
try {
|
||||
await restoreUser(user.id);
|
||||
await loadUsers(stateRef.current);
|
||||
} catch (error) {
|
||||
setAlertState({ title: 'Ошибка восстановления', message: error.message || 'Ошибка восстановления' });
|
||||
}
|
||||
}
|
||||
|
||||
async function runConfirm() {
|
||||
const confirm = confirmState;
|
||||
setConfirmState(null);
|
||||
await confirm.action();
|
||||
}
|
||||
|
||||
const users = pageData?.items || [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="card create-card">
|
||||
<h2>Новый пользователь</h2>
|
||||
<form onSubmit={submitCreate}>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label htmlFor="new-username">Имя пользователя</label>
|
||||
<input
|
||||
type="text"
|
||||
id="new-username"
|
||||
placeholder="Имя пользователя"
|
||||
autoComplete="username"
|
||||
required
|
||||
value={createUsername}
|
||||
onChange={event => setCreateUsername(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="new-password">Пароль</label>
|
||||
<input
|
||||
type="password"
|
||||
id="new-password"
|
||||
placeholder="Минимум 8 символов"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
value={createPassword}
|
||||
onChange={event => setCreatePassword(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="new-role">Роль</label>
|
||||
<select
|
||||
id="new-role"
|
||||
value={createRole}
|
||||
onChange={event => setCreateRole(event.target.value)}
|
||||
>
|
||||
<option value="STUDENT">Студент</option>
|
||||
<option value="TEACHER">Преподаватель</option>
|
||||
<option value="DEPARTMENT">Кафедра</option>
|
||||
<option value="EDUCATION_OFFICE">Учебный отдел</option>
|
||||
<option value="SCHEDULE_VIEWER">Просмотр расписаний</option>
|
||||
<option value="ADMIN">Администратор</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="new-fullname">ФИО пользователя</label>
|
||||
<input
|
||||
type="text"
|
||||
id="new-fullname"
|
||||
placeholder="Иванов Иван Иванович"
|
||||
required
|
||||
value={createFullName}
|
||||
onChange={event => setCreateFullName(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="new-jobtitle">Должность</label>
|
||||
<input
|
||||
type="text"
|
||||
id="new-jobtitle"
|
||||
placeholder="Студент / Доцент"
|
||||
required
|
||||
value={createJobTitle}
|
||||
onChange={event => setCreateJobTitle(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="new-department">ID Кафедры</label>
|
||||
<input
|
||||
type="number"
|
||||
id="new-department"
|
||||
placeholder="ID"
|
||||
required
|
||||
value={createDepartmentId}
|
||||
onChange={event => setCreateDepartmentId(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="btn btn-md btn-primary">Создать</button>
|
||||
</div>
|
||||
<div
|
||||
className={`form-alert${createAlert.text ? (createAlert.isError ? ' error' : ' success') : ''}`}
|
||||
role="alert"
|
||||
>
|
||||
{createAlert.text}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h2>Все пользователи</h2>
|
||||
<div className="registry-toolbar">
|
||||
<div className="form-group registry-search">
|
||||
<label htmlFor="users-search">Поиск</label>
|
||||
<input
|
||||
type="search"
|
||||
id="users-search"
|
||||
placeholder="Логин, ФИО или должность"
|
||||
value={searchInput}
|
||||
onChange={event => handleSearchInput(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="users-role-filter">Роль</label>
|
||||
<select
|
||||
id="users-role-filter"
|
||||
value={state.role}
|
||||
onChange={event => applyState({ role: event.target.value, page: 0 })}
|
||||
>
|
||||
<option value="">Все роли</option>
|
||||
{ROLE_FILTER_OPTIONS.map(option => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="users-sort">Сортировка</label>
|
||||
<select
|
||||
id="users-sort"
|
||||
value={state.sort}
|
||||
onChange={event => applyState({ sort: event.target.value, page: 0 })}
|
||||
>
|
||||
{USER_SORT_OPTIONS.map(option => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="table-wrap">
|
||||
<table id="users-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Имя пользователя</th>
|
||||
<th>ФИО</th>
|
||||
<th>Должность</th>
|
||||
<th>Кафедра</th>
|
||||
<th>Роль</th>
|
||||
<th className="th-actions">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tableError ? (
|
||||
<tr>
|
||||
<td colSpan={USER_TABLE_COLUMNS} className="loading-row view-state-error">
|
||||
<span>{tableError}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-secondary"
|
||||
onClick={() => void loadUsers(stateRef.current)}
|
||||
>
|
||||
Повторить
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
) : tableLoading ? (
|
||||
<tr><td colSpan={USER_TABLE_COLUMNS} className="loading-row"><span className="state-spinner" aria-hidden="true" />Загрузка пользователей...</td></tr>
|
||||
) : users.length === 0 ? (
|
||||
<tr><td colSpan={USER_TABLE_COLUMNS} className="loading-row view-state-empty">{usersEmptyMessage(state)}</td></tr>
|
||||
) : users.map(user => (
|
||||
<tr key={user.id}>
|
||||
<td>{user.id}</td>
|
||||
<td>{user.username}</td>
|
||||
<td>{user.fullName || '-'}</td>
|
||||
<td>{user.jobTitle || '-'}</td>
|
||||
<td>{user.departmentName || '-'}</td>
|
||||
<td><span className={`badge ${roleBadgeClass(user.role)}`}>{roleLabel(user.role)}</span></td>
|
||||
<td className="table-cell-actions">
|
||||
{user.status === 'ARCHIVED' ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-secondary btn-restore-user"
|
||||
onClick={() => void restore(user)}
|
||||
>
|
||||
Восстановить
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-danger-subtle btn-archive-user"
|
||||
onClick={() => confirmArchiveUser(user)}
|
||||
>
|
||||
Архивировать
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{pageData && (
|
||||
<Pagination
|
||||
page={pageData.page}
|
||||
size={pageData.size}
|
||||
totalItems={pageData.totalItems}
|
||||
totalPages={pageData.totalPages}
|
||||
label="пользователей"
|
||||
onPage={page => applyState({ page })}
|
||||
onSize={size => applyState({ size, page: 0 })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{confirmState && (
|
||||
<ConfirmDialog
|
||||
title={confirmState.title}
|
||||
message={confirmState.message}
|
||||
confirmLabel={confirmState.confirmLabel}
|
||||
danger={confirmState.danger}
|
||||
onConfirm={() => void runConfirm()}
|
||||
onClose={() => setConfirmState(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{alertState && (
|
||||
<AlertDialog
|
||||
title={alertState.title}
|
||||
message={alertState.message}
|
||||
onClose={() => setAlertState(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
236
frontend/react/admin/teacher-absences-service.js
Normal file
236
frontend/react/admin/teacher-absences-service.js
Normal file
@@ -0,0 +1,236 @@
|
||||
import { apiClient } from '../shared/api-client.js';
|
||||
import { formatLocalDate } from '../../admin/js/utils.js';
|
||||
|
||||
// Срез «Отсутствия преподавателей» (этап 5, волна 5): чистая логика и контракты API,
|
||||
// перенесённые из legacy admin/js/views/teacher-absences.js без изменения поведения.
|
||||
|
||||
export const MANAGER_ROLES = new Set(['ADMIN', 'EDUCATION_OFFICE', 'DEPARTMENT']);
|
||||
|
||||
// --- Поиск преподавателя для регистрации отсутствия ---
|
||||
|
||||
export function teacherAbsenceTeacherOptionsUrl({ query = '', selectedId = '', role = '', departmentId = '' } = {}) {
|
||||
const params = new URLSearchParams({ query, limit: '30' });
|
||||
if (role === 'DEPARTMENT' && departmentId) params.set('departmentId', departmentId);
|
||||
if (selectedId) params.set('selectedId', selectedId);
|
||||
return `/api/users/teachers/options?${params}`;
|
||||
}
|
||||
|
||||
// --- Решения мастера замены ---
|
||||
|
||||
export function decisionOptions(lesson) {
|
||||
const options = [];
|
||||
(lesson.teacherCandidates || []).forEach(candidate => options.push({
|
||||
value: `REPLACE_TEACHER|${candidate.id}`,
|
||||
label: `Заменить: ${candidate.name}`
|
||||
}));
|
||||
(lesson.moveCandidates || []).forEach(candidate => options.push({
|
||||
value: `MOVE_TIME|${candidate.targetDate}|${candidate.timeSlotId}`,
|
||||
label: `Перенести: ${formatAbsenceDate(candidate.targetDate)}, ${trimTime(candidate.startTime)}–${trimTime(candidate.endTime)}`
|
||||
}));
|
||||
(lesson.classroomCandidates || []).forEach(candidate => options.push({
|
||||
value: `CHANGE_CLASSROOM|${candidate.id}`,
|
||||
label: `Другая аудитория: ${candidate.name}`
|
||||
}));
|
||||
if (lesson.cancellationAvailable) {
|
||||
options.push({ value: 'CANCEL', label: 'Отменить занятие' });
|
||||
}
|
||||
options.push({ value: 'REJECT', label: 'Отклонить предложения' });
|
||||
return options;
|
||||
}
|
||||
|
||||
export function parseDecisionOption(value) {
|
||||
const [resolution, first, second] = String(value || '').split('|');
|
||||
if (resolution === 'REPLACE_TEACHER') return { resolution, newTeacherId: Number(first) };
|
||||
if (resolution === 'MOVE_TIME') {
|
||||
return { resolution, targetLessonDate: first, newTimeSlotId: Number(second) };
|
||||
}
|
||||
if (resolution === 'CHANGE_CLASSROOM') return { resolution, newClassroomId: Number(first) };
|
||||
if (resolution === 'CANCEL' || resolution === 'REJECT') return { resolution };
|
||||
throw new Error('Неизвестное решение мастера замены');
|
||||
}
|
||||
|
||||
// --- Подписи и статусы ---
|
||||
|
||||
export function statusLabel(status) {
|
||||
return {
|
||||
PENDING: 'На подтверждении',
|
||||
APPROVED: 'Требует решения',
|
||||
REJECTED: 'Отклонено',
|
||||
RESOLVED: 'Разрешено',
|
||||
CANCELLED: 'Отменено'
|
||||
}[status] || status || '—';
|
||||
}
|
||||
|
||||
export function statusClass(status) {
|
||||
return `is-${String(status || '').toLowerCase()}`;
|
||||
}
|
||||
|
||||
export function genericStatusLabel(status) {
|
||||
return {
|
||||
PENDING: 'На рассмотрении',
|
||||
APPROVED: 'Согласовано',
|
||||
REJECTED: 'Отклонено',
|
||||
CANCELLED: 'Отменено'
|
||||
}[status] || status || '—';
|
||||
}
|
||||
|
||||
export function decisionStatusLabel(status) {
|
||||
return status === 'APPLIED' ? 'Применено' : status === 'REJECTED' ? 'Отклонено' : status || '—';
|
||||
}
|
||||
|
||||
export function decisionCountLabel(count) {
|
||||
const value = Number(count) || 0;
|
||||
const tail = value % 100;
|
||||
if (tail >= 11 && tail <= 14) return `${value} решений`;
|
||||
if (value % 10 === 1) return `${value} решение`;
|
||||
if ([2, 3, 4].includes(value % 10)) return `${value} решения`;
|
||||
return `${value} решений`;
|
||||
}
|
||||
|
||||
export function resolutionLabel(resolution) {
|
||||
return {
|
||||
REPLACE_TEACHER: 'Замена преподавателя',
|
||||
MOVE_TIME: 'Перенос занятия',
|
||||
CHANGE_CLASSROOM: 'Замена аудитории',
|
||||
CANCEL: 'Отмена занятия',
|
||||
REJECT: 'Предложение отклонено'
|
||||
}[resolution] || resolution || 'Решение';
|
||||
}
|
||||
|
||||
export function changeTypeLabel(type) {
|
||||
return {
|
||||
MOVE: 'Перенос даты или времени',
|
||||
CHANGE_CLASSROOM: 'Смена аудитории',
|
||||
CANCEL: 'Отмена занятия'
|
||||
}[type] || type || 'Изменение';
|
||||
}
|
||||
|
||||
export function preferenceDescription(item) {
|
||||
const type = {
|
||||
HARD_UNAVAILABLE: 'Строгая недоступность',
|
||||
SOFT_PREFERRED: 'Предпочтительное время',
|
||||
SOFT_UNWANTED: 'Нежелательное время',
|
||||
CONSECUTIVE: 'Пожелание «пары подряд»',
|
||||
NO_GAPS: 'Пожелание «без окон»'
|
||||
}[item.preferenceType] || item.preferenceType;
|
||||
if (item.preferenceDate) return `${type}: ${formatAbsenceDate(item.preferenceDate)}`;
|
||||
if (item.dayName) return `${type}: ${item.dayName}, ${trimTime(item.startTime)}–${trimTime(item.endTime)}`;
|
||||
return type;
|
||||
}
|
||||
|
||||
export function absenceSemesterLabel(semester) {
|
||||
const type = semester.semesterType === 'AUTUMN' ? 'осенний' : semester.semesterType === 'SPRING' ? 'весенний' : semester.semesterType;
|
||||
return `${semester.academicYearTitle} · ${type} · ${formatAbsenceDate(semester.startDate)}–${formatAbsenceDate(semester.endDate)}`;
|
||||
}
|
||||
|
||||
export function formatAbsenceDate(value) {
|
||||
if (!value) return '—';
|
||||
const [year, month, day] = String(value).split('-');
|
||||
return `${day}.${month}.${year}`;
|
||||
}
|
||||
|
||||
export function dayOfMonth(value) {
|
||||
return String(value || '').split('-')[2] || '—';
|
||||
}
|
||||
|
||||
export function monthShort(value) {
|
||||
const month = Number(String(value || '').split('-')[1]);
|
||||
return ['янв', 'фев', 'мар', 'апр', 'май', 'июн', 'июл', 'авг', 'сен', 'окт', 'ноя', 'дек'][month - 1] || '';
|
||||
}
|
||||
|
||||
export function trimTime(value) {
|
||||
return String(value || '').slice(0, 5);
|
||||
}
|
||||
|
||||
// --- Сводка и цель заявки ---
|
||||
|
||||
export function absenceStats(items) {
|
||||
const list = Array.isArray(items) ? items : [];
|
||||
return {
|
||||
active: list.filter(item => item.status === 'APPROVED').length,
|
||||
pending: list.filter(item => item.status === 'PENDING').length,
|
||||
resolved: list.filter(item => item.status === 'RESOLVED').length
|
||||
};
|
||||
}
|
||||
|
||||
export function changeReviewTarget(item) {
|
||||
if (item.requestType === 'MOVE') {
|
||||
return `${formatAbsenceDate(item.targetLessonDate || item.lessonDate)} · ${trimTime(item.requestedStartTime)}–${trimTime(item.requestedEndTime)}`;
|
||||
}
|
||||
if (item.requestType === 'CHANGE_CLASSROOM') return item.requestedClassroomName;
|
||||
return 'Отмена занятия';
|
||||
}
|
||||
|
||||
// --- Контракты API ---
|
||||
|
||||
export function teacherAbsencesEndpoint(status) {
|
||||
return `/api/teacher-absences${status ? `?status=${encodeURIComponent(status)}` : ''}`;
|
||||
}
|
||||
|
||||
export function teacherPreferencesEndpoint({ semesterId = '', status = '' } = {}) {
|
||||
const params = new URLSearchParams();
|
||||
if (semesterId) params.set('semesterId', semesterId);
|
||||
if (status) params.set('status', status);
|
||||
return `/api/teacher-preferences${params.size ? `?${params}` : ''}`;
|
||||
}
|
||||
|
||||
export function teacherChangeRequestsEndpoint(status) {
|
||||
return `/api/teacher-change-requests${status ? `?status=${encodeURIComponent(status)}` : ''}`;
|
||||
}
|
||||
|
||||
export async function fetchTeacherAbsences(status, client = apiClient) {
|
||||
return client.get(teacherAbsencesEndpoint(status), { errorMessage: 'Не удалось загрузить отсутствия' });
|
||||
}
|
||||
|
||||
export async function createTeacherAbsence(payload, client = apiClient) {
|
||||
return client.post('/api/teacher-absences', payload, { errorMessage: 'Не удалось зарегистрировать отсутствие' });
|
||||
}
|
||||
|
||||
export async function reviewTeacherAbsence(id, payload, client = apiClient) {
|
||||
return client.post(`/api/teacher-absences/${encodeURIComponent(id)}/review`, payload, { errorMessage: 'Не удалось выполнить действие' });
|
||||
}
|
||||
|
||||
export async function deleteTeacherAbsence(id, client = apiClient) {
|
||||
return client.delete(`/api/teacher-absences/${encodeURIComponent(id)}`, { errorMessage: 'Не удалось выполнить действие' });
|
||||
}
|
||||
|
||||
export async function fetchTeacherAbsenceWizard(id, client = apiClient) {
|
||||
return client.get(`/api/teacher-absences/${encodeURIComponent(id)}/wizard`, { errorMessage: 'Не удалось открыть мастер замены' });
|
||||
}
|
||||
|
||||
export async function resolveTeacherAbsence(id, decisions, client = apiClient) {
|
||||
return client.post(
|
||||
`/api/teacher-absences/${encodeURIComponent(id)}/resolve`,
|
||||
{ decisions },
|
||||
{ errorMessage: 'Не удалось применить решения' }
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchTeacherPreferenceMeta(client = apiClient) {
|
||||
return client.get('/api/teacher-preferences/meta', { errorMessage: 'Не удалось загрузить семестры' });
|
||||
}
|
||||
|
||||
export async function fetchTeacherPreferences(filters, client = apiClient) {
|
||||
return client.get(teacherPreferencesEndpoint(filters), { errorMessage: 'Не удалось загрузить пожелания' });
|
||||
}
|
||||
|
||||
export async function reviewTeacherPreference(id, payload, client = apiClient) {
|
||||
return client.post(`/api/teacher-preferences/${encodeURIComponent(id)}/review`, payload, { errorMessage: 'Не удалось рассмотреть пожелание' });
|
||||
}
|
||||
|
||||
export async function fetchTeacherChangeRequests(status, client = apiClient) {
|
||||
return client.get(teacherChangeRequestsEndpoint(status), { errorMessage: 'Не удалось загрузить заявки' });
|
||||
}
|
||||
|
||||
export async function reviewTeacherChangeRequest(id, payload, client = apiClient) {
|
||||
return client.post(`/api/teacher-change-requests/${encodeURIComponent(id)}/review`, payload, { errorMessage: 'Не удалось применить решение' });
|
||||
}
|
||||
|
||||
export async function fetchAbsenceTeacherOptions({ query, selectedId, role, departmentId }, client = apiClient) {
|
||||
return client.get(
|
||||
teacherAbsenceTeacherOptionsUrl({ query, selectedId, role, departmentId }),
|
||||
{ errorMessage: 'Ошибка поиска преподавателей' }
|
||||
);
|
||||
}
|
||||
|
||||
export { formatLocalDate };
|
||||
118
frontend/react/admin/teacher-requests-service.js
Normal file
118
frontend/react/admin/teacher-requests-service.js
Normal file
@@ -0,0 +1,118 @@
|
||||
import { apiClient } from '../shared/api-client.js';
|
||||
|
||||
export const REQUEST_TABLE_COLUMNS = 7;
|
||||
export const REQUEST_PAGE_SIZES = [25, 50, 100];
|
||||
|
||||
export const REQUEST_STATUS_OPTIONS = Object.freeze([
|
||||
{ value: 'PENDING', label: 'Ожидают рассмотрения' },
|
||||
{ value: 'APPROVED', label: 'Одобрены' },
|
||||
{ value: 'REJECTED', label: 'Отклонены' },
|
||||
{ value: '', label: 'Все статусы' }
|
||||
]);
|
||||
|
||||
export function normalizeTeacherRequestsState(restored = {}) {
|
||||
const size = Number(restored.requestSize);
|
||||
return {
|
||||
query: restored.requestQuery || '',
|
||||
status: restored.requestStatus || 'PENDING',
|
||||
page: Math.max(Number(restored.requestPage) || 0, 0),
|
||||
size: REQUEST_PAGE_SIZES.includes(size) ? size : 25
|
||||
};
|
||||
}
|
||||
|
||||
export function initialTeacherRequestsState(restored = {}) {
|
||||
return normalizeTeacherRequestsState(restored);
|
||||
}
|
||||
|
||||
export function teacherRequestsQueryParams(state) {
|
||||
const params = new URLSearchParams({
|
||||
query: state.query || '',
|
||||
page: String(state.page || 0),
|
||||
size: String(state.size || 25),
|
||||
sort: 'createdAt',
|
||||
direction: 'desc'
|
||||
});
|
||||
if (state.status) params.set('status', state.status);
|
||||
return `/api/teacher-requests/page?${params}`;
|
||||
}
|
||||
|
||||
export function teacherRequestsUrlState(state) {
|
||||
return {
|
||||
requestQuery: state.query || '',
|
||||
requestStatus: state.status === 'PENDING' ? '' : state.status,
|
||||
requestPage: state.page || '',
|
||||
requestSize: state.size === 25 ? '' : state.size
|
||||
};
|
||||
}
|
||||
|
||||
// --- Справочники ---
|
||||
|
||||
export function fetchDepartments(client = apiClient) {
|
||||
return client.get('/api/departments', { errorMessage: 'Не удалось загрузить список кафедр' });
|
||||
}
|
||||
|
||||
// Кафедра в строке заявки подписывается только названием — как в legacy-реестре.
|
||||
export function requestDepartmentLabel(department) {
|
||||
return department.departmentName || department.name || department.id;
|
||||
}
|
||||
|
||||
// --- Реестр заявок ---
|
||||
|
||||
export function fetchTeacherRequestsPage(state, client = apiClient) {
|
||||
return client.get(teacherRequestsQueryParams(state), {
|
||||
errorMessage: 'Не удалось загрузить заявки преподавателей'
|
||||
});
|
||||
}
|
||||
|
||||
export function teacherRequestsEmptyMessage(state) {
|
||||
return state.query ? 'Заявки не найдены' : 'Заявок с выбранным статусом нет';
|
||||
}
|
||||
|
||||
export function reviewedStatusLabel(status) {
|
||||
return status === 'APPROVED' ? 'Одобрена' : 'Отклонена';
|
||||
}
|
||||
|
||||
// Счётчик в sidebar обновляется событием teacherRequestsChanged: он актуален
|
||||
// только когда реестр показывает нефильтрованный список ожидающих заявок.
|
||||
export function pendingCountDetail(state, totalItems) {
|
||||
return {
|
||||
count: state.status === 'PENDING' && !state.query
|
||||
? Number(totalItems || 0)
|
||||
: undefined
|
||||
};
|
||||
}
|
||||
|
||||
// --- Одобрение и отклонение ---
|
||||
|
||||
export function approvePayloadFromFields(fields) {
|
||||
const payload = {
|
||||
departmentId: Number(fields.departmentId),
|
||||
username: (fields.username || '').trim(),
|
||||
fullName: (fields.fullName || '').trim(),
|
||||
jobTitle: (fields.jobTitle || '').trim(),
|
||||
password: fields.password
|
||||
};
|
||||
if (!payload.departmentId || !payload.username || !payload.fullName || !payload.password) {
|
||||
return { error: 'Заполните кафедру, логин, ФИО и пароль' };
|
||||
}
|
||||
if (payload.password.length < 8) {
|
||||
return { error: 'Пароль должен быть не короче 8 символов' };
|
||||
}
|
||||
return { payload };
|
||||
}
|
||||
|
||||
export function approveRequest(id, payload, client = apiClient) {
|
||||
return client.post(
|
||||
`/api/teacher-requests/${encodeURIComponent(id)}/approve`,
|
||||
payload,
|
||||
{ errorMessage: 'Ошибка одобрения заявки' }
|
||||
);
|
||||
}
|
||||
|
||||
export function rejectRequest(id, reviewComment, client = apiClient) {
|
||||
return client.post(
|
||||
`/api/teacher-requests/${encodeURIComponent(id)}/reject`,
|
||||
{ reviewComment },
|
||||
{ errorMessage: 'Ошибка отклонения заявки' }
|
||||
);
|
||||
}
|
||||
83
frontend/react/admin/testing/render-admin.jsx
Normal file
83
frontend/react/admin/testing/render-admin.jsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
|
||||
import { AdminWorkspace } from '../AdminApp.jsx';
|
||||
import { AcademicCalendarTab } from '../tabs/AcademicCalendarTab.jsx';
|
||||
import { AuditoriumWorkloadTab } from '../tabs/AuditoriumWorkloadTab.jsx';
|
||||
import { ClassroomsTab } from '../tabs/ClassroomsTab.jsx';
|
||||
import { DashboardTab } from '../tabs/DashboardTab.jsx';
|
||||
import { DepartmentWorkspaceTab } from '../tabs/DepartmentWorkspaceTab.jsx';
|
||||
import { GroupsTab } from '../tabs/GroupsTab.jsx';
|
||||
import { ScheduleQualityTab } from '../tabs/ScheduleQualityTab.jsx';
|
||||
import { ScheduleTab } from '../tabs/ScheduleTab.jsx';
|
||||
import { ScheduleVersionsTab } from '../tabs/ScheduleVersionsTab.jsx';
|
||||
import { ScheduleViewTab } from '../tabs/ScheduleViewTab.jsx';
|
||||
import { TeacherAbsencesTab } from '../tabs/TeacherAbsencesTab.jsx';
|
||||
import { SubjectsTab } from '../tabs/SubjectsTab.jsx';
|
||||
import { TeacherRequestsTab } from '../tabs/TeacherRequestsTab.jsx';
|
||||
import { UniversityStructureTab } from '../tabs/UniversityStructureTab.jsx';
|
||||
import { UsersTab } from '../tabs/UsersTab.jsx';
|
||||
|
||||
export function renderAdminWorkspace({ role } = {}) {
|
||||
return renderToStaticMarkup(createElement(AdminWorkspace, { role: role || 'ADMIN' }));
|
||||
}
|
||||
|
||||
export function renderClassroomsTab() {
|
||||
return renderToStaticMarkup(createElement(ClassroomsTab));
|
||||
}
|
||||
|
||||
export function renderSubjectsTab() {
|
||||
return renderToStaticMarkup(createElement(SubjectsTab));
|
||||
}
|
||||
|
||||
export function renderDashboardTab() {
|
||||
return renderToStaticMarkup(createElement(DashboardTab));
|
||||
}
|
||||
|
||||
export function renderUsersTab() {
|
||||
return renderToStaticMarkup(createElement(UsersTab));
|
||||
}
|
||||
|
||||
export function renderGroupsTab() {
|
||||
return renderToStaticMarkup(createElement(GroupsTab));
|
||||
}
|
||||
|
||||
export function renderUniversityStructureTab() {
|
||||
return renderToStaticMarkup(createElement(UniversityStructureTab));
|
||||
}
|
||||
|
||||
export function renderTeacherRequestsTab() {
|
||||
return renderToStaticMarkup(createElement(TeacherRequestsTab));
|
||||
}
|
||||
|
||||
export function renderDepartmentWorkspaceTab() {
|
||||
return renderToStaticMarkup(createElement(DepartmentWorkspaceTab));
|
||||
}
|
||||
|
||||
export function renderAuditoriumWorkloadTab() {
|
||||
return renderToStaticMarkup(createElement(AuditoriumWorkloadTab));
|
||||
}
|
||||
|
||||
export function renderAcademicCalendarTab() {
|
||||
return renderToStaticMarkup(createElement(AcademicCalendarTab));
|
||||
}
|
||||
|
||||
export function renderScheduleViewTab() {
|
||||
return renderToStaticMarkup(createElement(ScheduleViewTab));
|
||||
}
|
||||
|
||||
export function renderScheduleTab() {
|
||||
return renderToStaticMarkup(createElement(ScheduleTab));
|
||||
}
|
||||
|
||||
export function renderScheduleVersionsTab() {
|
||||
return renderToStaticMarkup(createElement(ScheduleVersionsTab));
|
||||
}
|
||||
|
||||
export function renderScheduleQualityTab() {
|
||||
return renderToStaticMarkup(createElement(ScheduleQualityTab));
|
||||
}
|
||||
|
||||
export function renderTeacherAbsencesTab() {
|
||||
return renderToStaticMarkup(createElement(TeacherAbsencesTab));
|
||||
}
|
||||
150
frontend/react/admin/university-structure-service.js
Normal file
150
frontend/react/admin/university-structure-service.js
Normal file
@@ -0,0 +1,150 @@
|
||||
import { apiClient } from '../shared/api-client.js';
|
||||
|
||||
export const DEPARTMENT_TABLE_COLUMNS = 4;
|
||||
export const SPECIALTY_TABLE_COLUMNS = 4;
|
||||
export const PROFILE_TABLE_COLUMNS = 5;
|
||||
export const LIST_PAGE_SIZES = [25, 50, 100];
|
||||
|
||||
// --- Загрузка ---
|
||||
|
||||
export function fetchDepartments(client = apiClient) {
|
||||
return client.get('/api/departments', { errorMessage: 'Не удалось загрузить кафедры' });
|
||||
}
|
||||
|
||||
export function fetchSpecialties(client = apiClient) {
|
||||
return client.get('/api/specialties', { errorMessage: 'Не удалось загрузить специальности' });
|
||||
}
|
||||
|
||||
export function fetchAllProfiles(client = apiClient) {
|
||||
return client.get('/api/specialties/profiles', { errorMessage: 'Не удалось загрузить профили обучения' });
|
||||
}
|
||||
|
||||
export function fetchProfiles(specId, client = apiClient) {
|
||||
return client.get(`/api/specialties/${specId}/profiles`, { errorMessage: 'Не удалось загрузить профили обучения' });
|
||||
}
|
||||
|
||||
// --- Кафедры ---
|
||||
|
||||
export function createDepartment({ name, code }, client = apiClient) {
|
||||
return client.post(
|
||||
'/api/departments',
|
||||
{ departmentName: name, departmentCode: Number(code) },
|
||||
{ errorMessage: 'Ошибка создания кафедры' }
|
||||
);
|
||||
}
|
||||
|
||||
export function updateDepartment(id, { name, code }, client = apiClient) {
|
||||
return client.put(
|
||||
`/api/departments/${id}`,
|
||||
{ departmentName: name, departmentCode: Number(code) },
|
||||
{ errorMessage: 'Ошибка обновления кафедры' }
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteDepartment(id, client = apiClient) {
|
||||
return client.delete(`/api/departments/${id}`, { errorMessage: 'Ошибка удаления кафедры' });
|
||||
}
|
||||
|
||||
// --- Специальности ---
|
||||
|
||||
export function createSpecialty({ name, code }, client = apiClient) {
|
||||
return client.post(
|
||||
'/api/specialties',
|
||||
{ specialityName: name, specialityCode: code },
|
||||
{ errorMessage: 'Ошибка создания специальности' }
|
||||
);
|
||||
}
|
||||
|
||||
export function updateSpecialty(id, { name, code }, client = apiClient) {
|
||||
return client.put(
|
||||
`/api/specialties/${id}`,
|
||||
{ specialityName: name, specialityCode: code },
|
||||
{ errorMessage: 'Ошибка обновления специальности' }
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteSpecialty(id, client = apiClient) {
|
||||
return client.delete(`/api/specialties/${id}`, { errorMessage: 'Ошибка удаления специальности' });
|
||||
}
|
||||
|
||||
// --- Профили ---
|
||||
|
||||
export function createProfile(specId, { name, description }, client = apiClient) {
|
||||
return client.post(
|
||||
`/api/specialties/${specId}/profiles`,
|
||||
{ name, description },
|
||||
{ errorMessage: 'Ошибка сохранения профиля' }
|
||||
);
|
||||
}
|
||||
|
||||
export function updateProfile(specId, profileId, { name, description }, client = apiClient) {
|
||||
return client.put(
|
||||
`/api/specialties/${specId}/profiles/${profileId}`,
|
||||
{ id: Number(profileId), name, description },
|
||||
{ errorMessage: 'Ошибка сохранения профиля' }
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteProfile(specId, profileId, client = apiClient) {
|
||||
return client.delete(`/api/specialties/${specId}/profiles/${profileId}`, { errorMessage: 'Ошибка удаления профиля' });
|
||||
}
|
||||
|
||||
// --- Клиентская фильтрация и пагинация списков (как в legacy listPage) ---
|
||||
|
||||
export function filterListItems(items, query, fields) {
|
||||
const normalized = String(query || '').trim().toLocaleLowerCase('ru');
|
||||
if (!normalized) return [...(items || [])];
|
||||
return (items || []).filter(item =>
|
||||
fields(item).join(' ').toLocaleLowerCase('ru').includes(normalized));
|
||||
}
|
||||
|
||||
export function clampPage(page, totalPages) {
|
||||
return Math.min(Math.max(Number(page) || 0, 0), Math.max(0, totalPages - 1));
|
||||
}
|
||||
|
||||
export function pageSlice(items, page, size) {
|
||||
const pageSize = Number(size) || 25;
|
||||
return (items || []).slice(page * pageSize, (page + 1) * pageSize);
|
||||
}
|
||||
|
||||
export function departmentFields(department) {
|
||||
return [department.id, department.departmentName || department.name, department.departmentCode ?? department.code];
|
||||
}
|
||||
|
||||
export function specialtyFields(speciality) {
|
||||
return [
|
||||
speciality.id,
|
||||
speciality.specialityName || speciality.name,
|
||||
speciality.specialityCode || speciality.specialtyCode || speciality.specialty_code
|
||||
];
|
||||
}
|
||||
|
||||
export function profileFields(profile) {
|
||||
return [profile.id, profile.name, profile.description, profile.specialityCode, profile.specialityName];
|
||||
}
|
||||
|
||||
// --- Метки ---
|
||||
|
||||
export function departmentName(department) {
|
||||
return department.departmentName || department.name;
|
||||
}
|
||||
|
||||
export function departmentCode(department) {
|
||||
return department.departmentCode ?? department.code;
|
||||
}
|
||||
|
||||
export function specialtyName(speciality) {
|
||||
return speciality.specialityName || speciality.name;
|
||||
}
|
||||
|
||||
export function specialtyCode(speciality) {
|
||||
return speciality.specialityCode || speciality.specialtyCode || speciality.specialty_code;
|
||||
}
|
||||
|
||||
export function specialtyOptionLabel(speciality) {
|
||||
return `${specialtyCode(speciality)} · ${specialtyName(speciality)}`;
|
||||
}
|
||||
|
||||
export function profileSpecialtyLabel(profile) {
|
||||
return `${profile.specialityCode} · ${profile.specialityName}`;
|
||||
}
|
||||
112
frontend/react/admin/users-service.js
Normal file
112
frontend/react/admin/users-service.js
Normal file
@@ -0,0 +1,112 @@
|
||||
import { apiClient } from '../shared/api-client.js';
|
||||
|
||||
export const USER_TABLE_COLUMNS = 7;
|
||||
export const USER_PAGE_SIZES = [25, 50, 100];
|
||||
|
||||
export const USER_SORT_OPTIONS = Object.freeze([
|
||||
{ value: 'fullName,asc', label: 'По ФИО А–Я' },
|
||||
{ value: 'fullName,desc', label: 'По ФИО Я–А' },
|
||||
{ value: 'username,asc', label: 'По логину' },
|
||||
{ value: 'role,asc', label: 'По роли' },
|
||||
{ value: 'status,asc', label: 'По статусу' }
|
||||
]);
|
||||
|
||||
export const ROLE_LABELS = Object.freeze({
|
||||
ADMIN: 'Администратор',
|
||||
EDUCATION_OFFICE: 'Учебный отдел',
|
||||
DEPARTMENT: 'Кафедра',
|
||||
SCHEDULE_VIEWER: 'Просмотр расписаний',
|
||||
TEACHER: 'Преподаватель',
|
||||
STUDENT: 'Студент'
|
||||
});
|
||||
|
||||
export const ROLE_BADGE = Object.freeze({
|
||||
ADMIN: 'badge-admin',
|
||||
EDUCATION_OFFICE: 'badge-admin',
|
||||
DEPARTMENT: 'badge-teacher',
|
||||
SCHEDULE_VIEWER: 'badge-student',
|
||||
TEACHER: 'badge-teacher',
|
||||
STUDENT: 'badge-student'
|
||||
});
|
||||
|
||||
export const ROLE_FILTER_OPTIONS = Object.freeze([
|
||||
{ value: 'ADMIN', label: 'Администратор' },
|
||||
{ value: 'EDUCATION_OFFICE', label: 'Учебный отдел' },
|
||||
{ value: 'DEPARTMENT', label: 'Кафедра' },
|
||||
{ value: 'SCHEDULE_VIEWER', label: 'Просмотр расписаний' },
|
||||
{ value: 'TEACHER', label: 'Преподаватель' },
|
||||
{ value: 'STUDENT', label: 'Студент' }
|
||||
]);
|
||||
|
||||
export function normalizeUsersState(restored = {}) {
|
||||
const size = Number(restored.userSize);
|
||||
return {
|
||||
query: restored.userQuery || '',
|
||||
role: restored.userRole || '',
|
||||
page: Math.max(Number(restored.userPage) || 0, 0),
|
||||
size: USER_PAGE_SIZES.includes(size) ? size : 25,
|
||||
sort: USER_SORT_OPTIONS.some(option => option.value === restored.userSort)
|
||||
? restored.userSort
|
||||
: 'fullName,asc'
|
||||
};
|
||||
}
|
||||
|
||||
export function initialUsersState(restored = {}) {
|
||||
return normalizeUsersState(restored);
|
||||
}
|
||||
|
||||
export function usersQueryParams(state) {
|
||||
const [sort, direction] = String(state.sort || 'fullName,asc').split(',');
|
||||
const params = new URLSearchParams({
|
||||
query: state.query || '',
|
||||
page: String(state.page || 0),
|
||||
size: String(state.size || 25),
|
||||
sort,
|
||||
direction,
|
||||
includeArchived: 'true'
|
||||
});
|
||||
if (state.role) params.set('role', state.role);
|
||||
return `/api/users/page?${params}`;
|
||||
}
|
||||
|
||||
export function usersUrlState(state) {
|
||||
return {
|
||||
userQuery: state.query || '',
|
||||
userRole: state.role || '',
|
||||
userPage: state.page || '',
|
||||
userSize: state.size === 25 ? '' : state.size,
|
||||
userSort: state.sort === 'fullName,asc' ? '' : state.sort
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchUsersPage(state, client = apiClient) {
|
||||
return client.get(usersQueryParams(state), { errorMessage: 'Не удалось загрузить пользователей' });
|
||||
}
|
||||
|
||||
export function createUser({ username, password, role, fullName, jobTitle, departmentId }, client = apiClient) {
|
||||
return client.post(
|
||||
'/api/users',
|
||||
{ username, password, role, fullName, jobTitle, departmentId: Number(departmentId) },
|
||||
{ errorMessage: 'Ошибка соединения' }
|
||||
);
|
||||
}
|
||||
|
||||
export function archiveUser(id, client = apiClient) {
|
||||
return client.delete(`/api/users/${id}`, { errorMessage: 'Ошибка архивирования' });
|
||||
}
|
||||
|
||||
export function restoreUser(id, client = apiClient) {
|
||||
return client.post(`/api/users/${id}/restore`, {}, { errorMessage: 'Ошибка восстановления' });
|
||||
}
|
||||
|
||||
export function roleLabel(role) {
|
||||
return ROLE_LABELS[role] || role;
|
||||
}
|
||||
|
||||
export function roleBadgeClass(role) {
|
||||
return ROLE_BADGE[role] || '';
|
||||
}
|
||||
|
||||
export function usersEmptyMessage(state) {
|
||||
return state.query || state.role ? 'Пользователи не найдены' : 'Пользователей пока нет';
|
||||
}
|
||||
212
frontend/react/login/LoginApp.jsx
Normal file
212
frontend/react/login/LoginApp.jsx
Normal file
@@ -0,0 +1,212 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { clearAuthState, storeAuthState } from '../../auth-session.js';
|
||||
import { initializeTelemetry } from '../../telemetry.js';
|
||||
import {
|
||||
LOGIN_ERROR_MESSAGES,
|
||||
requestLogin,
|
||||
validateLogin
|
||||
} from './login-service.js';
|
||||
import { FormAlert } from '../shared/ui/FormAlert.jsx';
|
||||
|
||||
void initializeTelemetry();
|
||||
|
||||
const EMPTY_CREDENTIALS = Object.freeze({ password: '', username: '' });
|
||||
|
||||
function BrandIcon() {
|
||||
return (
|
||||
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<rect width="40" height="40" rx="12" fill="url(#logo-gradient)" />
|
||||
<path d="M12 20L18 26L28 14" stroke="white" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<defs>
|
||||
<linearGradient id="logo-gradient" x1="0" y1="0" x2="40" y2="40">
|
||||
<stop stopColor="#6366f1" />
|
||||
<stop offset="1" stopColor="#8b5cf6" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function UserIcon() {
|
||||
return (
|
||||
<svg className="input-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="12" cy="7" r="4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function LockIcon() {
|
||||
return (
|
||||
<svg className="input-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function EyeIcon() {
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function Spinner() {
|
||||
return (
|
||||
<svg className="spinner" width="20" height="20" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" fill="none" strokeDasharray="31.4 31.4" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function LoginApp() {
|
||||
const [credentials, setCredentials] = useState(EMPTY_CREDENTIALS);
|
||||
const [errors, setErrors] = useState({});
|
||||
const [alert, setAlert] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [passwordVisible, setPasswordVisible] = useState(false);
|
||||
const [rippleVisible, setRippleVisible] = useState(false);
|
||||
const redirectTimer = useRef(null);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (redirectTimer.current) {
|
||||
window.clearTimeout(redirectTimer.current);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updateField = event => {
|
||||
const { name, value } = event.target;
|
||||
setCredentials(current => ({ ...current, [name]: value }));
|
||||
setErrors(current => ({ ...current, [name]: undefined }));
|
||||
};
|
||||
|
||||
const submit = async event => {
|
||||
event.preventDefault();
|
||||
const nextErrors = validateLogin(credentials);
|
||||
setErrors(nextErrors);
|
||||
setAlert(null);
|
||||
|
||||
if (Object.keys(nextErrors).length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await requestLogin(credentials);
|
||||
if (!result.ok) {
|
||||
setAlert({ message: result.message, type: 'error' });
|
||||
return;
|
||||
}
|
||||
|
||||
clearAuthState();
|
||||
const session = storeAuthState(result.data);
|
||||
if (!session) {
|
||||
setAlert({ message: LOGIN_ERROR_MESSAGES.invalidResponse, type: 'error' });
|
||||
return;
|
||||
}
|
||||
|
||||
setAlert({ message: 'Вход выполнен успешно!', type: 'success' });
|
||||
redirectTimer.current = window.setTimeout(() => {
|
||||
window.location.assign(session.redirect || '/');
|
||||
}, 400);
|
||||
} catch (error) {
|
||||
setAlert({ message: LOGIN_ERROR_MESSAGES.connection, type: 'error' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const showRipple = () => {
|
||||
setRippleVisible(false);
|
||||
window.requestAnimationFrame(() => setRippleVisible(true));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="background" aria-hidden="true">
|
||||
<div className="shape shape-1" />
|
||||
<div className="shape shape-2" />
|
||||
<div className="shape shape-3" />
|
||||
</div>
|
||||
|
||||
<main className="login-container">
|
||||
<div className="login-card">
|
||||
<header className="login-header">
|
||||
<div className="logo"><BrandIcon /></div>
|
||||
<h1>Добро пожаловать</h1>
|
||||
<p className="subtitle">Войдите в свой аккаунт</p>
|
||||
</header>
|
||||
|
||||
<form id="login-form" noValidate onSubmit={submit}>
|
||||
<div className={`form-group stagger-1${errors.username ? ' has-error shake' : ''}`}>
|
||||
<label htmlFor="username">Имя пользователя</label>
|
||||
<div className="input-wrapper">
|
||||
<UserIcon />
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
name="username"
|
||||
value={credentials.username}
|
||||
onChange={updateField}
|
||||
placeholder="Введите имя пользователя"
|
||||
autoComplete="username"
|
||||
aria-invalid={Boolean(errors.username)}
|
||||
aria-describedby="username-error"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<span className="error-message" id="username-error">{errors.username || ''}</span>
|
||||
</div>
|
||||
|
||||
<div className={`form-group stagger-2${errors.password ? ' has-error shake' : ''}`}>
|
||||
<label htmlFor="password">Пароль</label>
|
||||
<div className="input-wrapper">
|
||||
<LockIcon />
|
||||
<input
|
||||
type={passwordVisible ? 'text' : 'password'}
|
||||
id="password"
|
||||
name="password"
|
||||
value={credentials.password}
|
||||
onChange={updateField}
|
||||
placeholder="Введите пароль"
|
||||
autoComplete="current-password"
|
||||
aria-invalid={Boolean(errors.password)}
|
||||
aria-describedby="password-error"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-icon-md btn-secondary toggle-password"
|
||||
id="toggle-password"
|
||||
aria-label={passwordVisible ? 'Скрыть пароль' : 'Показать пароль'}
|
||||
aria-pressed={passwordVisible}
|
||||
onClick={() => setPasswordVisible(visible => !visible)}
|
||||
>
|
||||
<EyeIcon />
|
||||
</button>
|
||||
</div>
|
||||
<span className="error-message" id="password-error">{errors.password || ''}</span>
|
||||
</div>
|
||||
|
||||
<FormAlert alert={alert} className="stagger-3" id="form-alert" />
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-lg btn-primary btn-submit stagger-4"
|
||||
id="btn-submit"
|
||||
disabled={loading}
|
||||
onPointerDown={showRipple}
|
||||
>
|
||||
{loading ? <Spinner /> : <span className="btn-text">Войти</span>}
|
||||
{rippleVisible && <span className="ripple" onAnimationEnd={() => setRippleVisible(false)} />}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
40
frontend/react/login/login-service.js
Normal file
40
frontend/react/login/login-service.js
Normal file
@@ -0,0 +1,40 @@
|
||||
export const LOGIN_ERROR_MESSAGES = Object.freeze({
|
||||
connection: 'Ошибка соединения с сервером',
|
||||
credentials: 'Неверное имя пользователя или пароль',
|
||||
invalidResponse: 'Сервер не вернул данные авторизации'
|
||||
});
|
||||
|
||||
export function validateLogin({ username = '', password = '' } = {}) {
|
||||
const errors = {};
|
||||
|
||||
if (!username.trim()) {
|
||||
errors.username = 'Введите имя пользователя';
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
errors.password = 'Введите пароль';
|
||||
} else if (password.length < 4) {
|
||||
errors.password = 'Минимум 4 символа';
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
export async function requestLogin({ username, password }, fetchImpl = fetch) {
|
||||
const response = await fetchImpl('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({
|
||||
username: username.trim(),
|
||||
password
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json().catch(() => null);
|
||||
return {
|
||||
data,
|
||||
message: data?.message || LOGIN_ERROR_MESSAGES.credentials,
|
||||
ok: response.ok
|
||||
};
|
||||
}
|
||||
19
frontend/react/login/main.jsx
Normal file
19
frontend/react/login/main.jsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import { LoginApp } from './LoginApp.jsx';
|
||||
import { AppErrorBoundary } from '../shared/ui/AppErrorBoundary.jsx';
|
||||
|
||||
const rootElement = document.getElementById('login-root');
|
||||
|
||||
if (!rootElement) {
|
||||
throw new Error('Не найден корневой элемент страницы входа');
|
||||
}
|
||||
|
||||
createRoot(rootElement).render(
|
||||
<StrictMode>
|
||||
<AppErrorBoundary>
|
||||
<LoginApp />
|
||||
</AppErrorBoundary>
|
||||
</StrictMode>
|
||||
);
|
||||
318
frontend/react/settings/DatabaseTab.jsx
Normal file
318
frontend/react/settings/DatabaseTab.jsx
Normal 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()}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
149
frontend/react/settings/EduFormsTab.jsx
Normal file
149
frontend/react/settings/EduFormsTab.jsx
Normal 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()}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
15
frontend/react/settings/GeneralTab.jsx
Normal file
15
frontend/react/settings/GeneralTab.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
209
frontend/react/settings/SettingsApp.jsx
Normal file
209
frontend/react/settings/SettingsApp.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
466
frontend/react/settings/TimeSlotsTab.jsx
Normal file
466
frontend/react/settings/TimeSlotsTab.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
frontend/react/settings/main.jsx
Normal file
22
frontend/react/settings/main.jsx
Normal 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>
|
||||
);
|
||||
213
frontend/react/settings/settings-service.js
Normal file
213
frontend/react/settings/settings-service.js
Normal file
@@ -0,0 +1,213 @@
|
||||
import { apiClient } from '../shared/api-client.js';
|
||||
import { capabilitiesForRole } from '../../admin/js/role-capabilities.js';
|
||||
|
||||
export const SETTINGS_TABS = Object.freeze([
|
||||
{ id: 'general', title: 'Общие настройки' },
|
||||
{ id: 'time-slots', title: 'Временные слоты' },
|
||||
{ id: 'edu-forms', title: 'Формы обучения' },
|
||||
{ id: 'database', title: 'База данных' }
|
||||
]);
|
||||
|
||||
const APPLY_MODE_LABELS = Object.freeze({
|
||||
DEFAULT: 'применяется по умолчанию',
|
||||
WEEKDAY: 'применяется автоматически',
|
||||
MANUAL: 'применяется вручную'
|
||||
});
|
||||
|
||||
const DAY_SHORT_LABELS = Object.freeze({
|
||||
1: 'Пн',
|
||||
2: 'Вт',
|
||||
3: 'Ср',
|
||||
4: 'Чт',
|
||||
5: 'Пт',
|
||||
6: 'Сб',
|
||||
7: 'Вс'
|
||||
});
|
||||
|
||||
export function settingsTabsForRole(role) {
|
||||
const capabilities = capabilitiesForRole(role);
|
||||
if (!capabilities || capabilities.settingsTabs.length === 0) return [];
|
||||
const allowed = new Set(capabilities.settingsTabs);
|
||||
return SETTINGS_TABS.filter(tab => allowed.has(tab.id));
|
||||
}
|
||||
|
||||
export function defaultSettingsTabForRole(role) {
|
||||
const capabilities = capabilitiesForRole(role);
|
||||
return capabilities ? capabilities.defaultSettingsTab : null;
|
||||
}
|
||||
|
||||
export function applyModeLabel(mode) {
|
||||
return APPLY_MODE_LABELS[mode] || mode || '—';
|
||||
}
|
||||
|
||||
export function dayShort(dayOfWeek) {
|
||||
return DAY_SHORT_LABELS[Number(dayOfWeek)] || '-';
|
||||
}
|
||||
|
||||
export function scopeOptionLabel(scope) {
|
||||
const suffix = scope.applyMode === 'WEEKDAY'
|
||||
? `, ${dayShort(scope.dayOfWeek)}`
|
||||
: '';
|
||||
return `${scope.name} - ${applyModeLabel(scope.applyMode)}${suffix}`;
|
||||
}
|
||||
|
||||
export function scopeBadgeLabel(scope) {
|
||||
if (scope.applyMode === 'DEFAULT') return 'Базовая';
|
||||
if (scope.applyMode === 'WEEKDAY') return dayShort(scope.dayOfWeek);
|
||||
return 'Ручная';
|
||||
}
|
||||
|
||||
export function scopeBadgeClass(scope) {
|
||||
return scope.applyMode === 'DEFAULT' ? 'scope-badge scope-badge-base' : 'scope-badge';
|
||||
}
|
||||
|
||||
export function scopeSummaryText(scope, slots) {
|
||||
const mode = applyModeLabel(scope.applyMode);
|
||||
const day = scope.applyMode === 'WEEKDAY' ? `, ${dayShort(scope.dayOfWeek)}` : '';
|
||||
const slotCount = slotsForScope(slots, scope.id).length;
|
||||
return `${mode}${day}. Слотов: ${slotCount}.`;
|
||||
}
|
||||
|
||||
export function isSystemScope(scope) {
|
||||
return Boolean(scope && scope.systemScope);
|
||||
}
|
||||
|
||||
export function defaultScopeId(scopes) {
|
||||
const found = (scopes || []).find(scope => scope.applyMode === 'DEFAULT');
|
||||
return found ? found.id : (scopes || [])[0]?.id || null;
|
||||
}
|
||||
|
||||
export function resolveActiveScopeId(scopes, preferredId) {
|
||||
const list = scopes || [];
|
||||
if (preferredId && list.some(scope => scope.id === preferredId)) return preferredId;
|
||||
return defaultScopeId(list);
|
||||
}
|
||||
|
||||
export function slotsForScope(slots, scopeId) {
|
||||
return (slots || [])
|
||||
.filter(slot => slot.scopeId === scopeId)
|
||||
.sort((left, right) => (left.orderNumber || 0) - (right.orderNumber || 0));
|
||||
}
|
||||
|
||||
export function validateSlotPayload({ scopeId, orderNumber, startTime = '', endTime = '' } = {}) {
|
||||
if (!scopeId) return 'Выберите сетку времени';
|
||||
if (!orderNumber || orderNumber <= 0) return 'Укажите номер пары';
|
||||
if (!startTime || !endTime) return 'Укажите время начала и окончания';
|
||||
if (startTime >= endTime) return 'Время начала должно быть раньше времени окончания';
|
||||
return '';
|
||||
}
|
||||
|
||||
export function slotDurationMinutes(startTime = '', endTime = '') {
|
||||
if (!startTime || !endTime || startTime >= endTime) return null;
|
||||
const [startHour, startMinute] = startTime.split(':').map(Number);
|
||||
const [endHour, endMinute] = endTime.split(':').map(Number);
|
||||
return (endHour * 60 + endMinute) - (startHour * 60 + startMinute);
|
||||
}
|
||||
|
||||
export function buildSlotPayload({ id = '', orderNumber, scopeId, startTime, endTime }) {
|
||||
return {
|
||||
id: id ? Number(id) : null,
|
||||
orderNumber: Number(orderNumber),
|
||||
scopeId,
|
||||
startTime,
|
||||
endTime
|
||||
};
|
||||
}
|
||||
|
||||
export function validateTenantFields({ name = '', domain = '', url = '' } = {}) {
|
||||
if (!name.trim() || !domain.trim() || !url.trim()) {
|
||||
return 'Заполните все обязательные поля';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export function normalizeDomain(domain = '') {
|
||||
return domain.trim().toLowerCase();
|
||||
}
|
||||
|
||||
export async function fetchScopes(client = apiClient) {
|
||||
return client.get('/api/admin/time-slots/scopes', { errorMessage: 'Не удалось загрузить сетки времени' });
|
||||
}
|
||||
|
||||
export async function createScope(name, client = apiClient) {
|
||||
return client.post(
|
||||
'/api/admin/time-slots/scopes',
|
||||
{ name },
|
||||
{ errorMessage: 'Не удалось создать сетку времени' }
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteScope(id, client = apiClient) {
|
||||
return client.delete(
|
||||
`/api/admin/time-slots/scopes/${encodeURIComponent(id)}`,
|
||||
{ errorMessage: 'Не удалось удалить сетку времени' }
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchTimeSlots(client = apiClient) {
|
||||
return client.get('/api/admin/time-slots', { errorMessage: 'Не удалось загрузить временные слоты' });
|
||||
}
|
||||
|
||||
export async function saveTimeSlot(payload, client = apiClient) {
|
||||
const options = { errorMessage: 'Не удалось сохранить временной слот' };
|
||||
return payload.id
|
||||
? client.put(`/api/admin/time-slots/${encodeURIComponent(payload.id)}`, payload, options)
|
||||
: client.post('/api/admin/time-slots', payload, options);
|
||||
}
|
||||
|
||||
export async function deleteTimeSlot(id, client = apiClient) {
|
||||
return client.delete(
|
||||
`/api/admin/time-slots/${encodeURIComponent(id)}`,
|
||||
{ errorMessage: 'Не удалось удалить временной слот' }
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchEducationForms(client = apiClient) {
|
||||
return client.get('/api/education-forms', { errorMessage: 'Не удалось загрузить формы обучения' });
|
||||
}
|
||||
|
||||
export async function createEducationForm(name, client = apiClient) {
|
||||
return client.post(
|
||||
'/api/education-forms',
|
||||
{ name },
|
||||
{ errorMessage: 'Не удалось создать форму обучения' }
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteEducationForm(id, client = apiClient) {
|
||||
return client.delete(
|
||||
`/api/education-forms/${encodeURIComponent(id)}`,
|
||||
{ errorMessage: 'Не удалось удалить форму обучения' }
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchDatabaseStatus(client = apiClient) {
|
||||
return client.get('/api/database/status', { errorMessage: 'Не удалось загрузить статус подключения' });
|
||||
}
|
||||
|
||||
export async function fetchTenants(client = apiClient) {
|
||||
return client.get('/api/database/tenants', { errorMessage: 'Не удалось загрузить список тенантов' });
|
||||
}
|
||||
|
||||
export async function testTenantConnection({ url, username, password }, client = apiClient) {
|
||||
return client.post(
|
||||
'/api/database/test',
|
||||
{ password, url, username },
|
||||
{ errorMessage: 'Не удалось проверить подключение' }
|
||||
);
|
||||
}
|
||||
|
||||
export async function createTenant({ name, domain, url, username, password }, client = apiClient) {
|
||||
return client.post(
|
||||
'/api/database/tenants',
|
||||
{ domain, name, password, url, username },
|
||||
{ errorMessage: 'Не удалось добавить тенант' }
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteTenant(domain, client = apiClient) {
|
||||
return client.delete(
|
||||
`/api/database/tenants/${encodeURIComponent(domain)}`,
|
||||
{ errorMessage: 'Не удалось удалить тенант' }
|
||||
);
|
||||
}
|
||||
28
frontend/react/settings/testing/render-settings.jsx
Normal file
28
frontend/react/settings/testing/render-settings.jsx
Normal 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));
|
||||
}
|
||||
99
frontend/react/shared/api-client.js
Normal file
99
frontend/react/shared/api-client.js
Normal file
@@ -0,0 +1,99 @@
|
||||
import { clearAuthState, fetchWithAuth } from '../../auth-session.js';
|
||||
|
||||
const DEFAULT_API_ERROR = 'Не удалось выполнить запрос';
|
||||
const UNAUTHORIZED_ERROR = 'Требуется повторный вход в систему';
|
||||
|
||||
export class ApiRequestError extends Error {
|
||||
constructor(message, { data = null, status = 0 } = {}) {
|
||||
super(message);
|
||||
this.name = 'ApiRequestError';
|
||||
this.data = data;
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
export async function requestJson(
|
||||
endpoint,
|
||||
{ errorMessage = DEFAULT_API_ERROR, headers: initialHeaders, json, ...options } = {},
|
||||
{
|
||||
fetchImpl = fetchWithAuth,
|
||||
onUnauthorized = () => window.location.replace('/')
|
||||
} = {}
|
||||
) {
|
||||
if (typeof endpoint !== 'string' || !endpoint.startsWith('/api/')) {
|
||||
throw new ApiRequestError('Разрешены только same-origin запросы к API');
|
||||
}
|
||||
|
||||
const headers = new Headers(initialHeaders || {});
|
||||
headers.set('Accept', 'application/json');
|
||||
|
||||
let body = options.body;
|
||||
if (json !== undefined) {
|
||||
headers.set('Content-Type', 'application/json');
|
||||
body = JSON.stringify(json);
|
||||
}
|
||||
|
||||
const response = await fetchImpl(endpoint, {
|
||||
...options,
|
||||
body,
|
||||
headers
|
||||
});
|
||||
const data = await readResponseData(response);
|
||||
|
||||
if (response.status === 401) {
|
||||
clearAuthState();
|
||||
onUnauthorized();
|
||||
throw new ApiRequestError(UNAUTHORIZED_ERROR, { data, status: response.status });
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ApiRequestError(errorMessage, { data, status: response.status });
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export const apiClient = Object.freeze({
|
||||
delete: (endpoint, options = {}, dependencies) => requestJson(
|
||||
endpoint,
|
||||
{ ...options, method: 'DELETE' },
|
||||
dependencies
|
||||
),
|
||||
get: (endpoint, options = {}, dependencies) => requestJson(
|
||||
endpoint,
|
||||
{ ...options, method: 'GET' },
|
||||
dependencies
|
||||
),
|
||||
post: (endpoint, json, options = {}, dependencies) => requestJson(
|
||||
endpoint,
|
||||
{ ...options, json, method: 'POST' },
|
||||
dependencies
|
||||
),
|
||||
put: (endpoint, json, options = {}, dependencies) => requestJson(
|
||||
endpoint,
|
||||
{ ...options, json, method: 'PUT' },
|
||||
dependencies
|
||||
)
|
||||
});
|
||||
|
||||
async function readResponseData(response) {
|
||||
if (response.status === 204) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('Content-Type') || '';
|
||||
if (!contentType.includes('application/json')) {
|
||||
return text;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
28
frontend/react/shared/auth-client.js
Normal file
28
frontend/react/shared/auth-client.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import { logoutSession, restoreSession } from '../../auth-session.js';
|
||||
import { resolveSessionState, SESSION_PHASES } from './session-policy.js';
|
||||
|
||||
const SESSION_RESTORE_ERROR = 'Не удалось восстановить сессию';
|
||||
|
||||
export async function loadAuthorizedSession(
|
||||
allowedRoles = [],
|
||||
{ restoreSessionImpl = restoreSession } = {}
|
||||
) {
|
||||
try {
|
||||
const session = await restoreSessionImpl();
|
||||
return resolveSessionState(session, allowedRoles);
|
||||
} catch (error) {
|
||||
return {
|
||||
message: SESSION_RESTORE_ERROR,
|
||||
phase: SESSION_PHASES.error,
|
||||
session: null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function logoutAndRedirect({
|
||||
logoutSessionImpl = logoutSession,
|
||||
replaceLocation = path => window.location.replace(path)
|
||||
} = {}) {
|
||||
await logoutSessionImpl();
|
||||
replaceLocation('/');
|
||||
}
|
||||
53
frontend/react/shared/dates.js
Normal file
53
frontend/react/shared/dates.js
Normal file
@@ -0,0 +1,53 @@
|
||||
export const WEEK_LENGTH_DAYS = 7;
|
||||
|
||||
export function startOfWeek(date) {
|
||||
const copy = new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
||||
const day = copy.getDay() || 7;
|
||||
copy.setDate(copy.getDate() - day + 1);
|
||||
return copy;
|
||||
}
|
||||
|
||||
export function parseIsoDate(value) {
|
||||
const [year, month, day] = String(value || '').split('-').map(Number);
|
||||
return year && month && day ? new Date(year, month - 1, day) : new Date();
|
||||
}
|
||||
|
||||
export function addDays(date, days) {
|
||||
const copy = new Date(date);
|
||||
copy.setDate(copy.getDate() + days);
|
||||
return copy;
|
||||
}
|
||||
|
||||
export function toIsoDate(date) {
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function formatDate(date) {
|
||||
return date.toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||
}
|
||||
|
||||
export function formatIsoDate(value) {
|
||||
const [year, month, day] = String(value || '').split('-');
|
||||
return year && month && day ? `${day}.${month}.${year}` : '—';
|
||||
}
|
||||
|
||||
export function dayName(date) {
|
||||
const value = date.toLocaleDateString('ru-RU', { weekday: 'long' });
|
||||
return value.charAt(0).toUpperCase() + value.slice(1);
|
||||
}
|
||||
|
||||
export function trimTime(value) {
|
||||
return String(value || '').slice(0, 5);
|
||||
}
|
||||
|
||||
export function weekDates(weekStart) {
|
||||
return Array.from({ length: WEEK_LENGTH_DAYS }, (_, index) => addDays(weekStart, index));
|
||||
}
|
||||
|
||||
export function weekEndIso(weekStart) {
|
||||
return toIsoDate(addDays(weekStart, WEEK_LENGTH_DAYS - 1));
|
||||
}
|
||||
|
||||
export function formatWeekLabel(weekStart) {
|
||||
return `${formatDate(weekStart)} - ${formatDate(addDays(weekStart, WEEK_LENGTH_DAYS - 1))}`;
|
||||
}
|
||||
19
frontend/react/shared/runtime-entry.jsx
Normal file
19
frontend/react/shared/runtime-entry.jsx
Normal file
@@ -0,0 +1,19 @@
|
||||
export {
|
||||
Component,
|
||||
Fragment,
|
||||
StrictMode,
|
||||
createElement,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react';
|
||||
export { createRoot } from 'react-dom/client';
|
||||
|
||||
export { apiClient, ApiRequestError, requestJson } from './api-client.js';
|
||||
export { loadAuthorizedSession, logoutAndRedirect } from './auth-client.js';
|
||||
export { resolveSessionState, SESSION_PHASES } from './session-policy.js';
|
||||
export { useAuthorizedSession } from './use-authorized-session.js';
|
||||
export { AppErrorBoundary } from './ui/AppErrorBoundary.jsx';
|
||||
export { AsyncState } from './ui/AsyncState.jsx';
|
||||
export { FormAlert } from './ui/FormAlert.jsx';
|
||||
38
frontend/react/shared/session-policy.js
Normal file
38
frontend/react/shared/session-policy.js
Normal file
@@ -0,0 +1,38 @@
|
||||
export const SESSION_PHASES = Object.freeze({
|
||||
error: 'error',
|
||||
loading: 'loading',
|
||||
ready: 'ready',
|
||||
unauthorized: 'unauthorized'
|
||||
});
|
||||
|
||||
export function resolveSessionState(session, allowedRoles = []) {
|
||||
if (!session?.role) {
|
||||
return {
|
||||
message: 'Требуется повторный вход в систему',
|
||||
phase: SESSION_PHASES.unauthorized,
|
||||
session: null
|
||||
};
|
||||
}
|
||||
|
||||
if (allowedRoles.length > 0 && !allowedRoles.includes(session.role)) {
|
||||
return {
|
||||
message: 'У вас нет доступа к этому разделу',
|
||||
phase: SESSION_PHASES.unauthorized,
|
||||
session: null
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
message: '',
|
||||
phase: SESSION_PHASES.ready,
|
||||
session
|
||||
};
|
||||
}
|
||||
|
||||
export function loadingSessionState() {
|
||||
return {
|
||||
message: 'Проверяем сессию…',
|
||||
phase: SESSION_PHASES.loading,
|
||||
session: null
|
||||
};
|
||||
}
|
||||
22
frontend/react/shared/testing/render-shared.jsx
Normal file
22
frontend/react/shared/testing/render-shared.jsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
|
||||
import { AppErrorBoundary } from '../ui/AppErrorBoundary.jsx';
|
||||
import { AsyncState } from '../ui/AsyncState.jsx';
|
||||
import { FormAlert } from '../ui/FormAlert.jsx';
|
||||
|
||||
export function renderAsyncState(props) {
|
||||
return renderToStaticMarkup(<AsyncState {...props} />);
|
||||
}
|
||||
|
||||
export function renderFormAlert(props) {
|
||||
return renderToStaticMarkup(<FormAlert {...props} />);
|
||||
}
|
||||
|
||||
export function renderErrorBoundaryContent() {
|
||||
return renderToStaticMarkup(
|
||||
<AppErrorBoundary>
|
||||
<p>Рабочее содержимое</p>
|
||||
</AppErrorBoundary>
|
||||
);
|
||||
}
|
||||
28
frontend/react/shared/ui/AppErrorBoundary.jsx
Normal file
28
frontend/react/shared/ui/AppErrorBoundary.jsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Component } from 'react';
|
||||
|
||||
import { AsyncState } from './AsyncState.jsx';
|
||||
|
||||
export class AppErrorBoundary extends Component {
|
||||
state = { failed: false };
|
||||
|
||||
static getDerivedStateFromError() {
|
||||
return { failed: true };
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.failed) {
|
||||
return (
|
||||
<main className="ui-fatal-state">
|
||||
<AsyncState
|
||||
kind="error"
|
||||
title="Интерфейс временно недоступен"
|
||||
message="Обновите страницу, чтобы восстановить работу"
|
||||
onRetry={() => window.location.reload()}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
175
frontend/react/shared/ui/AsyncCombobox.jsx
Normal file
175
frontend/react/shared/ui/AsyncCombobox.jsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import { useEffect, useId, useRef, useState } from 'react';
|
||||
|
||||
const SEARCH_DEBOUNCE_MS = 320;
|
||||
|
||||
export function AsyncCombobox({
|
||||
allowClear = true,
|
||||
ariaLabel = 'Поисковый список',
|
||||
emptyLabel = 'Ничего не найдено',
|
||||
load,
|
||||
onSelect,
|
||||
optionDescription = () => '',
|
||||
optionLabel = item => item.name,
|
||||
optionValue = item => item.id,
|
||||
placeholder = 'Начните вводить название',
|
||||
selectedLabel = '',
|
||||
selectedValue = ''
|
||||
}) {
|
||||
const rootRef = useRef(null);
|
||||
const inputRef = useRef(null);
|
||||
const debounceTimer = useRef(null);
|
||||
const requestNumber = useRef(0);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const [items, setItems] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const listId = useId();
|
||||
|
||||
useEffect(() => () => {
|
||||
window.clearTimeout(debounceTimer.current);
|
||||
requestNumber.current += 1;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return undefined;
|
||||
const handlePointerDown = event => {
|
||||
if (rootRef.current && !rootRef.current.contains(event.target)) {
|
||||
close();
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handlePointerDown);
|
||||
return () => document.removeEventListener('mousedown', handlePointerDown);
|
||||
});
|
||||
|
||||
async function search(nextQuery) {
|
||||
const currentRequest = requestNumber.current + 1;
|
||||
requestNumber.current = currentRequest;
|
||||
setOpen(true);
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const result = await load(String(nextQuery || '').trim(), selectedValue || '');
|
||||
if (requestNumber.current !== currentRequest) return;
|
||||
setItems(Array.isArray(result) ? result : []);
|
||||
setLoading(false);
|
||||
} catch {
|
||||
if (requestNumber.current !== currentRequest) return;
|
||||
setError('Не удалось выполнить поиск');
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
window.clearTimeout(debounceTimer.current);
|
||||
requestNumber.current += 1;
|
||||
setOpen(false);
|
||||
setQuery('');
|
||||
setItems([]);
|
||||
setError('');
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
function choose(item) {
|
||||
onSelect(item);
|
||||
close();
|
||||
}
|
||||
|
||||
function clear() {
|
||||
onSelect(null);
|
||||
setQuery('');
|
||||
inputRef.current?.focus();
|
||||
void search('');
|
||||
}
|
||||
|
||||
function handleFocus() {
|
||||
setQuery(selectedLabel);
|
||||
void search(selectedLabel);
|
||||
}
|
||||
|
||||
function handleInput(event) {
|
||||
const value = event.target.value;
|
||||
setQuery(value);
|
||||
window.clearTimeout(debounceTimer.current);
|
||||
debounceTimer.current = window.setTimeout(() => {
|
||||
void search(value);
|
||||
}, SEARCH_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
function handleInputKeydown(event) {
|
||||
if (event.key === 'Escape') {
|
||||
close();
|
||||
} else if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
rootRef.current?.querySelector('[role="option"]')?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function handleMenuKeydown(event) {
|
||||
const options = Array.from(rootRef.current?.querySelectorAll('[role="option"]') || []);
|
||||
const current = options.indexOf(document.activeElement);
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
close();
|
||||
inputRef.current?.focus();
|
||||
} else if (event.key === 'ArrowDown' && current >= 0) {
|
||||
event.preventDefault();
|
||||
options[Math.min(current + 1, options.length - 1)]?.focus();
|
||||
} else if (event.key === 'ArrowUp' && current >= 0) {
|
||||
event.preventDefault();
|
||||
(current === 0 ? inputRef.current : options[current - 1])?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`async-combobox${open ? ' is-open' : ''}`} ref={rootRef}>
|
||||
<div className="async-combobox-control">
|
||||
<input
|
||||
type="search"
|
||||
role="combobox"
|
||||
aria-autocomplete="list"
|
||||
aria-expanded={open}
|
||||
aria-controls={listId}
|
||||
aria-label={ariaLabel}
|
||||
autoComplete="off"
|
||||
placeholder={placeholder}
|
||||
value={open ? query : selectedLabel}
|
||||
onChange={handleInput}
|
||||
onFocus={handleFocus}
|
||||
onKeyDown={handleInputKeydown}
|
||||
ref={inputRef}
|
||||
/>
|
||||
{allowClear && (
|
||||
<button type="button" className="async-combobox-clear" aria-label="Очистить выбор" onClick={clear}>×</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="async-combobox-menu" role="listbox" id={listId} hidden={!open} onKeyDown={handleMenuKeydown}>
|
||||
{open && (
|
||||
loading ? (
|
||||
<div className="async-combobox-state"><span className="state-spinner" aria-hidden="true" />Поиск...</div>
|
||||
) : error ? (
|
||||
<div className="async-combobox-state is-error">{error}</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="async-combobox-state">{emptyLabel}</div>
|
||||
) : items.map(item => {
|
||||
const value = String(optionValue(item));
|
||||
const description = optionDescription(item);
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
role="option"
|
||||
className="async-combobox-option"
|
||||
aria-selected={String(selectedValue) === value}
|
||||
onClick={() => choose(item)}
|
||||
>
|
||||
<strong>{optionLabel(item)}</strong>
|
||||
{description ? <small>{description}</small> : null}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
frontend/react/shared/ui/AsyncState.jsx
Normal file
38
frontend/react/shared/ui/AsyncState.jsx
Normal file
@@ -0,0 +1,38 @@
|
||||
const DEFAULT_COPY = Object.freeze({
|
||||
empty: {
|
||||
message: 'Здесь пока нет данных',
|
||||
title: 'Пусто'
|
||||
},
|
||||
error: {
|
||||
message: 'Попробуйте повторить запрос',
|
||||
title: 'Не удалось загрузить данные'
|
||||
},
|
||||
loading: {
|
||||
message: 'Это займёт несколько секунд',
|
||||
title: 'Загружаем данные'
|
||||
}
|
||||
});
|
||||
|
||||
export function AsyncState({ kind = 'loading', message, onRetry, title }) {
|
||||
const copy = DEFAULT_COPY[kind] || DEFAULT_COPY.loading;
|
||||
const isError = kind === 'error';
|
||||
|
||||
return (
|
||||
<section
|
||||
className={`ui-state ui-state--${kind}`}
|
||||
role={isError ? 'alert' : 'status'}
|
||||
aria-live={isError ? 'assertive' : 'polite'}
|
||||
>
|
||||
<span className="ui-state__signal" aria-hidden="true" />
|
||||
<div className="ui-state__content">
|
||||
<h2 className="ui-state__title">{title || copy.title}</h2>
|
||||
<p className="ui-state__message">{message || copy.message}</p>
|
||||
</div>
|
||||
{isError && onRetry && (
|
||||
<button type="button" className="btn btn-md btn-secondary" onClick={onRetry}>
|
||||
Повторить
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
186
frontend/react/shared/ui/ConfirmDialog.jsx
Normal file
186
frontend/react/shared/ui/ConfirmDialog.jsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
export function ConfirmDialog({
|
||||
title = 'Подтвердите действие',
|
||||
message = '',
|
||||
confirmLabel = 'Подтвердить',
|
||||
cancelLabel = 'Отмена',
|
||||
danger = false,
|
||||
onConfirm,
|
||||
onClose
|
||||
}) {
|
||||
const confirmRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
confirmRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeydown = event => {
|
||||
if (event.key === 'Escape') onClose();
|
||||
};
|
||||
document.addEventListener('keydown', handleKeydown);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeydown);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="project-dialog-backdrop"
|
||||
onClick={event => {
|
||||
if (event.target === event.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<section
|
||||
className="project-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="project-dialog-title"
|
||||
aria-describedby="project-dialog-message"
|
||||
>
|
||||
<header>
|
||||
<h2 id="project-dialog-title">{title}</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-icon-md btn-ghost"
|
||||
aria-label="Закрыть"
|
||||
onClick={onClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
<p id="project-dialog-message">{message}</p>
|
||||
<footer>
|
||||
{cancelLabel && (
|
||||
<button type="button" className="btn btn-md btn-secondary" onClick={onClose}>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
ref={confirmRef}
|
||||
type="button"
|
||||
className={`btn btn-md ${danger ? 'btn-danger' : 'btn-primary'}`}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AlertDialog({ title, message, confirmLabel = 'Понятно', onClose }) {
|
||||
return (
|
||||
<ConfirmDialog
|
||||
cancelLabel={null}
|
||||
confirmLabel={confirmLabel}
|
||||
message={message}
|
||||
title={title}
|
||||
onClose={onClose}
|
||||
onConfirm={onClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Реплика legacy promptAction: диалог с обязательным текстовым полем.
|
||||
// onConfirm получает trimmed-значение; пустое обязательное поле показывает
|
||||
// ошибку внутри диалога, как в admin/js/dialog.js.
|
||||
export function PromptDialog({
|
||||
title = 'Подтвердите действие',
|
||||
message = '',
|
||||
confirmLabel = 'Подтвердить',
|
||||
cancelLabel = 'Отмена',
|
||||
danger = false,
|
||||
input = {},
|
||||
onConfirm,
|
||||
onClose
|
||||
}) {
|
||||
const [value, setValue] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const inputRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeydown = event => {
|
||||
if (event.key === 'Escape') onClose();
|
||||
};
|
||||
document.addEventListener('keydown', handleKeydown);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeydown);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
function confirm() {
|
||||
const trimmed = value.trim();
|
||||
if (input.required && !trimmed) {
|
||||
setError(input.requiredMessage || 'Заполните поле');
|
||||
inputRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
onConfirm(trimmed);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="project-dialog-backdrop"
|
||||
onClick={event => {
|
||||
if (event.target === event.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<section
|
||||
className="project-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="project-dialog-title"
|
||||
aria-describedby="project-dialog-message"
|
||||
>
|
||||
<header>
|
||||
<h2 id="project-dialog-title">{title}</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-icon-md btn-ghost"
|
||||
aria-label="Закрыть"
|
||||
onClick={onClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
<p id="project-dialog-message">{message}</p>
|
||||
<label className="project-dialog-field">
|
||||
<span>{input.label || 'Комментарий'}</span>
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
rows="4"
|
||||
maxLength={Number(input.maxlength || 2000)}
|
||||
placeholder={input.placeholder || ''}
|
||||
value={value}
|
||||
onChange={event => {
|
||||
setValue(event.target.value);
|
||||
setError('');
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<div className="project-dialog-error" role="alert">{error}</div>
|
||||
<footer>
|
||||
{cancelLabel && (
|
||||
<button type="button" className="btn btn-md btn-secondary" onClick={onClose}>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-md ${danger ? 'btn-danger' : 'btn-primary'}`}
|
||||
onClick={confirm}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
10
frontend/react/shared/ui/FormAlert.jsx
Normal file
10
frontend/react/shared/ui/FormAlert.jsx
Normal file
@@ -0,0 +1,10 @@
|
||||
export function FormAlert({ alert, className = '', id }) {
|
||||
const tone = alert?.type === 'success' ? 'success' : 'error';
|
||||
const classes = ['form-alert', className, alert ? tone : ''].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<div className={classes} id={id} role="alert" aria-live="polite">
|
||||
{alert?.message || ''}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
57
frontend/react/shared/ui/Pagination.jsx
Normal file
57
frontend/react/shared/ui/Pagination.jsx
Normal file
@@ -0,0 +1,57 @@
|
||||
const PAGE_SIZES = [25, 50, 100];
|
||||
|
||||
export function Pagination({
|
||||
id,
|
||||
page = 0,
|
||||
size = 25,
|
||||
totalItems = 0,
|
||||
totalPages = 0,
|
||||
label = 'записей',
|
||||
onPage,
|
||||
onSize
|
||||
}) {
|
||||
const total = Number(totalItems || 0);
|
||||
const pages = Math.max(Number(totalPages || 0), total ? 1 : 0);
|
||||
const current = Number(page || 0);
|
||||
const pageSize = Number(size || 25);
|
||||
const start = total ? current * pageSize + 1 : 0;
|
||||
const end = Math.min((current + 1) * pageSize, total);
|
||||
|
||||
return (
|
||||
<div className="pagination" id={id}>
|
||||
<div className="pagination-summary" aria-live="polite">
|
||||
Показано {start}–{end} из {total} {label}
|
||||
</div>
|
||||
<div className="pagination-actions">
|
||||
<label className="pagination-size">
|
||||
<span>На странице</span>
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={event => onSize?.(Number(event.target.value))}
|
||||
>
|
||||
{PAGE_SIZES.map(value => (
|
||||
<option key={value} value={value}>{value}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-secondary"
|
||||
disabled={current <= 0}
|
||||
onClick={() => onPage?.(current - 1)}
|
||||
>
|
||||
Назад
|
||||
</button>
|
||||
<span className="pagination-page">{pages ? current + 1 : 0} / {pages}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-secondary"
|
||||
disabled={current >= pages - 1}
|
||||
onClick={() => onPage?.(current + 1)}
|
||||
>
|
||||
Далее
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
93
frontend/react/shared/ui/ScheduleOverview.jsx
Normal file
93
frontend/react/shared/ui/ScheduleOverview.jsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import {
|
||||
buildScheduleOverviewModel,
|
||||
formatLongDate,
|
||||
lessonMeta,
|
||||
lessonStart,
|
||||
pluralLessons,
|
||||
relativeDate,
|
||||
trimTime
|
||||
} from '../../../schedule-overview.js';
|
||||
|
||||
export function ScheduleOverview({ lessons, now = new Date(), onGoToday, role = 'student' }) {
|
||||
const { todayIso, todayLessons, nextLesson } = buildScheduleOverviewModel(lessons, now);
|
||||
|
||||
return (
|
||||
<section className="schedule-overview" aria-live="polite">
|
||||
<header className="schedule-overview-head">
|
||||
<div>
|
||||
<span className="schedule-overview-kicker">Быстрый обзор</span>
|
||||
<h2>{formatLongDate(now)}</h2>
|
||||
</div>
|
||||
<button className="btn btn-md btn-secondary" type="button" onClick={onGoToday}>
|
||||
Открыть текущую неделю
|
||||
</button>
|
||||
</header>
|
||||
<div className="schedule-overview-grid">
|
||||
<article className="schedule-overview-card is-today">
|
||||
<div className="schedule-overview-card-head">
|
||||
<div>
|
||||
<span>Сегодня</span>
|
||||
<h3>{todayLessons.length ? pluralLessons(todayLessons.length) : 'Свободный день'}</h3>
|
||||
</div>
|
||||
<span className="schedule-overview-count">{todayLessons.length}</span>
|
||||
</div>
|
||||
<div className="schedule-overview-list">
|
||||
{todayLessons.length > 0
|
||||
? todayLessons.map((lesson, index) => (
|
||||
<CompactLesson
|
||||
key={`${lesson.date}-${trimTime(lesson.startTime)}-${index}`}
|
||||
lesson={lesson}
|
||||
role={role}
|
||||
/>
|
||||
))
|
||||
: <p className="schedule-overview-empty">На сегодня занятий нет.</p>}
|
||||
</div>
|
||||
</article>
|
||||
<article className="schedule-overview-card is-next">
|
||||
<div className="schedule-overview-card-head">
|
||||
<div>
|
||||
<span>Следующая пара</span>
|
||||
<h3>{nextLesson ? relativeDate(nextLesson.date, todayIso) : 'Пока ничего не запланировано'}</h3>
|
||||
</div>
|
||||
{nextLesson && (
|
||||
<span className="schedule-overview-badge">
|
||||
{lessonStart(nextLesson) <= now ? 'Идёт сейчас' : trimTime(nextLesson.startTime)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{nextLesson
|
||||
? <NextLessonCard lesson={nextLesson} role={role} />
|
||||
: <p className="schedule-overview-empty">В ближайшие 14 дней занятий нет.</p>}
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function CompactLesson({ lesson, role }) {
|
||||
return (
|
||||
<div className="schedule-overview-lesson">
|
||||
<time>{trimTime(lesson.startTime)}</time>
|
||||
<div>
|
||||
<strong>{lesson.subjectName || 'Занятие'}</strong>
|
||||
<small>{lessonMeta(lesson, role).join(' · ') || 'Подробности уточняются'}</small>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NextLessonCard({ lesson, role }) {
|
||||
return (
|
||||
<div className="schedule-overview-next">
|
||||
<div className="schedule-overview-next-time">
|
||||
<strong>{trimTime(lesson.startTime)}</strong>
|
||||
<span>до {trimTime(lesson.endTime)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<h4>{lesson.subjectName || 'Занятие'}</h4>
|
||||
<p>{lessonMeta(lesson, role).join(' · ') || 'Подробности уточняются'}</p>
|
||||
{lesson.lessonTypeName && <span className="schedule-overview-type">{lesson.lessonTypeName}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
35
frontend/react/shared/use-authorized-session.js
Normal file
35
frontend/react/shared/use-authorized-session.js
Normal file
@@ -0,0 +1,35 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { loadAuthorizedSession } from './auth-client.js';
|
||||
import { loadingSessionState, SESSION_PHASES } from './session-policy.js';
|
||||
|
||||
export function useAuthorizedSession(allowedRoles = [], { redirectOnUnauthorized = true } = {}) {
|
||||
const roleKey = useMemo(
|
||||
() => [...allowedRoles].sort().join('|'),
|
||||
[allowedRoles]
|
||||
);
|
||||
const normalizedRoles = useMemo(
|
||||
() => roleKey ? roleKey.split('|') : [],
|
||||
[roleKey]
|
||||
);
|
||||
const [state, setState] = useState(loadingSessionState);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setState(loadingSessionState());
|
||||
|
||||
void loadAuthorizedSession(normalizedRoles).then(result => {
|
||||
if (!active) return;
|
||||
setState(result);
|
||||
if (redirectOnUnauthorized && result.phase === SESSION_PHASES.unauthorized) {
|
||||
window.location.replace('/');
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [normalizedRoles, redirectOnUnauthorized]);
|
||||
|
||||
return state;
|
||||
}
|
||||
326
frontend/react/student/StudentApp.jsx
Normal file
326
frontend/react/student/StudentApp.jsx
Normal file
@@ -0,0 +1,326 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { readUrlState, writeUrlState } from '../../admin/js/url-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 { AsyncCombobox } from '../shared/ui/AsyncCombobox.jsx';
|
||||
import { AsyncState } from '../shared/ui/AsyncState.jsx';
|
||||
import { buildScheduleOverviewModel } from '../../schedule-overview.js';
|
||||
import { ScheduleOverview } from '../shared/ui/ScheduleOverview.jsx';
|
||||
import { WeekGrid } from './WeekGrid.jsx';
|
||||
import {
|
||||
addDays,
|
||||
buildWeekStatusMessage,
|
||||
fetchGroupOptions,
|
||||
fetchSchedule,
|
||||
formatWeekLabel,
|
||||
groupOptionDescription,
|
||||
GROUP_STORAGE_KEY,
|
||||
OVERVIEW_DAYS,
|
||||
parseIsoDate,
|
||||
resolveSelectedGroup,
|
||||
startOfWeek,
|
||||
toIsoDate,
|
||||
weekEndIso
|
||||
} from './student-service.js';
|
||||
|
||||
const THEME_STORAGE_KEY = 'theme';
|
||||
|
||||
export function StudentApp() {
|
||||
const sessionState = useAuthorizedSession(['STUDENT']);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.dataset.theme = localStorage.getItem(THEME_STORAGE_KEY) || 'light';
|
||||
}, []);
|
||||
|
||||
if (sessionState.phase === SESSION_PHASES.loading) {
|
||||
return (
|
||||
<main className="shell">
|
||||
<AsyncState kind="loading" title="Расписание студента" message="Проверяем сессию…" />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (sessionState.phase !== SESSION_PHASES.ready) {
|
||||
return (
|
||||
<main className="shell">
|
||||
<AsyncState kind="error" title="Раздел недоступен" message={sessionState.message} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return <StudentWorkspace />;
|
||||
}
|
||||
|
||||
function StudentWorkspace() {
|
||||
const [initialUrlState] = useState(() => readUrlState({ studentGroup: '', studentDate: '' }));
|
||||
const [weekStart, setWeekStart] = useState(() => startOfWeek(
|
||||
initialUrlState.studentDate ? parseIsoDate(initialUrlState.studentDate) : new Date()
|
||||
));
|
||||
const [group, setGroup] = useState(null);
|
||||
const [groupsState, setGroupsState] = useState({ kind: 'loading' });
|
||||
const [groupsReload, setGroupsReload] = useState(0);
|
||||
const [scheduleState, setScheduleState] = useState({ kind: 'loading' });
|
||||
const [overviewState, setOverviewState] = useState({ kind: 'loading' });
|
||||
const [overviewReload, setOverviewReload] = useState(0);
|
||||
|
||||
const weekStartIso = toIsoDate(weekStart);
|
||||
const groupId = group ? String(group.id) : '';
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
(async () => {
|
||||
setGroupsState({ kind: 'loading' });
|
||||
const savedGroupId = initialUrlState.studentGroup
|
||||
|| localStorage.getItem(GROUP_STORAGE_KEY)
|
||||
|| '';
|
||||
try {
|
||||
const groups = await fetchGroupOptions({ selectedId: savedGroupId });
|
||||
if (!active) return;
|
||||
if (!groups.length) {
|
||||
setGroupsState({ kind: 'empty' });
|
||||
setOverviewState({ kind: 'empty', message: 'Нет доступных групп' });
|
||||
return;
|
||||
}
|
||||
const selected = resolveSelectedGroup(groups, savedGroupId);
|
||||
applyGroup(selected);
|
||||
setGroupsState({ kind: 'ready' });
|
||||
} catch {
|
||||
if (!active) return;
|
||||
setGroupsState({ kind: 'error' });
|
||||
setOverviewState({ kind: 'error', message: 'Не удалось загрузить ближайшие занятия' });
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [groupsReload, initialUrlState]);
|
||||
|
||||
useEffect(() => {
|
||||
writeUrlState({ studentDate: weekStartIso });
|
||||
}, [weekStartIso]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!group) return undefined;
|
||||
let active = true;
|
||||
(async () => {
|
||||
setScheduleState({ kind: 'loading' });
|
||||
try {
|
||||
const lessons = await fetchSchedule({
|
||||
groupId: String(group.id),
|
||||
startDate: weekStartIso,
|
||||
endDate: weekEndIso(weekStart)
|
||||
});
|
||||
if (!active) return;
|
||||
setScheduleState({ kind: 'ready', lessons });
|
||||
trackUxEvent('student_schedule_loaded', {
|
||||
lessons: lessons.length,
|
||||
startDate: weekStartIso,
|
||||
groupId: String(group.id)
|
||||
});
|
||||
} catch {
|
||||
if (!active) return;
|
||||
setScheduleState({ kind: 'error' });
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [group, weekStart, weekStartIso]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!group) return undefined;
|
||||
let active = true;
|
||||
(async () => {
|
||||
setOverviewState({ kind: 'loading' });
|
||||
const now = new Date();
|
||||
try {
|
||||
const lessons = await fetchSchedule({
|
||||
groupId: String(group.id),
|
||||
startDate: toIsoDate(now),
|
||||
endDate: toIsoDate(addDays(now, OVERVIEW_DAYS))
|
||||
});
|
||||
if (!active) return;
|
||||
setOverviewState({ kind: 'ready', lessons, now });
|
||||
const overview = buildScheduleOverviewModel(lessons, now);
|
||||
trackUxEvent('student_schedule_overview_loaded', {
|
||||
todayCount: overview.todayLessons.length,
|
||||
hasNext: Boolean(overview.nextLesson),
|
||||
groupId: String(group.id)
|
||||
});
|
||||
} catch {
|
||||
if (!active) return;
|
||||
setOverviewState({ kind: 'error', message: 'Не удалось загрузить ближайшие занятия' });
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [group, overviewReload]);
|
||||
|
||||
function applyGroup(nextGroup) {
|
||||
setGroup(nextGroup);
|
||||
localStorage.setItem(GROUP_STORAGE_KEY, String(nextGroup.id));
|
||||
writeUrlState({ studentGroup: String(nextGroup.id) });
|
||||
}
|
||||
|
||||
function handleGroupSelect(nextGroup) {
|
||||
if (!nextGroup) return;
|
||||
applyGroup(nextGroup);
|
||||
trackUxEvent('student_group_selected', { groupId: String(nextGroup.id) });
|
||||
}
|
||||
|
||||
function moveWeek(delta) {
|
||||
setWeekStart(current => addDays(current, delta * 7));
|
||||
}
|
||||
|
||||
function goToCurrentWeek() {
|
||||
setWeekStart(startOfWeek(new Date()));
|
||||
}
|
||||
|
||||
function handleDateChange(event) {
|
||||
if (event.target.value) {
|
||||
setWeekStart(startOfWeek(parseIsoDate(event.target.value)));
|
||||
}
|
||||
}
|
||||
|
||||
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 (
|
||||
<main className="shell">
|
||||
<header className="topbar">
|
||||
<div className="title">
|
||||
<h1>Расписание студента</h1>
|
||||
<span>{formatWeekLabel(weekStart)}</span>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button
|
||||
className="btn btn-icon-md btn-secondary"
|
||||
type="button"
|
||||
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>
|
||||
<a className="btn btn-danger-subtle" href="/" onClick={handleLogout}>Выйти</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="toolbar">
|
||||
{groupsState.kind === 'ready' && group ? (
|
||||
<AsyncCombobox
|
||||
allowClear={false}
|
||||
ariaLabel="Группа"
|
||||
emptyLabel="Подходящие группы не найдены"
|
||||
placeholder="Найдите группу по названию или специальности"
|
||||
load={(query, selectedId) => fetchGroupOptions({ query, selectedId })}
|
||||
onSelect={handleGroupSelect}
|
||||
optionDescription={groupOptionDescription}
|
||||
optionLabel={option => option.name}
|
||||
optionValue={option => option.id}
|
||||
selectedLabel={group.name}
|
||||
selectedValue={groupId}
|
||||
/>
|
||||
) : (
|
||||
<select className="select" aria-label="Группа" disabled>
|
||||
<option value="">{groupSelectPlaceholder(groupsState.kind)}</option>
|
||||
</select>
|
||||
)}
|
||||
<div className="week-nav">
|
||||
<button className="btn btn-icon-md btn-secondary" type="button" aria-label="Предыдущая неделя" onClick={() => moveWeek(-1)}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<polyline points="15 18 9 12 15 6" />
|
||||
</svg>
|
||||
</button>
|
||||
<button className="btn btn-md btn-primary" type="button" onClick={goToCurrentWeek}>Сегодня</button>
|
||||
<button className="btn btn-icon-md btn-secondary" type="button" aria-label="Следующая неделя" onClick={() => moveWeek(1)}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
className="date-input"
|
||||
type="date"
|
||||
aria-label="Дата недели"
|
||||
value={weekStartIso}
|
||||
onChange={handleDateChange}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{overviewState.kind === 'ready' ? (
|
||||
<ScheduleOverview
|
||||
lessons={overviewState.lessons}
|
||||
now={overviewState.now}
|
||||
onGoToday={goToCurrentWeek}
|
||||
role="student"
|
||||
/>
|
||||
) : (
|
||||
<section className="schedule-overview" aria-live="polite">
|
||||
{overviewState.kind === 'empty' ? (
|
||||
<AsyncState kind="empty" title="Быстрый обзор" message={overviewState.message || 'В ближайшие дни занятий нет'} />
|
||||
) : overviewState.kind === 'error' ? (
|
||||
<AsyncState
|
||||
kind="error"
|
||||
title="Быстрый обзор"
|
||||
message={overviewState.message || 'Не удалось загрузить ближайшие занятия'}
|
||||
onRetry={() => setOverviewReload(reload => reload + 1)}
|
||||
/>
|
||||
) : (
|
||||
<AsyncState kind="loading" title="Быстрый обзор" message="Загружаем ближайшие занятия…" />
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div className={`status${scheduleState.kind === 'error' ? ' error' : ''}`}>
|
||||
{statusMessage(scheduleState, groupsState)}
|
||||
</div>
|
||||
|
||||
{groupsState.kind === 'error' ? (
|
||||
<AsyncState
|
||||
kind="error"
|
||||
title="Группы"
|
||||
message="Не удалось загрузить группы"
|
||||
onRetry={() => setGroupsReload(reload => reload + 1)}
|
||||
/>
|
||||
) : scheduleState.kind === 'ready' ? (
|
||||
<WeekGrid weekStart={weekStart} lessons={scheduleState.lessons} />
|
||||
) : scheduleState.kind === 'error' ? (
|
||||
<WeekGrid weekStart={weekStart} lessons={[]} emptyMessage="Ошибка загрузки" />
|
||||
) : (
|
||||
<WeekGrid
|
||||
weekStart={weekStart}
|
||||
lessons={[]}
|
||||
emptyMessage={groupId ? 'Загрузка...' : 'Нет выбранной группы'}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function groupSelectPlaceholder(kind) {
|
||||
if (kind === 'empty') return 'Группы не найдены';
|
||||
if (kind === 'error') return 'Ошибка загрузки групп';
|
||||
return 'Загрузка групп...';
|
||||
}
|
||||
|
||||
function statusMessage(scheduleState, groupsState) {
|
||||
if (groupsState.kind === 'error') return 'Не удалось загрузить группы';
|
||||
if (groupsState.kind === 'empty') return 'Выберите группу';
|
||||
if (scheduleState.kind === 'error') return 'Не удалось загрузить расписание';
|
||||
if (scheduleState.kind === 'ready') return buildWeekStatusMessage(scheduleState.lessons);
|
||||
return 'Загрузка расписания...';
|
||||
}
|
||||
58
frontend/react/student/WeekGrid.jsx
Normal file
58
frontend/react/student/WeekGrid.jsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
buildLessonChips,
|
||||
dayName,
|
||||
formatDate,
|
||||
groupLessonsByDate,
|
||||
toIsoDate,
|
||||
trimTime,
|
||||
weekDates
|
||||
} from './student-service.js';
|
||||
|
||||
export function WeekGrid({ emptyMessage = 'Занятий нет', lessons = [], weekStart }) {
|
||||
const lessonsByDate = groupLessonsByDate(lessons);
|
||||
|
||||
return (
|
||||
<section className="schedule" aria-live="polite">
|
||||
{weekDates(weekStart).map(date => (
|
||||
<DayCard
|
||||
key={toIsoDate(date)}
|
||||
date={date}
|
||||
lessons={lessonsByDate.get(toIsoDate(date)) || []}
|
||||
emptyMessage={emptyMessage}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function DayCard({ date, lessons, emptyMessage }) {
|
||||
return (
|
||||
<article className="day">
|
||||
<div className="day-head">
|
||||
<div className="day-name">{dayName(date)}</div>
|
||||
<div className="day-date">{formatDate(date)}</div>
|
||||
</div>
|
||||
<div className="lesson-list">
|
||||
{lessons.length > 0
|
||||
? lessons.map((lesson, index) => (
|
||||
<LessonCard key={`${lesson.date}-${trimTime(lesson.startTime)}-${index}`} lesson={lesson} />
|
||||
))
|
||||
: <div className="empty">{emptyMessage}</div>}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function LessonCard({ lesson }) {
|
||||
return (
|
||||
<div className="lesson">
|
||||
<div className="lesson-time">{trimTime(lesson.startTime)} - {trimTime(lesson.endTime)}</div>
|
||||
<div className="lesson-title">{lesson.subjectName}</div>
|
||||
<div className="lesson-meta">
|
||||
{buildLessonChips(lesson).map((chip, index) => (
|
||||
<span key={index} className="chip">{chip}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
22
frontend/react/student/main.jsx
Normal file
22
frontend/react/student/main.jsx
Normal 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 { StudentApp } from './StudentApp.jsx';
|
||||
|
||||
void initializeTelemetry();
|
||||
|
||||
const rootElement = document.getElementById('student-root');
|
||||
|
||||
if (!rootElement) {
|
||||
throw new Error('Не найден корневой элемент кабинета студента');
|
||||
}
|
||||
|
||||
createRoot(rootElement).render(
|
||||
<StrictMode>
|
||||
<AppErrorBoundary>
|
||||
<StudentApp />
|
||||
</AppErrorBoundary>
|
||||
</StrictMode>
|
||||
);
|
||||
73
frontend/react/student/student-service.js
Normal file
73
frontend/react/student/student-service.js
Normal file
@@ -0,0 +1,73 @@
|
||||
import { apiClient } from '../shared/api-client.js';
|
||||
|
||||
export {
|
||||
addDays,
|
||||
dayName,
|
||||
formatDate,
|
||||
formatWeekLabel,
|
||||
parseIsoDate,
|
||||
startOfWeek,
|
||||
toIsoDate,
|
||||
trimTime,
|
||||
weekDates,
|
||||
weekEndIso,
|
||||
WEEK_LENGTH_DAYS
|
||||
} from '../shared/dates.js';
|
||||
|
||||
export const OVERVIEW_DAYS = 14;
|
||||
export const GROUP_STORAGE_KEY = 'studentGroupId';
|
||||
|
||||
export function groupLessonsByDate(lessons) {
|
||||
const byDate = new Map();
|
||||
(lessons || []).forEach(lesson => {
|
||||
if (!byDate.has(lesson.date)) byDate.set(lesson.date, []);
|
||||
byDate.get(lesson.date).push(lesson);
|
||||
});
|
||||
return byDate;
|
||||
}
|
||||
|
||||
export function buildLessonChips(lesson) {
|
||||
const groups = Array.isArray(lesson.groupNames) ? lesson.groupNames.join(', ') : '';
|
||||
const subgroups = Array.isArray(lesson.subgroupNames) && lesson.subgroupNames.length
|
||||
? lesson.subgroupNames.join(', ')
|
||||
: lesson.subgroupName;
|
||||
return [
|
||||
lesson.lessonTypeName,
|
||||
subgroups,
|
||||
lesson.lessonFormat,
|
||||
lesson.teacherName,
|
||||
lesson.classroomName,
|
||||
groups
|
||||
].filter(Boolean);
|
||||
}
|
||||
|
||||
export function buildWeekStatusMessage(lessons) {
|
||||
const count = Array.isArray(lessons) ? lessons.length : 0;
|
||||
return count > 0 ? `Найдено занятий: ${count}` : 'На этой неделе занятий нет';
|
||||
}
|
||||
|
||||
export function resolveSelectedGroup(groups, savedGroupId = '') {
|
||||
if (!Array.isArray(groups) || groups.length === 0) return null;
|
||||
return groups.find(group => String(group.id) === String(savedGroupId)) || groups[0];
|
||||
}
|
||||
|
||||
export function groupOptionDescription(group) {
|
||||
return [
|
||||
group.specialtyCode,
|
||||
group.specialtyProfileName,
|
||||
group.course ? `${group.course} курс` : ''
|
||||
].filter(Boolean).join(' · ');
|
||||
}
|
||||
|
||||
export async function fetchGroupOptions({ query = '', selectedId = '' } = {}, client = apiClient) {
|
||||
const params = new URLSearchParams({ query, limit: '20' });
|
||||
if (selectedId) params.set('selectedId', selectedId);
|
||||
return client.get(`/api/groups/options?${params}`, { errorMessage: 'Не удалось загрузить группы' });
|
||||
}
|
||||
|
||||
export async function fetchSchedule({ groupId, startDate, endDate }, client = apiClient) {
|
||||
return client.get(
|
||||
`/api/schedule?groupId=${encodeURIComponent(groupId)}&startDate=${startDate}&endDate=${endDate}`,
|
||||
{ errorMessage: 'Не удалось загрузить расписание' }
|
||||
);
|
||||
}
|
||||
23
frontend/react/student/testing/render-student.jsx
Normal file
23
frontend/react/student/testing/render-student.jsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
|
||||
import { AsyncCombobox } from '../../shared/ui/AsyncCombobox.jsx';
|
||||
import { ScheduleOverview } from '../../shared/ui/ScheduleOverview.jsx';
|
||||
import { WeekGrid } from '../WeekGrid.jsx';
|
||||
import { parseIsoDate, startOfWeek } from '../student-service.js';
|
||||
|
||||
export function renderWeekGrid(props) {
|
||||
return renderToStaticMarkup(createElement(WeekGrid, props));
|
||||
}
|
||||
|
||||
export function renderScheduleOverview(props) {
|
||||
return renderToStaticMarkup(createElement(ScheduleOverview, props));
|
||||
}
|
||||
|
||||
export function renderAsyncCombobox(props) {
|
||||
return renderToStaticMarkup(createElement(AsyncCombobox, props));
|
||||
}
|
||||
|
||||
export function weekStartOf(isoDate) {
|
||||
return startOfWeek(parseIsoDate(isoDate));
|
||||
}
|
||||
158
frontend/react/teacher/AbsencePanel.jsx
Normal file
158
frontend/react/teacher/AbsencePanel.jsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { formatIsoDate, toIsoDate } from '../shared/dates.js';
|
||||
import {
|
||||
absenceStatusClass,
|
||||
absenceStatusLabel,
|
||||
cancelAbsence,
|
||||
createAbsence,
|
||||
fetchAbsences,
|
||||
validateAbsence
|
||||
} from './teacher-service.js';
|
||||
|
||||
export function AbsencePanel() {
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [startDate, setStartDate] = useState(() => toIsoDate(new Date()));
|
||||
const [endDate, setEndDate] = useState(() => toIsoDate(new Date()));
|
||||
const [reason, setReason] = useState('');
|
||||
const [status, setStatus] = useState({ message: '', isError: false });
|
||||
const [items, setItems] = useState(null);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
(async () => {
|
||||
try {
|
||||
const loaded = await fetchAbsences();
|
||||
if (active) setItems(loaded);
|
||||
} catch {
|
||||
if (active) setError(true);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function reload() {
|
||||
try {
|
||||
setItems(await fetchAbsences());
|
||||
setError(false);
|
||||
} catch {
|
||||
setError(true);
|
||||
}
|
||||
}
|
||||
|
||||
async function submit(event) {
|
||||
event.preventDefault();
|
||||
const validation = validateAbsence({ endDate, reason, startDate });
|
||||
if (validation) {
|
||||
setStatus({ message: validation, isError: true });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await createAbsence({ endDate, reason: reason.trim(), startDate });
|
||||
setReason('');
|
||||
setStatus({ message: 'Заявка отправлена кафедре', isError: false });
|
||||
await reload();
|
||||
} catch {
|
||||
setStatus({ message: 'Не удалось отправить заявку об отсутствии', isError: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function cancel(id) {
|
||||
try {
|
||||
await cancelAbsence(id);
|
||||
setStatus({ message: 'Заявка отменена', isError: false });
|
||||
await reload();
|
||||
} catch {
|
||||
setStatus({ message: 'Не удалось отменить заявку', isError: true });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="teacher-absence-panel">
|
||||
<div className="teacher-absence-heading">
|
||||
<div>
|
||||
<span>Изменение планов</span>
|
||||
<h2>Сообщить об отсутствии</h2>
|
||||
<p>Заявка поступит кафедре на подтверждение. После согласования учебный отдел сможет подобрать замены.</p>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-md btn-secondary"
|
||||
type="button"
|
||||
aria-expanded={formOpen}
|
||||
onClick={() => setFormOpen(open => !open)}
|
||||
>
|
||||
{formOpen ? 'Скрыть форму' : 'Новая заявка'}
|
||||
</button>
|
||||
</div>
|
||||
<form className="teacher-absence-form" hidden={!formOpen} onSubmit={submit}>
|
||||
<label>
|
||||
<span>Начало</span>
|
||||
<input
|
||||
className="date-input"
|
||||
type="date"
|
||||
required
|
||||
value={startDate}
|
||||
onChange={event => setStartDate(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Окончание</span>
|
||||
<input
|
||||
className="date-input"
|
||||
type="date"
|
||||
required
|
||||
value={endDate}
|
||||
onChange={event => setEndDate(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="teacher-absence-reason">
|
||||
<span>Причина</span>
|
||||
<input
|
||||
type="text"
|
||||
maxLength={2000}
|
||||
placeholder="Например, командировка"
|
||||
required
|
||||
value={reason}
|
||||
onChange={event => setReason(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<button className="btn btn-md btn-primary" type="submit">Отправить кафедре</button>
|
||||
</form>
|
||||
<div className={`teacher-absence-status${status.isError ? ' error' : ''}${status.message && !status.isError ? ' success' : ''}`}>
|
||||
{status.message}
|
||||
</div>
|
||||
<div className="teacher-absence-list" aria-live="polite">
|
||||
{error ? (
|
||||
<div className="teacher-absence-empty error">Не удалось загрузить заявки об отсутствии</div>
|
||||
) : items === null ? (
|
||||
<div className="teacher-absence-empty">Загрузка заявок...</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="teacher-absence-empty">Заявок об отсутствии пока нет</div>
|
||||
) : items.map(item => (
|
||||
<article key={item.id} className="teacher-absence-item">
|
||||
<span className={`teacher-absence-marker ${absenceStatusClass(item.status)}`} />
|
||||
<div>
|
||||
<strong>{formatIsoDate(item.startDate)} — {formatIsoDate(item.endDate)}</strong>
|
||||
<p>{item.reason || 'Причина не указана'}</p>
|
||||
</div>
|
||||
<span className={`teacher-absence-badge ${absenceStatusClass(item.status)}`}>
|
||||
{absenceStatusLabel(item.status)}
|
||||
</span>
|
||||
{item.status === 'PENDING' && (
|
||||
<button
|
||||
className="btn btn-sm btn-danger-subtle"
|
||||
type="button"
|
||||
onClick={() => void cancel(item.id)}
|
||||
>
|
||||
Отменить
|
||||
</button>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
350
frontend/react/teacher/PreferencesTab.jsx
Normal file
350
frontend/react/teacher/PreferencesTab.jsx
Normal file
@@ -0,0 +1,350 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { formatIsoDate, toIsoDate } from '../shared/dates.js';
|
||||
import {
|
||||
activeDatePreferences,
|
||||
activeWeekPreferences,
|
||||
createPreference,
|
||||
deletePreference,
|
||||
fetchPreferenceMeta,
|
||||
fetchPreferences,
|
||||
findCompactPreference,
|
||||
findWeekPreference,
|
||||
preferenceStatusLabel,
|
||||
preferenceTypeClass,
|
||||
preferenceTypeLabel,
|
||||
preferenceTypeSymbol,
|
||||
resolveCurrentSemester,
|
||||
resolveSelectedSemester,
|
||||
semesterLabel
|
||||
} from './teacher-service.js';
|
||||
|
||||
const WEEK_DAY_LABELS = Object.freeze(['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс']);
|
||||
|
||||
export function PreferencesTab() {
|
||||
const [meta, setMeta] = useState({ semesters: [], timeSlots: [] });
|
||||
const [metaError, setMetaError] = useState(false);
|
||||
const [semesterId, setSemesterId] = useState('');
|
||||
const [preferences, setPreferences] = useState([]);
|
||||
const [brush, setBrush] = useState('SOFT_PREFERRED');
|
||||
const [message, setMessage] = useState({ text: '', isError: false });
|
||||
const [dateValue, setDateValue] = useState('');
|
||||
const [dateComment, setDateComment] = useState('');
|
||||
const [busyCell, setBusyCell] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
(async () => {
|
||||
try {
|
||||
const loaded = await fetchPreferenceMeta();
|
||||
if (!active) return;
|
||||
setMeta(loaded);
|
||||
const semesters = loaded.semesters || [];
|
||||
const current = resolveCurrentSemester(semesters, toIsoDate(new Date()));
|
||||
if (current) setSemesterId(String(current.id));
|
||||
} catch {
|
||||
if (active) setMetaError(true);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!semesterId) return undefined;
|
||||
let active = true;
|
||||
(async () => {
|
||||
try {
|
||||
const loaded = await fetchPreferences(semesterId);
|
||||
if (!active) return;
|
||||
setPreferences(loaded);
|
||||
setMessage({ text: '', isError: false });
|
||||
} catch {
|
||||
if (active) setMessage({ text: 'Не удалось загрузить пожелания', isError: true });
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [semesterId]);
|
||||
|
||||
const semester = resolveSelectedSemester(meta.semesters, semesterId);
|
||||
|
||||
useEffect(() => {
|
||||
const min = semester?.startDate || '';
|
||||
const max = semester?.endDate || '';
|
||||
if (!dateValue || dateValue < min || dateValue > max) {
|
||||
setDateValue(min);
|
||||
}
|
||||
}, [semester, dateValue]);
|
||||
|
||||
async function reloadPreferences() {
|
||||
try {
|
||||
setPreferences(await fetchPreferences(semesterId));
|
||||
} catch {
|
||||
setMessage({ text: 'Не удалось загрузить пожелания', isError: true });
|
||||
}
|
||||
}
|
||||
|
||||
function showMessage(text, isError) {
|
||||
setMessage({ text, isError });
|
||||
}
|
||||
|
||||
async function handleCell(dayOfWeek, timeSlotId) {
|
||||
const existing = findWeekPreference(preferences, dayOfWeek, timeSlotId);
|
||||
if (existing) {
|
||||
if (existing.status === 'APPROVED') {
|
||||
showMessage('Согласованную отметку может снять кафедра или учебный отдел', true);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await deletePreference(existing.id);
|
||||
showMessage('Ожидающее пожелание отменено', false);
|
||||
await reloadPreferences();
|
||||
} catch {
|
||||
showMessage('Не удалось отменить пожелание', true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const cellKey = `${dayOfWeek}:${timeSlotId}`;
|
||||
setBusyCell(cellKey);
|
||||
try {
|
||||
await createPreference({
|
||||
dayOfWeek,
|
||||
preferenceType: brush,
|
||||
semesterId: Number(semesterId),
|
||||
timeSlotId: Number(timeSlotId)
|
||||
});
|
||||
showMessage('Отметка отправлена на согласование', false);
|
||||
await reloadPreferences();
|
||||
} catch {
|
||||
showMessage('Не удалось сохранить пожелание', true);
|
||||
} finally {
|
||||
setBusyCell('');
|
||||
}
|
||||
}
|
||||
|
||||
async function submitDate(event) {
|
||||
event.preventDefault();
|
||||
if (!dateValue) return;
|
||||
try {
|
||||
await createPreference({
|
||||
comment: dateComment.trim() || null,
|
||||
preferenceDate: dateValue,
|
||||
preferenceType: 'HARD_UNAVAILABLE',
|
||||
semesterId: Number(semesterId)
|
||||
});
|
||||
setDateComment('');
|
||||
showMessage('Дата недоступности отправлена на согласование', false);
|
||||
await reloadPreferences();
|
||||
} catch {
|
||||
showMessage('Не удалось сохранить пожелание', true);
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelDatePreference(id) {
|
||||
try {
|
||||
await deletePreference(id);
|
||||
showMessage('Ожидающее пожелание отменено', false);
|
||||
await reloadPreferences();
|
||||
} catch {
|
||||
showMessage('Не удалось отменить пожелание', true);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleCompact(type, checked, existingId) {
|
||||
try {
|
||||
if (!checked && existingId) {
|
||||
await deletePreference(existingId);
|
||||
} else if (checked) {
|
||||
await createPreference({
|
||||
preferenceType: type,
|
||||
semesterId: Number(semesterId)
|
||||
});
|
||||
}
|
||||
await reloadPreferences();
|
||||
} catch {
|
||||
showMessage('Не удалось сохранить пожелание', true);
|
||||
await reloadPreferences();
|
||||
}
|
||||
}
|
||||
|
||||
const slots = meta.timeSlots || [];
|
||||
const weekCells = activeWeekPreferences(preferences);
|
||||
const dateItems = activeDatePreferences(preferences);
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="preference-hero">
|
||||
<div>
|
||||
<span className="preference-kicker">Контур доступности</span>
|
||||
<h2>Соберите ритм семестра до составления расписания</h2>
|
||||
<p>Строгая недоступность блокирует назначение. Мягкие отметки и пожелания к компактности помогают учебному отделу выбрать удобный вариант.</p>
|
||||
</div>
|
||||
<label className="preference-semester-field">
|
||||
<span>Семестр</span>
|
||||
<select value={semesterId} onChange={event => setSemesterId(event.target.value)}>
|
||||
{(meta.semesters || []).map(item => (
|
||||
<option key={item.id} value={item.id}>{semesterLabel(item)}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section className="preference-board">
|
||||
<header className="preference-board-head">
|
||||
<div>
|
||||
<span>Недельный шаблон</span>
|
||||
<h3>Отмечайте интервалы одним касанием</h3>
|
||||
</div>
|
||||
<label>
|
||||
<span>Режим отметки</span>
|
||||
<select value={brush} onChange={event => setBrush(event.target.value)}>
|
||||
<option value="SOFT_PREFERRED">Предпочтительно</option>
|
||||
<option value="SOFT_UNWANTED">Нежелательно</option>
|
||||
<option value="HARD_UNAVAILABLE">Строго недоступен</option>
|
||||
</select>
|
||||
</label>
|
||||
</header>
|
||||
<div className="preference-legend" aria-label="Легенда">
|
||||
<span className="is-preferred">Предпочтительно</span>
|
||||
<span className="is-unwanted">Нежелательно</span>
|
||||
<span className="is-hard">Строгий запрет</span>
|
||||
<span className="is-pending">Ожидает согласования</span>
|
||||
</div>
|
||||
<div className="preference-grid-wrap">
|
||||
<div className="preference-grid" aria-live="polite">
|
||||
{metaError ? (
|
||||
<div className="preference-empty">Не удалось загрузить параметры семестра</div>
|
||||
) : slots.length === 0 ? (
|
||||
<div className="preference-empty">Базовая сетка времени не настроена</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="preference-grid-corner">Пара</div>
|
||||
{WEEK_DAY_LABELS.map(day => (
|
||||
<div key={day} className="preference-grid-day">{day}</div>
|
||||
))}
|
||||
{slots.flatMap(slot => [
|
||||
<div key={`time-${slot.id}`} className="preference-grid-time">
|
||||
<strong>{slot.orderNumber}</strong>
|
||||
<span>{String(slot.startTime || '').slice(0, 5)}–{String(slot.endTime || '').slice(0, 5)}</span>
|
||||
</div>,
|
||||
...WEEK_DAY_LABELS.map((_, index) => {
|
||||
const dayOfWeek = index + 1;
|
||||
const item = findWeekPreference(weekCells, dayOfWeek, slot.id);
|
||||
const title = item
|
||||
? `${preferenceTypeLabel(item.preferenceType)} · ${preferenceStatusLabel(item.status)}`
|
||||
: 'Добавить отметку';
|
||||
return (
|
||||
<button
|
||||
key={`cell-${slot.id}-${dayOfWeek}`}
|
||||
type="button"
|
||||
className={`preference-cell ${preferenceTypeClass(item?.preferenceType)}${item?.status === 'PENDING' ? ' is-pending' : ''}`}
|
||||
disabled={busyCell === `${dayOfWeek}:${slot.id}`}
|
||||
title={title}
|
||||
aria-label={title}
|
||||
onClick={() => void handleCell(dayOfWeek, slot.id)}
|
||||
>
|
||||
<span>{item ? preferenceTypeSymbol(item.preferenceType) : '+'}</span>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
])}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`preference-message${message.isError ? ' error' : ''}${message.text && !message.isError ? ' success' : ''}`}
|
||||
role="status"
|
||||
>
|
||||
{message.text}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="preference-lower-grid">
|
||||
<section className="preference-card">
|
||||
<span className="preference-card-number">01</span>
|
||||
<h3>Полная недоступность</h3>
|
||||
<p>Добавьте отдельную дату внутри семестра — все пары этого дня будут считаться строгим ограничением.</p>
|
||||
<form className="preference-date-form" onSubmit={submitDate}>
|
||||
<input
|
||||
type="date"
|
||||
required
|
||||
min={semester?.startDate || ''}
|
||||
max={semester?.endDate || ''}
|
||||
value={dateValue}
|
||||
onChange={event => setDateValue(event.target.value)}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
maxLength={2000}
|
||||
placeholder="Комментарий (необязательно)"
|
||||
value={dateComment}
|
||||
onChange={event => setDateComment(event.target.value)}
|
||||
/>
|
||||
<button className="btn btn-md btn-primary" type="submit">Добавить дату</button>
|
||||
</form>
|
||||
<div className="preference-date-list">
|
||||
{dateItems.length === 0 ? (
|
||||
<div className="preference-empty">Даты полной недоступности не добавлены</div>
|
||||
) : dateItems.map(item => (
|
||||
<article key={item.id}>
|
||||
<span className={`preference-date-mark ${item.status === 'APPROVED' ? 'approved' : ''}`} />
|
||||
<div>
|
||||
<strong>{formatIsoDate(item.preferenceDate)}</strong>
|
||||
<small>{item.comment || 'Весь день'}</small>
|
||||
</div>
|
||||
<span className="preference-status">{preferenceStatusLabel(item.status)}</span>
|
||||
{item.status === 'PENDING' && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-danger-subtle"
|
||||
onClick={() => void cancelDatePreference(item.id)}
|
||||
>
|
||||
Отменить
|
||||
</button>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<section className="preference-card">
|
||||
<span className="preference-card-number">02</span>
|
||||
<h3>Компактность недели</h3>
|
||||
<p>Эти мягкие пожелания не отменяют занятия автоматически, но видны при составлении правил.</p>
|
||||
<CompactToggle
|
||||
type="CONSECUTIVE"
|
||||
title="Пары подряд"
|
||||
description="Собирать занятия последовательными блоками"
|
||||
preference={findCompactPreference(preferences, 'CONSECUTIVE')}
|
||||
onToggle={toggleCompact}
|
||||
/>
|
||||
<CompactToggle
|
||||
type="NO_GAPS"
|
||||
title="Без окон"
|
||||
description="Не оставлять пустые интервалы между занятиями"
|
||||
preference={findCompactPreference(preferences, 'NO_GAPS')}
|
||||
onToggle={toggleCompact}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CompactToggle({ type, title, description, preference, onToggle }) {
|
||||
const approved = preference?.status === 'APPROVED';
|
||||
return (
|
||||
<label className={`preference-toggle-card${approved ? ' approved' : ''}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(preference)}
|
||||
disabled={approved}
|
||||
onChange={event => void onToggle(type, event.target.checked, preference?.id)}
|
||||
/>
|
||||
<span><strong>{title}</strong><small>{description}</small></span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
177
frontend/react/teacher/RequestDialog.jsx
Normal file
177
frontend/react/teacher/RequestDialog.jsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { formatIsoDate, trimTime } from '../shared/dates.js';
|
||||
import {
|
||||
buildChangeRequestPayload,
|
||||
candidateOptions,
|
||||
fetchChangeRequestCandidates
|
||||
} from './teacher-service.js';
|
||||
|
||||
export function RequestDialog({ lesson, onClose, onSubmitted }) {
|
||||
const [candidates, setCandidates] = useState(null);
|
||||
const [candidatesError, setCandidatesError] = useState(false);
|
||||
const [requestType, setRequestType] = useState('MOVE');
|
||||
const [targetDate, setTargetDate] = useState('');
|
||||
const [timeSlotId, setTimeSlotId] = useState('');
|
||||
const [classroomId, setClassroomId] = useState('');
|
||||
const [reason, setReason] = useState('');
|
||||
const [message, setMessage] = useState({ text: 'Проверяем допустимые варианты...', isError: false });
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
document.body.classList.add('dialog-open');
|
||||
return () => {
|
||||
document.body.classList.remove('dialog-open');
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
(async () => {
|
||||
try {
|
||||
const loaded = await fetchChangeRequestCandidates({
|
||||
baseRuleSlotId: lesson.scheduleRuleSlotId,
|
||||
lessonDate: lesson.date
|
||||
});
|
||||
if (!active) return;
|
||||
setCandidates(loaded);
|
||||
setCandidatesError(false);
|
||||
setTargetDate(loaded.checkedDate || (loaded.availableDates || [])[0] || '');
|
||||
setMessage({ text: 'Доступны только варианты, прошедшие проверку конфликтов', isError: false });
|
||||
} catch {
|
||||
if (!active) return;
|
||||
setCandidatesError(true);
|
||||
setMessage({ text: 'Не удалось проверить допустимые варианты', isError: true });
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [lesson]);
|
||||
|
||||
async function reloadCandidates(nextDate) {
|
||||
if (!nextDate) return;
|
||||
setMessage({ text: 'Проверяем выбранную дату...', isError: false });
|
||||
try {
|
||||
const loaded = await fetchChangeRequestCandidates({
|
||||
baseRuleSlotId: lesson.scheduleRuleSlotId,
|
||||
lessonDate: lesson.date,
|
||||
targetDate: nextDate
|
||||
});
|
||||
setCandidates(loaded);
|
||||
setCandidatesError(false);
|
||||
setTimeSlotId('');
|
||||
setClassroomId('');
|
||||
setMessage({ text: 'Предварительная проверка завершена', isError: false });
|
||||
} catch {
|
||||
setMessage({ text: 'Не удалось проверить допустимые варианты', isError: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function submit(event) {
|
||||
event.preventDefault();
|
||||
if (!reason.trim()) {
|
||||
setMessage({ text: 'Укажите обоснование заявки', isError: true });
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onSubmitted(buildChangeRequestPayload({
|
||||
classroomId,
|
||||
lesson,
|
||||
reason,
|
||||
requestType,
|
||||
targetDate,
|
||||
timeSlotId
|
||||
}));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const timeOptions = candidateOptions(candidates?.timeSlots, 'Нет свободных временных слотов');
|
||||
const classroomOptions = candidateOptions(candidates?.classrooms, 'Нет свободных аудиторий');
|
||||
|
||||
return (
|
||||
<div className="request-dialog-overlay" onClick={event => {
|
||||
if (event.target === event.currentTarget) onClose();
|
||||
}}>
|
||||
<section className="request-dialog" role="dialog" aria-modal="true" aria-labelledby="request-dialog-title">
|
||||
<header>
|
||||
<div>
|
||||
<span>Предварительная проверка</span>
|
||||
<h2 id="request-dialog-title">Заявка на изменение</h2>
|
||||
<p>{lesson.subjectName} · {formatIsoDate(lesson.date)} · {trimTime(lesson.startTime)}–{trimTime(lesson.endTime)}</p>
|
||||
</div>
|
||||
<button className="btn btn-icon-md btn-secondary" type="button" aria-label="Закрыть" onClick={onClose}>×</button>
|
||||
</header>
|
||||
<form onSubmit={submit}>
|
||||
<label>
|
||||
<span>Что изменить</span>
|
||||
<select value={requestType} onChange={event => setRequestType(event.target.value)}>
|
||||
<option value="MOVE">Перенести дату или время</option>
|
||||
<option value="CHANGE_CLASSROOM">Сменить аудиторию</option>
|
||||
<option value="CANCEL">Отменить занятие</option>
|
||||
</select>
|
||||
</label>
|
||||
{requestType === 'MOVE' && (
|
||||
<label>
|
||||
<span>Новая дата</span>
|
||||
<select
|
||||
value={targetDate}
|
||||
onChange={event => {
|
||||
setTargetDate(event.target.value);
|
||||
void reloadCandidates(event.target.value);
|
||||
}}
|
||||
>
|
||||
{(candidates?.availableDates || []).map(date => (
|
||||
<option key={date} value={date}>{formatIsoDate(date)}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
{requestType === 'MOVE' && (
|
||||
<label>
|
||||
<span>Новая пара</span>
|
||||
<select value={timeSlotId} onChange={event => setTimeSlotId(event.target.value)}>
|
||||
{timeOptions.map(option => (
|
||||
<option key={option.id} value={option.id}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
{requestType === 'CHANGE_CLASSROOM' && (
|
||||
<label>
|
||||
<span>Новая аудитория</span>
|
||||
<select value={classroomId} onChange={event => setClassroomId(event.target.value)}>
|
||||
{classroomOptions.map(option => (
|
||||
<option key={option.id} value={option.id}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<label className="request-reason-field">
|
||||
<span>Обоснование</span>
|
||||
<textarea
|
||||
maxLength={2000}
|
||||
rows={4}
|
||||
placeholder="Почему требуется изменение"
|
||||
required
|
||||
value={reason}
|
||||
onChange={event => setReason(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<div className={`request-dialog-message${message.isError ? ' error' : ''}`} role="alert">
|
||||
{message.text}
|
||||
</div>
|
||||
<footer>
|
||||
<button className="btn btn-md btn-secondary" type="button" onClick={onClose}>Закрыть</button>
|
||||
<button className="btn btn-md btn-primary" type="submit" disabled={submitting || candidatesError}>
|
||||
Отправить в учебный отдел
|
||||
</button>
|
||||
</footer>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
80
frontend/react/teacher/RequestsTab.jsx
Normal file
80
frontend/react/teacher/RequestsTab.jsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
changeRequestTarget,
|
||||
changeTypeLabel,
|
||||
formatInstant,
|
||||
preferenceStatusLabel
|
||||
} from './teacher-service.js';
|
||||
import { formatIsoDate, trimTime } from '../shared/dates.js';
|
||||
|
||||
export function RequestsTab({ items, onCancel, onRefresh, stateKind }) {
|
||||
return (
|
||||
<>
|
||||
<section className="request-ledger-head">
|
||||
<div>
|
||||
<span>Журнал решений</span>
|
||||
<h2>Заявки на изменение занятий</h2>
|
||||
<p>Откройте занятие в расписании, чтобы запросить перенос, другую аудиторию или отмену. Здесь сохраняются статус, комментарий и вся история решения.</p>
|
||||
</div>
|
||||
<button className="btn btn-md btn-secondary" type="button" onClick={onRefresh}>Обновить</button>
|
||||
</section>
|
||||
<div className="request-ledger" aria-live="polite">
|
||||
{stateKind === 'error' ? (
|
||||
<div className="request-empty error">Не удалось загрузить заявки</div>
|
||||
) : stateKind === 'loading' ? (
|
||||
<div className="request-empty">Загрузка заявок...</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="request-empty">Заявок на изменение пока нет. Откройте занятие во вкладке «Расписание».</div>
|
||||
) : items.map(item => (
|
||||
<RequestLedgerItem key={item.id} item={item} onCancel={onCancel} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function RequestLedgerItem({ item, onCancel }) {
|
||||
const history = item.history || [];
|
||||
const statusClass = String(item.status || '').toLowerCase();
|
||||
|
||||
return (
|
||||
<article className="request-ledger-item">
|
||||
<div className={`request-ledger-stripe ${statusClass}`} />
|
||||
<div className="request-ledger-main">
|
||||
<span>{formatIsoDate(item.lessonDate)} · {trimTime(item.sourceStartTime)}–{trimTime(item.sourceEndTime)}</span>
|
||||
<h3>{item.subjectName}</h3>
|
||||
<p>{(item.groupNames || []).join(', ')} · {item.sourceClassroomName}</p>
|
||||
<div className="request-change-line">
|
||||
<b>{changeTypeLabel(item.requestType)}</b>
|
||||
<i>→</i>
|
||||
<strong>{changeRequestTarget(item)}</strong>
|
||||
</div>
|
||||
<p className="request-reason">{item.reason}</p>
|
||||
{item.reviewComment && <p className="request-review-comment">Решение: {item.reviewComment}</p>}
|
||||
<details>
|
||||
<summary>История решения · {history.length}</summary>
|
||||
<ol>
|
||||
{history.map((entry, index) => (
|
||||
<li key={index}>
|
||||
<time>{formatInstant(entry.createdAt)}</time>
|
||||
<strong>{preferenceStatusLabel(entry.status)}</strong>
|
||||
<span>{entry.comment || 'Без комментария'}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</details>
|
||||
</div>
|
||||
<div className="request-ledger-side">
|
||||
<span className={`request-status ${statusClass}`}>{preferenceStatusLabel(item.status)}</span>
|
||||
{item.status === 'PENDING' && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-danger-subtle"
|
||||
onClick={() => void onCancel(item.id)}
|
||||
>
|
||||
Отозвать
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
363
frontend/react/teacher/TeacherApp.jsx
Normal file
363
frontend/react/teacher/TeacherApp.jsx
Normal file
@@ -0,0 +1,363 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { readUrlState, writeUrlState } from '../../admin/js/url-state.js';
|
||||
import { trackUxEvent } from '../../telemetry.js';
|
||||
import { buildScheduleOverviewModel } from '../../schedule-overview.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 { ScheduleOverview } from '../shared/ui/ScheduleOverview.jsx';
|
||||
import {
|
||||
addDays,
|
||||
formatWeekLabel,
|
||||
parseIsoDate,
|
||||
startOfWeek,
|
||||
toIsoDate,
|
||||
weekEndIso
|
||||
} from '../shared/dates.js';
|
||||
import { AbsencePanel } from './AbsencePanel.jsx';
|
||||
import { PreferencesTab } from './PreferencesTab.jsx';
|
||||
import { RequestDialog } from './RequestDialog.jsx';
|
||||
import { RequestsTab } from './RequestsTab.jsx';
|
||||
import { TeacherWeekGrid } from './TeacherWeekGrid.jsx';
|
||||
import {
|
||||
cancelChangeRequest,
|
||||
createChangeRequest,
|
||||
fetchChangeRequests,
|
||||
fetchTeacherSchedule,
|
||||
pendingCount,
|
||||
WORKSPACE_TABS
|
||||
} from './teacher-service.js';
|
||||
|
||||
const THEME_STORAGE_KEY = 'theme';
|
||||
const OVERVIEW_DAYS = 14;
|
||||
|
||||
const TAB_TITLES = Object.freeze({
|
||||
preferences: 'Мои пожелания',
|
||||
requests: 'Заявки на изменение',
|
||||
schedule: 'Расписание'
|
||||
});
|
||||
|
||||
export function TeacherApp() {
|
||||
const sessionState = useAuthorizedSession(['TEACHER']);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.dataset.theme = localStorage.getItem(THEME_STORAGE_KEY) || 'light';
|
||||
}, []);
|
||||
|
||||
if (sessionState.phase === SESSION_PHASES.loading) {
|
||||
return (
|
||||
<main className="shell">
|
||||
<AsyncState kind="loading" title="Кабинет преподавателя" message="Проверяем сессию…" />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (sessionState.phase !== SESSION_PHASES.ready) {
|
||||
return (
|
||||
<main className="shell">
|
||||
<AsyncState kind="error" title="Раздел недоступен" message={sessionState.message} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return <TeacherWorkspace teacherId={sessionState.session.userId} />;
|
||||
}
|
||||
|
||||
function TeacherWorkspace({ teacherId }) {
|
||||
const [initialUrlState] = useState(() => readUrlState({ teacherTab: 'schedule', teacherDate: '' }));
|
||||
const [activeTab, setActiveTab] = useState(() => (
|
||||
WORKSPACE_TABS.includes(initialUrlState.teacherTab) ? initialUrlState.teacherTab : 'schedule'
|
||||
));
|
||||
const [weekStart, setWeekStart] = useState(() => startOfWeek(
|
||||
initialUrlState.teacherDate ? parseIsoDate(initialUrlState.teacherDate) : new Date()
|
||||
));
|
||||
const [scheduleState, setScheduleState] = useState({ kind: 'loading' });
|
||||
const [overviewState, setOverviewState] = useState({ kind: 'loading' });
|
||||
const [requestsState, setRequestsState] = useState({ kind: 'loading', items: [] });
|
||||
const [dialogLesson, setDialogLesson] = useState(null);
|
||||
|
||||
const weekStartIso = toIsoDate(weekStart);
|
||||
|
||||
useEffect(() => {
|
||||
writeUrlState({ teacherDate: weekStartIso });
|
||||
}, [weekStartIso]);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
(async () => {
|
||||
setRequestsState({ kind: 'loading', items: [] });
|
||||
try {
|
||||
const items = await fetchChangeRequests();
|
||||
if (!active) return;
|
||||
setRequestsState({ kind: 'ready', items });
|
||||
} catch {
|
||||
if (!active) return;
|
||||
setRequestsState({ kind: 'error', items: [] });
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!teacherId) {
|
||||
setScheduleState({ kind: 'error', message: 'Не удалось определить преподавателя' });
|
||||
return undefined;
|
||||
}
|
||||
let active = true;
|
||||
(async () => {
|
||||
setScheduleState({ kind: 'loading' });
|
||||
try {
|
||||
const lessons = await fetchTeacherSchedule({
|
||||
endDate: weekEndIso(weekStart),
|
||||
startDate: weekStartIso,
|
||||
teacherId
|
||||
});
|
||||
if (!active) return;
|
||||
setScheduleState({ kind: 'ready', lessons });
|
||||
trackUxEvent('teacher_schedule_loaded', { lessons: lessons.length, startDate: weekStartIso });
|
||||
} catch {
|
||||
if (!active) return;
|
||||
setScheduleState({ kind: 'error' });
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [teacherId, weekStart, weekStartIso]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!teacherId) {
|
||||
setOverviewState({ kind: 'error', message: 'Не удалось определить преподавателя' });
|
||||
return undefined;
|
||||
}
|
||||
let active = true;
|
||||
(async () => {
|
||||
setOverviewState({ kind: 'loading' });
|
||||
const now = new Date();
|
||||
try {
|
||||
const lessons = await fetchTeacherSchedule({
|
||||
endDate: toIsoDate(addDays(now, OVERVIEW_DAYS)),
|
||||
startDate: toIsoDate(now),
|
||||
teacherId
|
||||
});
|
||||
if (!active) return;
|
||||
setOverviewState({ kind: 'ready', lessons, now });
|
||||
const overview = buildScheduleOverviewModel(lessons, now);
|
||||
trackUxEvent('teacher_schedule_overview_loaded', {
|
||||
todayCount: overview.todayLessons.length,
|
||||
hasNext: Boolean(overview.nextLesson)
|
||||
});
|
||||
} catch {
|
||||
if (!active) return;
|
||||
setOverviewState({ kind: 'error', message: 'Не удалось загрузить ближайшие занятия' });
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [teacherId]);
|
||||
|
||||
function switchTab(name) {
|
||||
setActiveTab(name);
|
||||
writeUrlState({ teacherTab: name });
|
||||
trackUxEvent('teacher_workspace_opened', { section: name });
|
||||
}
|
||||
|
||||
function moveWeek(delta) {
|
||||
setWeekStart(current => addDays(current, delta * 7));
|
||||
}
|
||||
|
||||
function goToCurrentWeek() {
|
||||
setWeekStart(startOfWeek(new Date()));
|
||||
}
|
||||
|
||||
function handleDateChange(event) {
|
||||
if (event.target.value) {
|
||||
setWeekStart(startOfWeek(parseIsoDate(event.target.value)));
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
async function refreshRequests() {
|
||||
try {
|
||||
const items = await fetchChangeRequests();
|
||||
setRequestsState({ kind: 'ready', items });
|
||||
} catch {
|
||||
setRequestsState({ kind: 'error', items: [] });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCancelRequest(id) {
|
||||
try {
|
||||
await cancelChangeRequest(id);
|
||||
await refreshRequests();
|
||||
} catch {
|
||||
setRequestsState({ kind: 'error', items: [] });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmitRequest(payload) {
|
||||
try {
|
||||
await createChangeRequest(payload);
|
||||
setDialogLesson(null);
|
||||
await refreshRequests();
|
||||
switchTab('requests');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="shell">
|
||||
<header className="topbar">
|
||||
<div className="title">
|
||||
<h1>Кабинет преподавателя</h1>
|
||||
<span>{formatWeekLabel(weekStart)}</span>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button
|
||||
className="btn btn-icon-md btn-secondary"
|
||||
type="button"
|
||||
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>
|
||||
<a className="btn btn-danger-subtle" href="/" onClick={handleLogout}>Выйти</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav className="workspace-nav" aria-label="Разделы кабинета">
|
||||
{WORKSPACE_TABS.map(tab => (
|
||||
<button
|
||||
key={tab}
|
||||
className={`workspace-tab${activeTab === tab ? ' active' : ''}`}
|
||||
type="button"
|
||||
onClick={() => switchTab(tab)}
|
||||
>
|
||||
{TAB_TITLES[tab]}
|
||||
{tab === 'requests' && (
|
||||
<span>{pendingCount(requestsState.items)}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<section className={`workspace-view${activeTab === 'schedule' ? ' active' : ''}`} hidden={activeTab !== 'schedule'}>
|
||||
<section className="toolbar">
|
||||
<div className="week-nav">
|
||||
<button className="btn btn-icon-md btn-secondary" type="button" aria-label="Предыдущая неделя" onClick={() => moveWeek(-1)}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<polyline points="15 18 9 12 15 6" />
|
||||
</svg>
|
||||
</button>
|
||||
<button className="btn btn-md btn-primary" type="button" onClick={goToCurrentWeek}>Сегодня</button>
|
||||
<button className="btn btn-icon-md btn-secondary" type="button" aria-label="Следующая неделя" onClick={() => moveWeek(1)}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
className="date-input"
|
||||
type="date"
|
||||
aria-label="Дата недели"
|
||||
value={weekStartIso}
|
||||
onChange={handleDateChange}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{overviewState.kind === 'ready' ? (
|
||||
<ScheduleOverview
|
||||
lessons={overviewState.lessons}
|
||||
now={overviewState.now}
|
||||
onGoToday={goToCurrentWeek}
|
||||
role="teacher"
|
||||
/>
|
||||
) : (
|
||||
<section className="schedule-overview" aria-live="polite">
|
||||
{overviewState.kind === 'error' ? (
|
||||
<AsyncState
|
||||
kind="error"
|
||||
title="Быстрый обзор"
|
||||
message={overviewState.message || 'Не удалось загрузить ближайшие занятия'}
|
||||
/>
|
||||
) : (
|
||||
<AsyncState kind="loading" title="Быстрый обзор" message="Загружаем ближайшие занятия…" />
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div className={`status${scheduleState.kind === 'error' ? ' error' : ''}`}>
|
||||
{scheduleStatus(scheduleState)}
|
||||
</div>
|
||||
|
||||
<AbsencePanel />
|
||||
|
||||
{scheduleState.kind === 'ready' ? (
|
||||
<TeacherWeekGrid
|
||||
lessons={scheduleState.lessons}
|
||||
onRequestLesson={setDialogLesson}
|
||||
weekStart={weekStart}
|
||||
/>
|
||||
) : (
|
||||
<TeacherWeekGrid
|
||||
emptyMessage={scheduleState.kind === 'error' ? 'Ошибка загрузки' : 'Загрузка...'}
|
||||
lessons={[]}
|
||||
onRequestLesson={setDialogLesson}
|
||||
weekStart={weekStart}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className={`workspace-view${activeTab === 'preferences' ? ' active' : ''}`} hidden={activeTab !== 'preferences'}>
|
||||
<PreferencesTab />
|
||||
</section>
|
||||
|
||||
<section className={`workspace-view${activeTab === 'requests' ? ' active' : ''}`} hidden={activeTab !== 'requests'}>
|
||||
<RequestsTab
|
||||
items={requestsState.items}
|
||||
onCancel={handleCancelRequest}
|
||||
onRefresh={() => void refreshRequests()}
|
||||
stateKind={requestsState.kind}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{dialogLesson && (
|
||||
<RequestDialog
|
||||
lesson={dialogLesson}
|
||||
onClose={() => setDialogLesson(null)}
|
||||
onSubmitted={handleSubmitRequest}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function scheduleStatus(scheduleState) {
|
||||
if (scheduleState.kind === 'error') {
|
||||
return scheduleState.message || 'Не удалось загрузить расписание';
|
||||
}
|
||||
if (scheduleState.kind === 'ready') {
|
||||
const count = scheduleState.lessons.length;
|
||||
return count > 0 ? `Найдено занятий: ${count}` : 'На этой неделе занятий нет';
|
||||
}
|
||||
return 'Загрузка расписания...';
|
||||
}
|
||||
156
frontend/react/teacher/TeacherWeekGrid.jsx
Normal file
156
frontend/react/teacher/TeacherWeekGrid.jsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { dayName, formatDate, toIsoDate, weekDates } from '../shared/dates.js';
|
||||
import {
|
||||
buildDesktopWeekModel,
|
||||
buildTeacherLessonChips,
|
||||
canRequestChange,
|
||||
lessonsByDate
|
||||
} from './teacher-service.js';
|
||||
|
||||
const MOBILE_BREAKPOINT = '(max-width: 980px)';
|
||||
|
||||
export function TeacherWeekGrid({ emptyMessage = 'Загрузка...', lessons = [], onRequestLesson, weekStart }) {
|
||||
const mobile = useMobileLayout();
|
||||
|
||||
if (mobile) {
|
||||
return <MobileWeek emptyMessage={emptyMessage} lessons={lessons} onRequestLesson={onRequestLesson} weekStart={weekStart} />;
|
||||
}
|
||||
return <DesktopWeek emptyMessage={emptyMessage} lessons={lessons} onRequestLesson={onRequestLesson} weekStart={weekStart} />;
|
||||
}
|
||||
|
||||
function DesktopWeek({ emptyMessage, lessons, onRequestLesson, weekStart }) {
|
||||
const { days, slots, lessonsByKey } = buildDesktopWeekModel(weekStart, lessons);
|
||||
const hasLessons = lessons.length > 0;
|
||||
|
||||
return (
|
||||
<section className="schedule" aria-live="polite">
|
||||
<div className="corner" />
|
||||
{days.map(day => <DayHead key={day.isoDate} date={day.date} />)}
|
||||
{hasLessons ? (
|
||||
slots.map(slot => (
|
||||
<SlotRow
|
||||
key={slot.order}
|
||||
days={days}
|
||||
lessonsByKey={lessonsByKey}
|
||||
onRequestLesson={onRequestLesson}
|
||||
slot={slot}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
<div className="time-cell">Неделя</div>
|
||||
{days.map(day => (
|
||||
<div key={day.isoDate} className="day-cell">
|
||||
<div className="empty">{emptyMessage}</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function SlotRow({ days, lessonsByKey, onRequestLesson, slot }) {
|
||||
return (
|
||||
<>
|
||||
<div className="time-cell">{slot.label}</div>
|
||||
{days.map(day => {
|
||||
const lesson = lessonsByKey.get(`${day.isoDate}:${slot.order}`);
|
||||
return (
|
||||
<div key={`${day.isoDate}:${slot.order}`} className="day-cell">
|
||||
{lesson
|
||||
? <LessonCard lesson={lesson} onRequestLesson={onRequestLesson} />
|
||||
: <div className="empty">Нет занятия</div>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileWeek({ emptyMessage, lessons, onRequestLesson, weekStart }) {
|
||||
const byDate = lessonsByDate(lessons);
|
||||
|
||||
return (
|
||||
<section className="schedule" aria-live="polite">
|
||||
{weekDates(weekStart).map(date => {
|
||||
const isoDate = toIsoDate(date);
|
||||
const dayLessons = byDate.get(isoDate) || [];
|
||||
return (
|
||||
<article key={isoDate} className="mobile-day">
|
||||
<DayHead date={date} />
|
||||
<div className="mobile-list">
|
||||
{dayLessons.length > 0
|
||||
? dayLessons.map((lesson, index) => (
|
||||
<LessonCard
|
||||
key={`${lesson.date}-${trimKey(lesson)}-${index}`}
|
||||
lesson={lesson}
|
||||
onRequestLesson={onRequestLesson}
|
||||
/>
|
||||
))
|
||||
: <div className="empty">{emptyMessage}</div>}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function DayHead({ date }) {
|
||||
return (
|
||||
<div className="day-head">
|
||||
<strong>{dayName(date)}</strong>
|
||||
<span>{formatDate(date)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LessonCard({ lesson, onRequestLesson }) {
|
||||
return (
|
||||
<div className="lesson">
|
||||
<div className="lesson-title">{lesson.subjectName}</div>
|
||||
<div className="lesson-meta">
|
||||
{buildTeacherLessonChips(lesson).map((chip, index) => (
|
||||
<span key={index} className="chip">{chip}</span>
|
||||
))}
|
||||
</div>
|
||||
{canRequestChange(lesson) ? (
|
||||
<button
|
||||
className="btn btn-sm btn-secondary lesson-request-button"
|
||||
type="button"
|
||||
onClick={() => onRequestLesson(lesson)}
|
||||
>
|
||||
Запросить изменение
|
||||
</button>
|
||||
) : (
|
||||
<span className="lesson-change-note">Занятие уже изменено учебным отделом</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function trimKey(lesson) {
|
||||
return String(lesson.startTime || '').slice(0, 5);
|
||||
}
|
||||
|
||||
function useMobileLayout() {
|
||||
const [mobile, setMobile] = useState(() => matchesMobile());
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined' || !window.matchMedia) return undefined;
|
||||
const query = window.matchMedia(MOBILE_BREAKPOINT);
|
||||
const update = () => setMobile(query.matches);
|
||||
update();
|
||||
query.addEventListener('change', update);
|
||||
return () => query.removeEventListener('change', update);
|
||||
}, []);
|
||||
|
||||
return mobile;
|
||||
}
|
||||
|
||||
function matchesMobile() {
|
||||
return typeof window !== 'undefined' && Boolean(window.matchMedia)
|
||||
&& window.matchMedia(MOBILE_BREAKPOINT).matches;
|
||||
}
|
||||
22
frontend/react/teacher/main.jsx
Normal file
22
frontend/react/teacher/main.jsx
Normal 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 { TeacherApp } from './TeacherApp.jsx';
|
||||
|
||||
void initializeTelemetry();
|
||||
|
||||
const rootElement = document.getElementById('teacher-root');
|
||||
|
||||
if (!rootElement) {
|
||||
throw new Error('Не найден корневой элемент кабинета преподавателя');
|
||||
}
|
||||
|
||||
createRoot(rootElement).render(
|
||||
<StrictMode>
|
||||
<AppErrorBoundary>
|
||||
<TeacherApp />
|
||||
</AppErrorBoundary>
|
||||
</StrictMode>
|
||||
);
|
||||
292
frontend/react/teacher/teacher-service.js
Normal file
292
frontend/react/teacher/teacher-service.js
Normal file
@@ -0,0 +1,292 @@
|
||||
import { apiClient } from '../shared/api-client.js';
|
||||
import {
|
||||
formatDate,
|
||||
formatIsoDate,
|
||||
toIsoDate,
|
||||
trimTime,
|
||||
weekDates
|
||||
} from '../shared/dates.js';
|
||||
|
||||
export const WORKSPACE_TABS = Object.freeze(['schedule', 'preferences', 'requests']);
|
||||
export const PREFERENCE_BRUSHES = Object.freeze(['SOFT_PREFERRED', 'SOFT_UNWANTED', 'HARD_UNAVAILABLE']);
|
||||
export const CHANGE_REQUEST_TYPES = Object.freeze(['MOVE', 'CHANGE_CLASSROOM', 'CANCEL']);
|
||||
|
||||
export function lessonKey(lesson) {
|
||||
return `${lesson.scheduleRuleSlotId}:${lesson.date}`;
|
||||
}
|
||||
|
||||
export function collectSlots(lessons) {
|
||||
const slots = new Map();
|
||||
(lessons || []).forEach(lesson => {
|
||||
slots.set(lesson.timeSlotOrder, {
|
||||
order: lesson.timeSlotOrder,
|
||||
label: `${trimTime(lesson.startTime)} - ${trimTime(lesson.endTime)}`
|
||||
});
|
||||
});
|
||||
if (!slots.size) {
|
||||
slots.set(1, { order: 1, label: 'Расписание' });
|
||||
}
|
||||
return [...slots.values()].sort((left, right) => left.order - right.order);
|
||||
}
|
||||
|
||||
export function buildDesktopWeekModel(weekStart, lessons) {
|
||||
const slots = collectSlots(lessons);
|
||||
const lessonsByKey = new Map();
|
||||
(lessons || []).forEach(lesson => {
|
||||
lessonsByKey.set(`${lesson.date}:${lesson.timeSlotOrder}`, lesson);
|
||||
});
|
||||
const days = weekDates(weekStart).map(date => ({
|
||||
date,
|
||||
isoDate: toIsoDate(date),
|
||||
label: formatDate(date)
|
||||
}));
|
||||
return { days, slots, lessonsByKey };
|
||||
}
|
||||
|
||||
export function lessonsByDate(lessons) {
|
||||
const byDate = new Map();
|
||||
(lessons || []).forEach(lesson => {
|
||||
if (!byDate.has(lesson.date)) byDate.set(lesson.date, []);
|
||||
byDate.get(lesson.date).push(lesson);
|
||||
});
|
||||
return byDate;
|
||||
}
|
||||
|
||||
export function buildTeacherLessonChips(lesson) {
|
||||
const groups = Array.isArray(lesson.groupNames) ? lesson.groupNames.join(', ') : '';
|
||||
const subgroups = Array.isArray(lesson.subgroupNames) && lesson.subgroupNames.length
|
||||
? lesson.subgroupNames.join(', ')
|
||||
: lesson.subgroupName;
|
||||
return [
|
||||
`${trimTime(lesson.startTime)} - ${trimTime(lesson.endTime)}`,
|
||||
lesson.lessonTypeName,
|
||||
subgroups,
|
||||
lesson.classroomName,
|
||||
groups
|
||||
].filter(Boolean);
|
||||
}
|
||||
|
||||
export function canRequestChange(lesson) {
|
||||
return Boolean(lesson.scheduleRuleSlotId && !lesson.scheduleOverrideId);
|
||||
}
|
||||
|
||||
export function absenceStatusLabel(status) {
|
||||
return {
|
||||
PENDING: 'На подтверждении',
|
||||
APPROVED: 'Согласовано',
|
||||
REJECTED: 'Отклонено',
|
||||
RESOLVED: 'Замены выполнены',
|
||||
CANCELLED: 'Отменено'
|
||||
}[status] || status || '—';
|
||||
}
|
||||
|
||||
export function absenceStatusClass(status) {
|
||||
return `is-${String(status || '').toLowerCase()}`;
|
||||
}
|
||||
|
||||
export function preferenceTypeLabel(type) {
|
||||
return {
|
||||
HARD_UNAVAILABLE: 'Строго недоступен',
|
||||
SOFT_PREFERRED: 'Предпочтительно',
|
||||
SOFT_UNWANTED: 'Нежелательно',
|
||||
CONSECUTIVE: 'Пары подряд',
|
||||
NO_GAPS: 'Без окон'
|
||||
}[type] || type || '—';
|
||||
}
|
||||
|
||||
export function preferenceTypeClass(type) {
|
||||
return {
|
||||
HARD_UNAVAILABLE: 'is-hard',
|
||||
SOFT_PREFERRED: 'is-preferred',
|
||||
SOFT_UNWANTED: 'is-unwanted'
|
||||
}[type] || '';
|
||||
}
|
||||
|
||||
export function preferenceTypeSymbol(type) {
|
||||
return { HARD_UNAVAILABLE: '×', SOFT_PREFERRED: '✓', SOFT_UNWANTED: '!' }[type] || '•';
|
||||
}
|
||||
|
||||
export function preferenceStatusLabel(status) {
|
||||
return {
|
||||
PENDING: 'На рассмотрении',
|
||||
APPROVED: 'Согласовано',
|
||||
REJECTED: 'Отклонено',
|
||||
CANCELLED: 'Отменено'
|
||||
}[status] || status || '—';
|
||||
}
|
||||
|
||||
export function changeTypeLabel(type) {
|
||||
return {
|
||||
MOVE: 'Перенос',
|
||||
CHANGE_CLASSROOM: 'Смена аудитории',
|
||||
CANCEL: 'Отмена'
|
||||
}[type] || type || '—';
|
||||
}
|
||||
|
||||
export function changeRequestTarget(item) {
|
||||
if (item.requestType === 'MOVE') {
|
||||
return `${formatIsoDate(item.targetLessonDate || item.lessonDate)}, ${trimTime(item.requestedStartTime)}–${trimTime(item.requestedEndTime)}`;
|
||||
}
|
||||
if (item.requestType === 'CHANGE_CLASSROOM') {
|
||||
return item.requestedClassroomName;
|
||||
}
|
||||
return 'Отмена занятия';
|
||||
}
|
||||
|
||||
export function semesterLabel(semester) {
|
||||
const type = semester.semesterType === 'AUTUMN'
|
||||
? 'осенний'
|
||||
: semester.semesterType === 'SPRING'
|
||||
? 'весенний'
|
||||
: semester.semesterType;
|
||||
return `${semester.academicYearTitle} · ${type} · ${formatIsoDate(semester.startDate)}–${formatIsoDate(semester.endDate)}`;
|
||||
}
|
||||
|
||||
export function resolveCurrentSemester(semesters, todayIso) {
|
||||
return (semesters || []).find(semester => semester.startDate <= todayIso && semester.endDate >= todayIso) || null;
|
||||
}
|
||||
|
||||
export function resolveSelectedSemester(semesters, semesterId) {
|
||||
return (semesters || []).find(semester => String(semester.id) === String(semesterId));
|
||||
}
|
||||
|
||||
export function activeWeekPreferences(preferences) {
|
||||
return (preferences || []).filter(item => ['PENDING', 'APPROVED'].includes(item.status) && item.dayOfWeek);
|
||||
}
|
||||
|
||||
export function activeDatePreferences(preferences) {
|
||||
return (preferences || []).filter(item => item.preferenceDate && item.status !== 'CANCELLED');
|
||||
}
|
||||
|
||||
export function findCompactPreference(preferences, type) {
|
||||
return (preferences || []).find(
|
||||
candidate => candidate.preferenceType === type && ['PENDING', 'APPROVED'].includes(candidate.status)
|
||||
) || null;
|
||||
}
|
||||
|
||||
export function findWeekPreference(preferences, dayOfWeek, timeSlotId) {
|
||||
return (preferences || []).find(
|
||||
candidate => ['PENDING', 'APPROVED'].includes(candidate.status)
|
||||
&& candidate.dayOfWeek === dayOfWeek
|
||||
&& candidate.timeSlotId === timeSlotId
|
||||
) || null;
|
||||
}
|
||||
|
||||
export function pendingCount(items) {
|
||||
return (items || []).filter(item => item.status === 'PENDING').length;
|
||||
}
|
||||
|
||||
export function formatInstant(value) {
|
||||
if (!value) return '—';
|
||||
return new Date(value).toLocaleString('ru-RU', { dateStyle: 'short', timeStyle: 'short' });
|
||||
}
|
||||
|
||||
export function validateAbsence({ startDate = '', endDate = '', reason = '' } = {}) {
|
||||
if (!startDate || !endDate || !reason.trim()) {
|
||||
return 'Заполните период и причину отсутствия';
|
||||
}
|
||||
if (endDate < startDate) {
|
||||
return 'Дата окончания не может быть раньше даты начала';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export function buildChangeRequestPayload({ lesson, requestType, targetDate = '', timeSlotId = '', classroomId = '', reason = '' }) {
|
||||
return {
|
||||
baseRuleSlotId: lesson.scheduleRuleSlotId,
|
||||
lessonDate: lesson.date,
|
||||
requestType,
|
||||
targetLessonDate: requestType === 'MOVE' ? targetDate : null,
|
||||
requestedTimeSlotId: requestType === 'MOVE' ? Number(timeSlotId) || null : null,
|
||||
requestedClassroomId: requestType === 'CHANGE_CLASSROOM' ? Number(classroomId) || null : null,
|
||||
reason: reason.trim()
|
||||
};
|
||||
}
|
||||
|
||||
export function candidateOptions(items, emptyLabel) {
|
||||
const available = (items || []).filter(item => item.available);
|
||||
return available.length > 0 ? available : [{ id: '', label: emptyLabel }];
|
||||
}
|
||||
|
||||
export async function fetchTeacherSchedule({ teacherId, startDate, endDate }, client = apiClient) {
|
||||
return client.get(
|
||||
`/api/schedule?teacherId=${encodeURIComponent(teacherId)}&startDate=${startDate}&endDate=${endDate}`,
|
||||
{ errorMessage: 'Не удалось загрузить расписание' }
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchPreferenceMeta(client = apiClient) {
|
||||
return client.get('/api/teacher-preferences/meta', { errorMessage: 'Не удалось загрузить параметры семестра' });
|
||||
}
|
||||
|
||||
export async function fetchPreferences(semesterId, client = apiClient) {
|
||||
return client.get(
|
||||
`/api/teacher-preferences?semesterId=${encodeURIComponent(semesterId)}`,
|
||||
{ errorMessage: 'Не удалось загрузить пожелания' }
|
||||
);
|
||||
}
|
||||
|
||||
export async function createPreference(payload, client = apiClient) {
|
||||
return client.post(
|
||||
'/api/teacher-preferences',
|
||||
payload,
|
||||
{ errorMessage: 'Не удалось сохранить пожелание' }
|
||||
);
|
||||
}
|
||||
|
||||
export async function deletePreference(id, client = apiClient) {
|
||||
return client.delete(
|
||||
`/api/teacher-preferences/${encodeURIComponent(id)}`,
|
||||
{ errorMessage: 'Не удалось отменить пожелание' }
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchChangeRequests(client = apiClient) {
|
||||
return client.get('/api/teacher-change-requests', { errorMessage: 'Не удалось загрузить заявки' });
|
||||
}
|
||||
|
||||
export async function createChangeRequest(payload, client = apiClient) {
|
||||
return client.post(
|
||||
'/api/teacher-change-requests',
|
||||
payload,
|
||||
{ errorMessage: 'Не удалось отправить заявку' }
|
||||
);
|
||||
}
|
||||
|
||||
export async function cancelChangeRequest(id, client = apiClient) {
|
||||
return client.delete(
|
||||
`/api/teacher-change-requests/${encodeURIComponent(id)}`,
|
||||
{ errorMessage: 'Не удалось отозвать заявку' }
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchChangeRequestCandidates({ baseRuleSlotId, lessonDate, targetDate = '' }, client = apiClient) {
|
||||
const params = new URLSearchParams({
|
||||
baseRuleSlotId: String(baseRuleSlotId),
|
||||
lessonDate
|
||||
});
|
||||
if (targetDate) params.set('targetDate', targetDate);
|
||||
return client.get(
|
||||
`/api/teacher-change-requests/candidates?${params}`,
|
||||
{ errorMessage: 'Не удалось проверить допустимые варианты' }
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchAbsences(client = apiClient) {
|
||||
return client.get('/api/teacher-absences', { errorMessage: 'Не удалось загрузить заявки об отсутствии' });
|
||||
}
|
||||
|
||||
export async function createAbsence({ startDate, endDate, reason }, client = apiClient) {
|
||||
return client.post(
|
||||
'/api/teacher-absences',
|
||||
{ startDate, endDate, reason },
|
||||
{ errorMessage: 'Не удалось отправить заявку об отсутствии' }
|
||||
);
|
||||
}
|
||||
|
||||
export async function cancelAbsence(id, client = apiClient) {
|
||||
return client.delete(
|
||||
`/api/teacher-absences/${encodeURIComponent(id)}`,
|
||||
{ errorMessage: 'Не удалось отменить заявку' }
|
||||
);
|
||||
}
|
||||
38
frontend/react/teacher/testing/render-teacher.jsx
Normal file
38
frontend/react/teacher/testing/render-teacher.jsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
|
||||
import { ScheduleOverview } from '../../shared/ui/ScheduleOverview.jsx';
|
||||
import { AbsencePanel } from '../AbsencePanel.jsx';
|
||||
import { PreferencesTab } from '../PreferencesTab.jsx';
|
||||
import { RequestDialog } from '../RequestDialog.jsx';
|
||||
import { RequestsTab } from '../RequestsTab.jsx';
|
||||
import { TeacherWeekGrid } from '../TeacherWeekGrid.jsx';
|
||||
import { parseIsoDate, startOfWeek } from '../../shared/dates.js';
|
||||
|
||||
export function renderTeacherWeekGrid(props) {
|
||||
return renderToStaticMarkup(createElement(TeacherWeekGrid, props));
|
||||
}
|
||||
|
||||
export function renderScheduleOverview(props) {
|
||||
return renderToStaticMarkup(createElement(ScheduleOverview, props));
|
||||
}
|
||||
|
||||
export function renderAbsencePanel() {
|
||||
return renderToStaticMarkup(createElement(AbsencePanel));
|
||||
}
|
||||
|
||||
export function renderPreferencesTab() {
|
||||
return renderToStaticMarkup(createElement(PreferencesTab));
|
||||
}
|
||||
|
||||
export function renderRequestsTab(props) {
|
||||
return renderToStaticMarkup(createElement(RequestsTab, props));
|
||||
}
|
||||
|
||||
export function renderRequestDialog(props) {
|
||||
return renderToStaticMarkup(createElement(RequestDialog, props));
|
||||
}
|
||||
|
||||
export function weekStartOf(isoDate) {
|
||||
return startOfWeek(parseIsoDate(isoDate));
|
||||
}
|
||||
Reference in New Issue
Block a user