баги
This commit is contained in:
@@ -1,14 +1,14 @@
|
||||
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 { useSidebar } from '../shared/use-sidebar.js';
|
||||
import { SidebarToggle } from '../shared/ui/SidebarToggle.jsx';
|
||||
import { REACT_TABS } from './react-tabs.js';
|
||||
import {
|
||||
ADMIN_ROUTES,
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
} from './admin-service.js';
|
||||
|
||||
const THEME_STORAGE_KEY = 'theme';
|
||||
const SIDEBAR_STORAGE_KEY = 'sidebar-collapsed';
|
||||
|
||||
const ICONS = Object.freeze({
|
||||
dashboard: (
|
||||
@@ -166,7 +165,7 @@ function AdminFrame({ children }) {
|
||||
return (
|
||||
<main className="main">
|
||||
<header className="topbar">
|
||||
<div className="topbar-title-group">
|
||||
<div className="topbar-title-group">
|
||||
<h1>Админ-панель</h1>
|
||||
</div>
|
||||
</header>
|
||||
@@ -185,22 +184,11 @@ export function AdminWorkspace({ 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 { collapsed, mobileOpen, toggleSidebar, closeSidebar } = useSidebar();
|
||||
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';
|
||||
}, []);
|
||||
@@ -255,25 +243,22 @@ export function AdminWorkspace({ role }) {
|
||||
}, [role]);
|
||||
|
||||
async function requestTabSwitch(tab) {
|
||||
if (tab === activeTabRef.current || !allowedTabs.has(tab)) return;
|
||||
if (tab === activeTabRef.current) {
|
||||
closeSidebar();
|
||||
return;
|
||||
}
|
||||
if (!allowedTabs.has(tab)) return;
|
||||
if (activeTabRef.current && !(await confirmAbandonDirtyChanges())) return;
|
||||
|
||||
activeTabRef.current = tab;
|
||||
setActiveTab(tab);
|
||||
closeSidebar();
|
||||
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;
|
||||
@@ -291,7 +276,7 @@ export function AdminWorkspace({ role }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<aside className={`sidebar${collapsed ? ' collapsed' : ''}`}>
|
||||
<aside id="app-sidebar" className={`sidebar${collapsed && !mobileOpen ? ' collapsed' : ''}${mobileOpen ? ' open' : ''}`}>
|
||||
<div className="sidebar-header">
|
||||
<div className="logo">
|
||||
<svg width="32" height="32" viewBox="0 0 40 40" fill="none">
|
||||
@@ -332,6 +317,7 @@ export function AdminWorkspace({ role }) {
|
||||
<a
|
||||
key={item.id}
|
||||
href={`#${item.id}`}
|
||||
title={collapsed ? item.label : undefined}
|
||||
className={`nav-item${item.id === activeTab ? ' active' : ''}`}
|
||||
data-tab={item.id}
|
||||
onClick={event => {
|
||||
@@ -406,10 +392,11 @@ export function AdminWorkspace({ role }) {
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="sidebar-overlay" />
|
||||
<div className={`sidebar-overlay${mobileOpen ? ' open' : ''}`} onClick={closeSidebar} />
|
||||
|
||||
<main className={`main${collapsed ? ' sidebar-collapsed' : ''}`}>
|
||||
<header className="topbar">
|
||||
<SidebarToggle open={mobileOpen} onClick={toggleSidebar} />
|
||||
<div className="topbar-title-group">
|
||||
<h1 id="page-title">{title}</h1>
|
||||
{badge && !badge.hidden && (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { CustomSelect } from '../../shared/ui/CustomSelect.jsx';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { confirmAbandonDirtyChanges, createDirtyScope } from '../../../admin/js/dirty-state.js';
|
||||
@@ -237,17 +238,17 @@ function CalendarDayDialog({
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="calendar-day-dialog-activity">Код активности</label>
|
||||
<select id="calendar-day-dialog-activity" value={common} onChange={onActivityChange}>
|
||||
<CustomSelect id="calendar-day-dialog-activity" value={common} onChange={onActivityChange}>
|
||||
<option value="">Выберите</option>
|
||||
{activityTypes.map(type => (
|
||||
<option key={type.id} value={type.id}>{`${type.code} - ${type.name}`}</option>
|
||||
))}
|
||||
</select>
|
||||
</CustomSelect>
|
||||
</div>
|
||||
<div className="calendar-day-dialog-time-grid">
|
||||
<div className="form-group">
|
||||
<label htmlFor="calendar-day-dialog-time-scope">Временная сетка даты</label>
|
||||
<select
|
||||
<CustomSelect
|
||||
id="calendar-day-dialog-time-scope"
|
||||
value={scopeValue}
|
||||
disabled={!isSingle}
|
||||
@@ -263,7 +264,7 @@ function CalendarDayDialog({
|
||||
) : (
|
||||
<option value="">Для выделения доступен только код дня</option>
|
||||
)}
|
||||
</select>
|
||||
</CustomSelect>
|
||||
</div>
|
||||
<div className="calendar-day-dialog-actions">
|
||||
<button
|
||||
@@ -1447,7 +1448,7 @@ export function AcademicCalendarTab() {
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label htmlFor="academic-calendar-year">Учебный год</label>
|
||||
<select
|
||||
<CustomSelect
|
||||
id="academic-calendar-year"
|
||||
required
|
||||
value={calendarForm.yearId}
|
||||
@@ -1466,11 +1467,11 @@ export function AcademicCalendarTab() {
|
||||
) : (
|
||||
<option value="">Загрузка...</option>
|
||||
)}
|
||||
</select>
|
||||
</CustomSelect>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="academic-calendar-specialty">Специальность</label>
|
||||
<select
|
||||
<CustomSelect
|
||||
id="academic-calendar-specialty"
|
||||
required
|
||||
value={calendarForm.specialtyId}
|
||||
@@ -1492,11 +1493,11 @@ export function AcademicCalendarTab() {
|
||||
) : (
|
||||
<option value="">Загрузка...</option>
|
||||
)}
|
||||
</select>
|
||||
</CustomSelect>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="academic-calendar-profile">Профиль</label>
|
||||
<select
|
||||
<CustomSelect
|
||||
id="academic-calendar-profile"
|
||||
required
|
||||
value={calendarForm.profileId}
|
||||
@@ -1515,11 +1516,11 @@ export function AcademicCalendarTab() {
|
||||
) : (
|
||||
<option value="">{profileEmptyLabel}</option>
|
||||
)}
|
||||
</select>
|
||||
</CustomSelect>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="academic-calendar-study-form">Форма обучения</label>
|
||||
<select
|
||||
<CustomSelect
|
||||
id="academic-calendar-study-form"
|
||||
required
|
||||
value={calendarForm.studyFormId}
|
||||
@@ -1538,7 +1539,7 @@ export function AcademicCalendarTab() {
|
||||
) : (
|
||||
<option value="">Загрузка...</option>
|
||||
)}
|
||||
</select>
|
||||
</CustomSelect>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="academic-calendar-course-count">Курсов</label>
|
||||
@@ -1590,7 +1591,7 @@ export function AcademicCalendarTab() {
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="academic-calendars-year-filter">Учебный год</label>
|
||||
<select
|
||||
<CustomSelect
|
||||
id="academic-calendars-year-filter"
|
||||
value={calendarYearFilter}
|
||||
onChange={event => {
|
||||
@@ -1604,7 +1605,7 @@ export function AcademicCalendarTab() {
|
||||
{academicYears.map(year => (
|
||||
<option key={year.id} value={year.id}>{year.title}</option>
|
||||
))}
|
||||
</select>
|
||||
</CustomSelect>
|
||||
</div>
|
||||
</div>
|
||||
<div className="table-wrap">
|
||||
@@ -1899,7 +1900,7 @@ export function AcademicCalendarTab() {
|
||||
<div className="calendar-fill-panel">
|
||||
<div className="form-group">
|
||||
<label htmlFor="calendar-fill-course">Курс</label>
|
||||
<select
|
||||
<CustomSelect
|
||||
id="calendar-fill-course"
|
||||
value={fillForm.course}
|
||||
onChange={event => setFillForm(current => ({
|
||||
@@ -1911,7 +1912,7 @@ export function AcademicCalendarTab() {
|
||||
{fillCourseOptions.map(course => (
|
||||
<option key={course} value={course}>{course} курс</option>
|
||||
))}
|
||||
</select>
|
||||
</CustomSelect>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="calendar-fill-start">С даты</label>
|
||||
@@ -1931,7 +1932,7 @@ export function AcademicCalendarTab() {
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="calendar-fill-activity">Код</label>
|
||||
<select
|
||||
<CustomSelect
|
||||
id="calendar-fill-activity"
|
||||
value={fillForm.activityId}
|
||||
onChange={event => setFillForm(current => ({
|
||||
@@ -1951,7 +1952,7 @@ export function AcademicCalendarTab() {
|
||||
) : (
|
||||
<option value="">Загрузка...</option>
|
||||
)}
|
||||
</select>
|
||||
</CustomSelect>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -2053,7 +2054,7 @@ export function AcademicCalendarTab() {
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="calendar-subject-semester">Семестр</label>
|
||||
<select
|
||||
<CustomSelect
|
||||
id="calendar-subject-semester"
|
||||
value={subjectSemester}
|
||||
onChange={event => setSubjectSemester(event.target.value)}
|
||||
@@ -2070,11 +2071,11 @@ export function AcademicCalendarTab() {
|
||||
) : (
|
||||
<option value="">Выберите график</option>
|
||||
)}
|
||||
</select>
|
||||
</CustomSelect>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="calendar-subject-id">Дисциплина</label>
|
||||
<select
|
||||
<CustomSelect
|
||||
id="calendar-subject-id"
|
||||
value={sortedSubjects.some(item => String(item.id) === subjectIdValue)
|
||||
? subjectIdValue
|
||||
@@ -2097,7 +2098,7 @@ export function AcademicCalendarTab() {
|
||||
) : (
|
||||
<option value="">Загрузка...</option>
|
||||
)}
|
||||
</select>
|
||||
</CustomSelect>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { CustomSelect } from '../../shared/ui/CustomSelect.jsx';
|
||||
|
||||
import { toIsoDate } from '../../shared/dates.js';
|
||||
import { FormAlert } from '../../shared/ui/FormAlert.jsx';
|
||||
@@ -406,7 +407,7 @@ export function AuditoriumWorkloadTab() {
|
||||
<div className="filter-row workload-filter-row">
|
||||
<div className="form-group workload-target-control">
|
||||
<label htmlFor="workload-target-select">Что смотреть</label>
|
||||
<select
|
||||
<CustomSelect
|
||||
id="workload-target-select"
|
||||
className="workload-display-select"
|
||||
value={currentTarget}
|
||||
@@ -415,12 +416,12 @@ export function AuditoriumWorkloadTab() {
|
||||
<option value={TARGET_CLASSROOMS}>Аудитории</option>
|
||||
<option value={TARGET_TEACHERS}>Преподаватели</option>
|
||||
<option value={TARGET_DEPARTMENTS}>Кафедры</option>
|
||||
</select>
|
||||
</CustomSelect>
|
||||
</div>
|
||||
|
||||
<div className="form-group workload-display-control">
|
||||
<label htmlFor="workload-display-select">Отображение</label>
|
||||
<select
|
||||
<CustomSelect
|
||||
id="workload-display-select"
|
||||
className="workload-display-select"
|
||||
value={displayValue}
|
||||
@@ -429,7 +430,7 @@ export function AuditoriumWorkloadTab() {
|
||||
{displayOptions.map(option => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</CustomSelect>
|
||||
</div>
|
||||
|
||||
{showOverviewFilters && (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { CustomSelect } from '../../shared/ui/CustomSelect.jsx';
|
||||
|
||||
import { getSession } from '../../../admin/js/api.js';
|
||||
import { addDays, toIsoDate } from '../../shared/dates.js';
|
||||
@@ -356,7 +357,7 @@ export function DepartmentWorkspaceTab() {
|
||||
{!isDepartmentRole && (
|
||||
<div className="form-group" id="department-workspace-department-field">
|
||||
<label htmlFor="department-workspace-department">Кафедра</label>
|
||||
<select
|
||||
<CustomSelect
|
||||
id="department-workspace-department"
|
||||
value={departmentId}
|
||||
onChange={event => handleDepartmentChange(event.target.value)}
|
||||
@@ -367,7 +368,7 @@ export function DepartmentWorkspaceTab() {
|
||||
{departmentOptionLabel(department)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</CustomSelect>
|
||||
</div>
|
||||
)}
|
||||
<div className="form-group">
|
||||
@@ -471,7 +472,7 @@ export function DepartmentWorkspaceTab() {
|
||||
<form id="department-teacher-assign-form" className="stack-form" onSubmit={submitAssign}>
|
||||
<div className="form-group">
|
||||
<label htmlFor="department-teacher-select">Преподаватель</label>
|
||||
<select
|
||||
<CustomSelect
|
||||
id="department-teacher-select"
|
||||
value={assignTeacherId}
|
||||
onChange={event => setAssignTeacherId(event.target.value)}
|
||||
@@ -492,7 +493,7 @@ export function DepartmentWorkspaceTab() {
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</select>
|
||||
</CustomSelect>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="department-teacher-assign-comment">Комментарий</label>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { CustomSelect } from '../../shared/ui/CustomSelect.jsx';
|
||||
|
||||
import { readUrlState, readUrlValues, writeUrlState } from '../../../admin/js/url-state.js';
|
||||
import { trackUxEvent } from '../../../telemetry.js';
|
||||
@@ -134,6 +135,8 @@ export function GroupsTab() {
|
||||
}, []);
|
||||
|
||||
async function loadGroups(nextState) {
|
||||
stateRef.current = nextState;
|
||||
setState(nextState);
|
||||
const currentRequest = requestNumber.current + 1;
|
||||
requestNumber.current = currentRequest;
|
||||
setTableLoading(true);
|
||||
@@ -174,6 +177,7 @@ export function GroupsTab() {
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
window.clearTimeout(searchTimer.current);
|
||||
setSearchInput('');
|
||||
void loadGroups({
|
||||
...stateRef.current,
|
||||
@@ -546,7 +550,7 @@ export function GroupsTab() {
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="new-group-ef">Форма обучения</label>
|
||||
<select
|
||||
<CustomSelect
|
||||
id="new-group-ef"
|
||||
value={createForm.educationFormId}
|
||||
onChange={event => updateCreateForm({ educationFormId: event.target.value })}
|
||||
@@ -554,11 +558,11 @@ export function GroupsTab() {
|
||||
{(educationForms || []).map(form => (
|
||||
<option key={form.id} value={form.id}>{form.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</CustomSelect>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="new-group-department">Кафедра</label>
|
||||
<select
|
||||
<CustomSelect
|
||||
id="new-group-department"
|
||||
value={createForm.departmentId}
|
||||
onChange={event => updateCreateForm({ departmentId: event.target.value })}
|
||||
@@ -569,7 +573,7 @@ export function GroupsTab() {
|
||||
{departmentOptionLabel(department)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</CustomSelect>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="new-group-yearStartStudy">Год начала обучения</label>
|
||||
@@ -586,7 +590,7 @@ export function GroupsTab() {
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="new-group-speciality-code">Специальность</label>
|
||||
<select
|
||||
<CustomSelect
|
||||
id="new-group-speciality-code"
|
||||
value={createForm.specialtyId}
|
||||
onChange={event => updateCreateForm({ specialtyId: event.target.value, specialtyProfileId: '' })}
|
||||
@@ -597,11 +601,11 @@ export function GroupsTab() {
|
||||
{specialityLabel(specialties, speciality.id)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</CustomSelect>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="new-group-profile">Профиль обучения</label>
|
||||
<select
|
||||
<CustomSelect
|
||||
id="new-group-profile"
|
||||
value={createForm.specialtyProfileId}
|
||||
onChange={event => updateCreateForm({ specialtyProfileId: event.target.value })}
|
||||
@@ -616,7 +620,7 @@ export function GroupsTab() {
|
||||
) : (
|
||||
<option value="">Нет профилей</option>
|
||||
)}
|
||||
</select>
|
||||
</CustomSelect>
|
||||
</div>
|
||||
<button type="submit" className="btn btn-md btn-primary">Создать</button>
|
||||
</div>
|
||||
@@ -654,7 +658,7 @@ export function GroupsTab() {
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="groups-sort">Сортировка</label>
|
||||
<select
|
||||
<CustomSelect
|
||||
id="groups-sort"
|
||||
value={state.sort}
|
||||
onChange={event => applyState({ sort: event.target.value, page: 0 })}
|
||||
@@ -662,7 +666,7 @@ export function GroupsTab() {
|
||||
{GROUP_SORT_OPTIONS.map(option => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</CustomSelect>
|
||||
</div>
|
||||
<button type="button" className="btn btn-md btn-ghost" onClick={resetFilters}>Сбросить фильтры</button>
|
||||
</div>
|
||||
@@ -819,7 +823,7 @@ export function GroupsTab() {
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="edit-group-ef">Форма обучения</label>
|
||||
<select
|
||||
<CustomSelect
|
||||
id="edit-group-ef"
|
||||
value={editModal.form.educationFormId}
|
||||
onChange={event => setEditModal(current => (
|
||||
@@ -829,13 +833,13 @@ export function GroupsTab() {
|
||||
{(educationForms || []).map(form => (
|
||||
<option key={form.id} value={form.id}>{form.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</CustomSelect>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row edit-group-form-row edit-group-form-row-details">
|
||||
<div className="form-group">
|
||||
<label htmlFor="edit-group-department">Кафедра</label>
|
||||
<select
|
||||
<CustomSelect
|
||||
id="edit-group-department"
|
||||
value={editModal.form.departmentId}
|
||||
onChange={event => setEditModal(current => (
|
||||
@@ -848,7 +852,7 @@ export function GroupsTab() {
|
||||
{departmentOptionLabel(department)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</CustomSelect>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="edit-group-yearStartStudy">Год начала обучения</label>
|
||||
@@ -866,7 +870,7 @@ export function GroupsTab() {
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="edit-group-speciality-code">Специальность</label>
|
||||
<select
|
||||
<CustomSelect
|
||||
id="edit-group-speciality-code"
|
||||
value={editModal.form.specialtyId}
|
||||
onChange={event => setEditModal(current => (
|
||||
@@ -881,11 +885,11 @@ export function GroupsTab() {
|
||||
{specialityLabel(specialties, speciality.id)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</CustomSelect>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="edit-group-profile">Профиль обучения</label>
|
||||
<select
|
||||
<CustomSelect
|
||||
id="edit-group-profile"
|
||||
value={editModal.form.specialtyProfileId}
|
||||
onChange={event => setEditModal(current => (
|
||||
@@ -902,7 +906,7 @@ export function GroupsTab() {
|
||||
) : (
|
||||
<option value="">Нет профилей</option>
|
||||
)}
|
||||
</select>
|
||||
</CustomSelect>
|
||||
</div>
|
||||
<div className="form-group edit-group-submit">
|
||||
<button type="submit" className="btn btn-md btn-primary">Сохранить</button>
|
||||
@@ -949,7 +953,7 @@ export function GroupsTab() {
|
||||
<div className="subgroup-config-grid">
|
||||
<div className="form-group subgroup-count-field">
|
||||
<label htmlFor="manage-subgroup-count">Количество подгрупп</label>
|
||||
<select
|
||||
<CustomSelect
|
||||
id="manage-subgroup-count"
|
||||
value={subgroupsModal.count}
|
||||
onChange={event => changeSubgroupCount(event.target.value)}
|
||||
@@ -957,7 +961,7 @@ export function GroupsTab() {
|
||||
{SUBGROUP_COUNT_OPTIONS.map(option => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</CustomSelect>
|
||||
</div>
|
||||
<div className={`subgroup-capacity-fields subgroup-capacity-fields-${subgroupsModal.count}`}>
|
||||
{subgroupsModal.capacities.map((value, index) => (
|
||||
@@ -1074,7 +1078,7 @@ export function GroupsTab() {
|
||||
<div className="form-row form-row--bottom">
|
||||
<div className="form-group form-group--flush">
|
||||
<label htmlFor="manage-calendar-year">Учебный год</label>
|
||||
<select
|
||||
<CustomSelect
|
||||
id="manage-calendar-year"
|
||||
required
|
||||
value={calendarModal.yearId}
|
||||
@@ -1084,7 +1088,7 @@ export function GroupsTab() {
|
||||
{(academicYears || []).map(year => (
|
||||
<option key={year.id} value={year.id}>{year.title}</option>
|
||||
))}
|
||||
</select>
|
||||
</CustomSelect>
|
||||
</div>
|
||||
<div className="form-group form-group--flush form-group--wide">
|
||||
<label htmlFor="manage-calendar-id">Календарный график</label>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { CustomSelect } from '../../shared/ui/CustomSelect.jsx';
|
||||
|
||||
import {
|
||||
academicYearsFromSemesters,
|
||||
@@ -238,17 +239,17 @@ export function ScheduleQualityTab() {
|
||||
</div>
|
||||
<div className="quality-run-panel">
|
||||
<label htmlFor="quality-year">Учебный год</label>
|
||||
<select id="quality-year" value={academicYearId} onChange={handleYearChange}>
|
||||
<CustomSelect id="quality-year" value={academicYearId} onChange={handleYearChange}>
|
||||
{yearOptions}
|
||||
</select>
|
||||
</CustomSelect>
|
||||
<label htmlFor="quality-semester">Семестр</label>
|
||||
<select id="quality-semester" value={semesterId} onChange={handleSemesterChange}>
|
||||
<CustomSelect id="quality-semester" value={semesterId} onChange={handleSemesterChange}>
|
||||
{semesterOptions}
|
||||
</select>
|
||||
</CustomSelect>
|
||||
<label htmlFor="quality-version">Версия</label>
|
||||
<select id="quality-version" value={versionId} onChange={event => setVersionId(event.target.value)}>
|
||||
<CustomSelect id="quality-version" value={versionId} onChange={event => setVersionId(event.target.value)}>
|
||||
{versionOptions}
|
||||
</select>
|
||||
</CustomSelect>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-md btn-primary"
|
||||
@@ -335,7 +336,7 @@ export function ScheduleQualityTab() {
|
||||
<div className="quality-filters">
|
||||
<label>
|
||||
Важность
|
||||
<select
|
||||
<CustomSelect
|
||||
id="quality-severity-filter"
|
||||
value={severityFilter}
|
||||
onChange={event => setSeverityFilter(event.target.value)}
|
||||
@@ -345,11 +346,11 @@ export function ScheduleQualityTab() {
|
||||
<option value="HIGH">Высокие</option>
|
||||
<option value="MEDIUM">Средние</option>
|
||||
<option value="LOW">Низкие</option>
|
||||
</select>
|
||||
</CustomSelect>
|
||||
</label>
|
||||
<label>
|
||||
Категория
|
||||
<select
|
||||
<CustomSelect
|
||||
id="quality-type-filter"
|
||||
value={categoryFilter}
|
||||
onChange={event => setCategoryFilter(event.target.value)}
|
||||
@@ -359,7 +360,7 @@ export function ScheduleQualityTab() {
|
||||
<option value="LOAD">Дневная нагрузка</option>
|
||||
<option value="ROOM">Аудитории</option>
|
||||
<option value="PREFERENCE">Пожелания</option>
|
||||
</select>
|
||||
</CustomSelect>
|
||||
</label>
|
||||
</div>
|
||||
<div className="quality-problems" id="quality-problems">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { CustomSelect } from '../../shared/ui/CustomSelect.jsx';
|
||||
|
||||
import {
|
||||
academicYearsFromSemesters,
|
||||
@@ -1368,7 +1369,7 @@ export function ScheduleTab() {
|
||||
<div className="version-constructor-controls">
|
||||
<div className="version-constructor-field">
|
||||
<label htmlFor="schedule-version-year">Учебный год</label>
|
||||
<select id="schedule-version-year" value={versionYearId} onChange={handleVersionYearChange}>
|
||||
<CustomSelect id="schedule-version-year" value={versionYearId} onChange={handleVersionYearChange}>
|
||||
{initialPhase === 'loading' ? (
|
||||
<option value="">Загрузка годов…</option>
|
||||
) : years.length ? (
|
||||
@@ -1376,11 +1377,11 @@ export function ScheduleTab() {
|
||||
) : (
|
||||
<option value="">Учебные годы не настроены</option>
|
||||
)}
|
||||
</select>
|
||||
</CustomSelect>
|
||||
</div>
|
||||
<div className="version-constructor-field">
|
||||
<label htmlFor="schedule-version-semester">Семестр</label>
|
||||
<select id="schedule-version-semester" value={versionSemesterId} onChange={handleVersionSemesterChange}>
|
||||
<CustomSelect id="schedule-version-semester" value={versionSemesterId} onChange={handleVersionSemesterChange}>
|
||||
{initialPhase === 'loading' ? (
|
||||
<option value="">Сначала выберите год</option>
|
||||
) : yearSemesters.length ? (
|
||||
@@ -1390,11 +1391,11 @@ export function ScheduleTab() {
|
||||
) : (
|
||||
<option value="">Семестры не настроены</option>
|
||||
)}
|
||||
</select>
|
||||
</CustomSelect>
|
||||
</div>
|
||||
<div className="version-constructor-field version-constructor-field-wide">
|
||||
<label htmlFor="schedule-version-select">Версия расписания</label>
|
||||
<select id="schedule-version-select" value={versionId} onChange={handleVersionChange}>
|
||||
<CustomSelect id="schedule-version-select" value={versionId} onChange={handleVersionChange}>
|
||||
{initialPhase === 'loading' || !versionSemesterId ? (
|
||||
<option value="">Сначала выберите семестр</option>
|
||||
) : scheduleVersions.length ? (
|
||||
@@ -1410,7 +1411,7 @@ export function ScheduleTab() {
|
||||
) : (
|
||||
<option value="">Расписания не созданы</option>
|
||||
)}
|
||||
</select>
|
||||
</CustomSelect>
|
||||
</div>
|
||||
<div className="version-constructor-actions">
|
||||
<button type="button" className="btn btn-sm btn-primary" id="schedule-version-create-draft" onClick={openDraftModal} disabled={!selectedVersion}>Создать черновик</button>
|
||||
@@ -1466,7 +1467,7 @@ export function ScheduleTab() {
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label htmlFor="schedule-rule-subject">Дисциплина</label>
|
||||
<select id="schedule-rule-subject" required disabled={editorDisabled} value={ruleSubjectId} onChange={handleSubjectChange}>
|
||||
<CustomSelect id="schedule-rule-subject" required disabled={editorDisabled} value={ruleSubjectId} onChange={handleSubjectChange}>
|
||||
{!subjects.length ? (
|
||||
<option value="">Загрузка...</option>
|
||||
) : (
|
||||
@@ -1475,11 +1476,11 @@ export function ScheduleTab() {
|
||||
{subjects.map(subject => (
|
||||
<option key={subject.id} value={subject.id}>{subject.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</CustomSelect>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="schedule-rule-semester">Семестр</label>
|
||||
<select id="schedule-rule-semester" required disabled value={ruleSemesterId} onChange={() => {}}>
|
||||
<CustomSelect id="schedule-rule-semester" required disabled value={ruleSemesterId} onChange={() => {}}>
|
||||
{!semesters.length ? (
|
||||
<option value="">Загрузка...</option>
|
||||
) : (
|
||||
@@ -1488,7 +1489,7 @@ export function ScheduleTab() {
|
||||
{semesters.map(semester => (
|
||||
<option key={semester.id} value={semester.id}>{semesterLabel(semester)}</option>
|
||||
))}
|
||||
</select>
|
||||
</CustomSelect>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1723,7 +1724,7 @@ export function ScheduleTab() {
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label htmlFor="schedule-move-slot-day">День</label>
|
||||
<select
|
||||
<CustomSelect
|
||||
id="schedule-move-slot-day"
|
||||
required
|
||||
value={moveSlot.dayOfWeek}
|
||||
@@ -1733,11 +1734,11 @@ export function ScheduleTab() {
|
||||
>
|
||||
<option value="">Выберите</option>
|
||||
{DAY_OPTIONS.map(day => <option key={day.value} value={day.value}>{day.label}</option>)}
|
||||
</select>
|
||||
</CustomSelect>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="schedule-move-slot-time">Пара</label>
|
||||
<select
|
||||
<CustomSelect
|
||||
id="schedule-move-slot-time"
|
||||
required
|
||||
value={moveSlot.timeSlotId}
|
||||
@@ -1749,7 +1750,7 @@ export function ScheduleTab() {
|
||||
{timeSlotOptions.map(option => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</CustomSelect>
|
||||
</div>
|
||||
</div>
|
||||
<FormAlert id="schedule-move-slot-alert" alert={moveSlotAlert} />
|
||||
@@ -1863,14 +1864,14 @@ export function ScheduleTab() {
|
||||
<div className="schedule-visual-period-grid">
|
||||
<div className="form-group">
|
||||
<label htmlFor="schedule-visual-year">Учебный год</label>
|
||||
<select id="schedule-visual-year" value={effectiveVisualPeriod.yearId} onChange={handleVisualYearChange}>
|
||||
<CustomSelect id="schedule-visual-year" value={effectiveVisualPeriod.yearId} onChange={handleVisualYearChange}>
|
||||
<option value="">Выберите учебный год</option>
|
||||
{academicYears.map(year => <option key={year.id} value={year.id}>{year.title}</option>)}
|
||||
</select>
|
||||
</CustomSelect>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="schedule-visual-semester">Семестр</label>
|
||||
<select id="schedule-visual-semester" value={effectiveVisualPeriod.semesterId} onChange={handleVisualSemesterChange}>
|
||||
<CustomSelect id="schedule-visual-semester" value={effectiveVisualPeriod.semesterId} onChange={handleVisualSemesterChange}>
|
||||
{effectiveVisualPeriod.yearSemesters.length ? (
|
||||
<>
|
||||
<option value="">Выберите семестр</option>
|
||||
@@ -1881,7 +1882,7 @@ export function ScheduleTab() {
|
||||
) : (
|
||||
<option value="">Нет семестров</option>
|
||||
)}
|
||||
</select>
|
||||
</CustomSelect>
|
||||
</div>
|
||||
</div>
|
||||
<div className="schedule-visual-filter-head">
|
||||
@@ -1950,24 +1951,24 @@ function RuleSlotRow({
|
||||
<div className={`schedule-slot-row${laboratory ? ' has-subgroup' : ''}`} data-index={index} data-slot-id={slot.id || ''}>
|
||||
<div className="form-group">
|
||||
<label>День</label>
|
||||
<select className="slot-day" required disabled={disabled} value={slot.dayOfWeek} onChange={event => onChange(index, { dayOfWeek: Number(event.target.value) })}>
|
||||
<CustomSelect className="slot-day" required disabled={disabled} value={slot.dayOfWeek} onChange={event => onChange(index, { dayOfWeek: Number(event.target.value) })}>
|
||||
<option value="">Выберите</option>
|
||||
{DAY_OPTIONS.map(day => <option key={day.value} value={day.value}>{day.label}</option>)}
|
||||
</select>
|
||||
</CustomSelect>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Чётность</label>
|
||||
<select className="slot-parity" required disabled={disabled} value={slot.parity} onChange={event => onChange(index, { parity: event.target.value })}>
|
||||
<CustomSelect className="slot-parity" required disabled={disabled} value={slot.parity} onChange={event => onChange(index, { parity: event.target.value })}>
|
||||
<option value="">Выберите</option>
|
||||
{PARITY_OPTIONS.map(option => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
</CustomSelect>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Пара</label>
|
||||
<select className="slot-time" required disabled={disabled} value={slot.timeSlotId} onChange={event => onChange(index, { timeSlotId: Number(event.target.value) })}>
|
||||
<CustomSelect className="slot-time" required disabled={disabled} value={slot.timeSlotId} onChange={event => onChange(index, { timeSlotId: Number(event.target.value) })}>
|
||||
<option value="">Выберите</option>
|
||||
{timeSlotOptions.map(option => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
</CustomSelect>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Преподаватель</label>
|
||||
@@ -2005,10 +2006,10 @@ function RuleSlotRow({
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Тип</label>
|
||||
<select className="slot-lesson-type" required disabled={disabled} value={slot.lessonTypeId} onChange={event => onChange(index, { lessonTypeId: Number(event.target.value) })}>
|
||||
<CustomSelect className="slot-lesson-type" required disabled={disabled} value={slot.lessonTypeId} onChange={event => onChange(index, { lessonTypeId: Number(event.target.value) })}>
|
||||
<option value="">Выберите</option>
|
||||
{lessonTypeOptions.map(option => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
</CustomSelect>
|
||||
</div>
|
||||
{laboratory && (selectedGroupIds.length > 1 ? (
|
||||
<div className="form-group slot-subgroup-multi">
|
||||
@@ -2068,7 +2069,7 @@ function RuleSlotRow({
|
||||
) : (
|
||||
<div className="form-group">
|
||||
<label>Подгруппа</label>
|
||||
<select
|
||||
<CustomSelect
|
||||
className="slot-subgroup"
|
||||
disabled={disabled}
|
||||
value={slot.subgroupIds[0] == null ? WHOLE_GROUP_SUBGROUP_VALUE : String(slot.subgroupIds[0])}
|
||||
@@ -2088,15 +2089,15 @@ function RuleSlotRow({
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</select>
|
||||
</CustomSelect>
|
||||
</div>
|
||||
))}
|
||||
<div className="form-group">
|
||||
<label>Формат</label>
|
||||
<select className="slot-format" required disabled={disabled} value={slot.lessonFormat} onChange={event => onChange(index, { lessonFormat: event.target.value })}>
|
||||
<CustomSelect className="slot-format" required disabled={disabled} value={slot.lessonFormat} onChange={event => onChange(index, { lessonFormat: event.target.value })}>
|
||||
<option value="">Выберите</option>
|
||||
{LESSON_FORMAT_OPTIONS.map(option => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
</CustomSelect>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -2142,24 +2143,24 @@ function ConflictSlotRow({ slot, index, conflictFields, timeSlotOptions, onSlotC
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>День</label>
|
||||
<select className="conflict-slot-day" required value={slot.dayOfWeek} onChange={event => onSlotChange(index, { dayOfWeek: Number(event.target.value) })}>
|
||||
<CustomSelect className="conflict-slot-day" required value={slot.dayOfWeek} onChange={event => onSlotChange(index, { dayOfWeek: Number(event.target.value) })}>
|
||||
<option value="">Выберите</option>
|
||||
{DAY_OPTIONS.map(day => <option key={day.value} value={day.value}>{day.label}</option>)}
|
||||
</select>
|
||||
</CustomSelect>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Чётность</label>
|
||||
<select className="conflict-slot-parity" required value={slot.parity} onChange={event => onSlotChange(index, { parity: event.target.value })}>
|
||||
<CustomSelect className="conflict-slot-parity" required value={slot.parity} onChange={event => onSlotChange(index, { parity: event.target.value })}>
|
||||
<option value="">Выберите</option>
|
||||
{PARITY_OPTIONS.map(option => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
</CustomSelect>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Пара</label>
|
||||
<select className="conflict-slot-time" required value={slot.timeSlotId} onChange={event => onSlotChange(index, { timeSlotId: Number(event.target.value) })}>
|
||||
<CustomSelect className="conflict-slot-time" required value={slot.timeSlotId} onChange={event => onSlotChange(index, { timeSlotId: Number(event.target.value) })}>
|
||||
<option value="">Выберите</option>
|
||||
{timeSlotOptions.map(option => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
</CustomSelect>
|
||||
</div>
|
||||
<div className={`form-group ${conflictFieldClass('teacher', conflictFields)}`}>
|
||||
<label>Преподаватель</label>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { CustomSelect } from '../../shared/ui/CustomSelect.jsx';
|
||||
|
||||
import {
|
||||
academicYearsFromSemesters,
|
||||
@@ -301,15 +302,15 @@ export function ScheduleVersionsTab() {
|
||||
<div className="version-hero-period-grid">
|
||||
<div>
|
||||
<label htmlFor="version-year">Учебный год</label>
|
||||
<select id="version-year" value={academicYearId} onChange={handleYearChange}>
|
||||
<CustomSelect id="version-year" value={academicYearId} onChange={handleYearChange}>
|
||||
{yearOptions}
|
||||
</select>
|
||||
</CustomSelect>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="version-semester">Рабочий семестр</label>
|
||||
<select id="version-semester" value={semesterId} onChange={handleSemesterChange}>
|
||||
<CustomSelect id="version-semester" value={semesterId} onChange={handleSemesterChange}>
|
||||
{semesterOptions}
|
||||
</select>
|
||||
</CustomSelect>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" className="btn btn-md btn-primary" id="version-create" onClick={openCreateModal}>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
|
||||
import { CustomSelect } from '../../shared/ui/CustomSelect.jsx';
|
||||
|
||||
import { getSession } from '../../../admin/js/api.js';
|
||||
import {
|
||||
@@ -710,7 +711,7 @@ export function ScheduleViewTab() {
|
||||
<div className="schedule-view-control-grid">
|
||||
<div className="form-group">
|
||||
<label htmlFor="schedule-view-target">Что смотреть</label>
|
||||
<select
|
||||
<CustomSelect
|
||||
id="schedule-view-target"
|
||||
value={activeTargetValue}
|
||||
disabled={isDepartment}
|
||||
@@ -720,7 +721,7 @@ export function ScheduleViewTab() {
|
||||
<option value={TARGET_TEACHERS}>Преподаватель</option>
|
||||
<option value={TARGET_CLASSROOMS}>Аудитория</option>
|
||||
<option value={TARGET_DEPARTMENTS}>Кафедра</option>
|
||||
</select>
|
||||
</CustomSelect>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -793,7 +794,7 @@ export function ScheduleViewTab() {
|
||||
hidden={activeTargetValue !== TARGET_DEPARTMENTS}
|
||||
>
|
||||
<label htmlFor="schedule-view-department">Кафедра</label>
|
||||
<select
|
||||
<CustomSelect
|
||||
id="schedule-view-department"
|
||||
value={departmentId}
|
||||
disabled={isDepartment}
|
||||
@@ -801,7 +802,7 @@ export function ScheduleViewTab() {
|
||||
>
|
||||
<option value="">Все кафедры</option>
|
||||
{departmentOptions}
|
||||
</select>
|
||||
</CustomSelect>
|
||||
</div>
|
||||
|
||||
<input
|
||||
@@ -818,7 +819,7 @@ export function ScheduleViewTab() {
|
||||
<div className="schedule-view-extra-grid">
|
||||
<div className="form-group">
|
||||
<label htmlFor="schedule-view-year">Учебный год</label>
|
||||
<select id="schedule-view-year" value={yearId} onChange={handleYearChange}>
|
||||
<CustomSelect id="schedule-view-year" value={yearId} onChange={handleYearChange}>
|
||||
{!baseLoaded ? (
|
||||
<option value="">Загрузка годов…</option>
|
||||
) : (
|
||||
@@ -828,11 +829,11 @@ export function ScheduleViewTab() {
|
||||
))
|
||||
: <option value="">Учебные годы не настроены</option>
|
||||
)}
|
||||
</select>
|
||||
</CustomSelect>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="schedule-view-semester">Семестр</label>
|
||||
<select id="schedule-view-semester" value={semesterId} onChange={handleSemesterChange}>
|
||||
<CustomSelect id="schedule-view-semester" value={semesterId} onChange={handleSemesterChange}>
|
||||
{!baseLoaded ? (
|
||||
<option value="">Сначала выберите год</option>
|
||||
) : (
|
||||
@@ -844,11 +845,11 @@ export function ScheduleViewTab() {
|
||||
))
|
||||
: <option value="">Семестры не настроены</option>
|
||||
)}
|
||||
</select>
|
||||
</CustomSelect>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="schedule-view-subject">Дисциплина</label>
|
||||
<select
|
||||
<CustomSelect
|
||||
id="schedule-view-subject"
|
||||
value={subjectId}
|
||||
onChange={handleSimpleFilterChange('subjectId', setSubjectId)}
|
||||
@@ -857,11 +858,11 @@ export function ScheduleViewTab() {
|
||||
{subjects.map(subject => (
|
||||
<option key={String(subject.id)} value={String(subject.id)}>{subject.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</CustomSelect>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="schedule-view-lesson-type">Тип занятия</label>
|
||||
<select
|
||||
<CustomSelect
|
||||
id="schedule-view-lesson-type"
|
||||
value={lessonTypeId}
|
||||
onChange={handleSimpleFilterChange('lessonTypeId', setLessonTypeId)}
|
||||
@@ -872,11 +873,11 @@ export function ScheduleViewTab() {
|
||||
{lessonType.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</CustomSelect>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="schedule-view-parity">Неделя</label>
|
||||
<select
|
||||
<CustomSelect
|
||||
id="schedule-view-parity"
|
||||
value={parity}
|
||||
onChange={handleSimpleFilterChange('parity', setParity)}
|
||||
@@ -885,7 +886,7 @@ export function ScheduleViewTab() {
|
||||
<option value="BOTH">Каждая</option>
|
||||
<option value="ODD">Нечётная</option>
|
||||
<option value="EVEN">Чётная</option>
|
||||
</select>
|
||||
</CustomSelect>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
@@ -1635,7 +1636,7 @@ const OverrideDrawer = forwardRef(function OverrideDrawer({
|
||||
<div className="schedule-override-fields">
|
||||
<div className="form-group">
|
||||
<label htmlFor="schedule-override-time">Время</label>
|
||||
<select
|
||||
<CustomSelect
|
||||
id="schedule-override-time"
|
||||
required
|
||||
disabled={timeSlotsLoading}
|
||||
@@ -1652,7 +1653,7 @@ const OverrideDrawer = forwardRef(function OverrideDrawer({
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</select>
|
||||
</CustomSelect>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="schedule-override-teacher">Преподаватель</label>
|
||||
@@ -1692,14 +1693,14 @@ const OverrideDrawer = forwardRef(function OverrideDrawer({
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="schedule-override-format">Формат</label>
|
||||
<select
|
||||
<CustomSelect
|
||||
id="schedule-override-format"
|
||||
value={lessonFormat}
|
||||
onChange={event => setLessonFormat(event.target.value)}
|
||||
>
|
||||
<option value="Очно">Очно</option>
|
||||
<option value="Онлайн">Онлайн</option>
|
||||
</select>
|
||||
</CustomSelect>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { CustomSelect } from '../../shared/ui/CustomSelect.jsx';
|
||||
|
||||
import { readUrlState, writeUrlState } from '../../../admin/js/url-state.js';
|
||||
import { trackUxEvent } from '../../../telemetry.js';
|
||||
@@ -83,6 +84,8 @@ export function SubjectsTab() {
|
||||
}, []);
|
||||
|
||||
async function loadSubjects(nextState) {
|
||||
stateRef.current = nextState;
|
||||
setState(nextState);
|
||||
const currentRequest = requestNumber.current + 1;
|
||||
requestNumber.current = currentRequest;
|
||||
setTableLoading(true);
|
||||
@@ -267,7 +270,7 @@ export function SubjectsTab() {
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="new-subject-department">Кафедра</label>
|
||||
<select
|
||||
<CustomSelect
|
||||
id="new-subject-department"
|
||||
required
|
||||
value={createDepartmentId}
|
||||
@@ -279,7 +282,7 @@ export function SubjectsTab() {
|
||||
{departmentOptionLabel(department)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</CustomSelect>
|
||||
</div>
|
||||
<button type="submit" className="btn btn-md btn-primary">Добавить</button>
|
||||
</div>
|
||||
@@ -307,7 +310,7 @@ export function SubjectsTab() {
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="subjects-department-filter">Кафедра</label>
|
||||
<select
|
||||
<CustomSelect
|
||||
id="subjects-department-filter"
|
||||
value={state.departmentId}
|
||||
onChange={event => applyState({ departmentId: event.target.value, page: 0 })}
|
||||
@@ -318,11 +321,11 @@ export function SubjectsTab() {
|
||||
{department.departmentName || department.name || department.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</CustomSelect>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="subjects-sort">Сортировка</label>
|
||||
<select
|
||||
<CustomSelect
|
||||
id="subjects-sort"
|
||||
value={state.sort}
|
||||
onChange={event => applyState({ sort: event.target.value, page: 0 })}
|
||||
@@ -330,7 +333,7 @@ export function SubjectsTab() {
|
||||
{SUBJECT_SORT_OPTIONS.map(option => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</CustomSelect>
|
||||
</div>
|
||||
</div>
|
||||
<div className="table-wrap">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { CustomSelect } from '../../shared/ui/CustomSelect.jsx';
|
||||
|
||||
import { getSession } from '../../../admin/js/api.js';
|
||||
import { AsyncCombobox } from '../../shared/ui/AsyncCombobox.jsx';
|
||||
@@ -621,7 +622,7 @@ export function TeacherAbsencesTab() {
|
||||
<h2>Реестр инцидентов</h2>
|
||||
</div>
|
||||
<div className="absence-registry-actions">
|
||||
<select
|
||||
<CustomSelect
|
||||
id="teacher-absence-status-filter"
|
||||
aria-label="Фильтр по статусу"
|
||||
value={statusFilter}
|
||||
@@ -636,7 +637,7 @@ export function TeacherAbsencesTab() {
|
||||
<option value="RESOLVED">Разрешены</option>
|
||||
<option value="REJECTED">Отклонены</option>
|
||||
<option value="CANCELLED">Отменены</option>
|
||||
</select>
|
||||
</CustomSelect>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-md btn-secondary"
|
||||
@@ -668,7 +669,7 @@ export function TeacherAbsencesTab() {
|
||||
</div>
|
||||
</header>
|
||||
<div className="teacher-dialogue-toolbar">
|
||||
<select
|
||||
<CustomSelect
|
||||
id="teacher-preference-semester-filter"
|
||||
aria-label="Семестр пожеланий"
|
||||
value={preferenceSemesterId}
|
||||
@@ -683,8 +684,8 @@ export function TeacherAbsencesTab() {
|
||||
{absenceSemesterLabel(semester)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
</CustomSelect>
|
||||
<CustomSelect
|
||||
id="teacher-preference-status-filter"
|
||||
aria-label="Статус пожеланий"
|
||||
value={preferenceStatusFilter}
|
||||
@@ -696,7 +697,7 @@ export function TeacherAbsencesTab() {
|
||||
<option value="PENDING">На рассмотрении</option>
|
||||
<option value="APPROVED">Согласованные</option>
|
||||
<option value="">Все статусы</option>
|
||||
</select>
|
||||
</CustomSelect>
|
||||
<button
|
||||
className="btn btn-md btn-secondary"
|
||||
id="teacher-preference-refresh"
|
||||
@@ -726,7 +727,7 @@ export function TeacherAbsencesTab() {
|
||||
</div>
|
||||
</header>
|
||||
<div className="teacher-dialogue-toolbar">
|
||||
<select
|
||||
<CustomSelect
|
||||
id="teacher-change-status-filter"
|
||||
aria-label="Статус заявок"
|
||||
value={changeStatusFilter}
|
||||
@@ -739,7 +740,7 @@ export function TeacherAbsencesTab() {
|
||||
<option value="APPROVED">Одобренные</option>
|
||||
<option value="REJECTED">Отклонённые</option>
|
||||
<option value="">Все статусы</option>
|
||||
</select>
|
||||
</CustomSelect>
|
||||
<button
|
||||
className="btn btn-md btn-secondary"
|
||||
id="teacher-change-refresh"
|
||||
@@ -919,7 +920,7 @@ function LessonDecision({ lesson, index, locked, value, onChange }) {
|
||||
</div>
|
||||
<div className="absence-lesson-decision">
|
||||
<label htmlFor={`absence-decision-${index}`}>Решение</label>
|
||||
<select
|
||||
<CustomSelect
|
||||
id={`absence-decision-${index}`}
|
||||
className="absence-decision-select"
|
||||
disabled={locked}
|
||||
@@ -930,7 +931,7 @@ function LessonDecision({ lesson, index, locked, value, onChange }) {
|
||||
{options.map(option => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</CustomSelect>
|
||||
{lesson.decisionStatus
|
||||
? <span className="absence-decision-fixed">{decisionStatusLabel(lesson.decisionStatus)}</span>
|
||||
: null}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { CustomSelect } from '../../shared/ui/CustomSelect.jsx';
|
||||
|
||||
import { readUrlState, writeUrlState } from '../../../admin/js/url-state.js';
|
||||
import { trackUxEvent } from '../../../telemetry.js';
|
||||
@@ -71,6 +72,8 @@ export function TeacherRequestsTab() {
|
||||
}, []);
|
||||
|
||||
async function loadRequests(nextState) {
|
||||
stateRef.current = nextState;
|
||||
setState(nextState);
|
||||
const currentRequest = requestNumber.current + 1;
|
||||
requestNumber.current = currentRequest;
|
||||
setTableLoading(true);
|
||||
@@ -180,7 +183,7 @@ export function TeacherRequestsTab() {
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="teacher-requests-status">Статус</label>
|
||||
<select
|
||||
<CustomSelect
|
||||
id="teacher-requests-status"
|
||||
value={state.status}
|
||||
onChange={event => applyState({ status: event.target.value, page: 0 })}
|
||||
@@ -188,7 +191,7 @@ export function TeacherRequestsTab() {
|
||||
{REQUEST_STATUS_OPTIONS.map(option => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</CustomSelect>
|
||||
</div>
|
||||
</div>
|
||||
<div className="table-wrap">
|
||||
@@ -296,7 +299,7 @@ function TeacherRequestRow({ request, departments, onApprove, onReject }) {
|
||||
return (
|
||||
<tr>
|
||||
<td>
|
||||
<select
|
||||
<CustomSelect
|
||||
value={departmentId}
|
||||
onChange={event => setDepartmentId(event.target.value)}
|
||||
>
|
||||
@@ -305,7 +308,7 @@ function TeacherRequestRow({ request, departments, onApprove, onReject }) {
|
||||
{requestDepartmentLabel(department)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</CustomSelect>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { CustomSelect } from '../../shared/ui/CustomSelect.jsx';
|
||||
|
||||
import { AlertDialog, ConfirmDialog } from '../../shared/ui/ConfirmDialog.jsx';
|
||||
import { Pagination } from '../../shared/ui/Pagination.jsx';
|
||||
@@ -708,7 +709,7 @@ export function UniversityStructureTab() {
|
||||
<div className="form-row form-row--bottom">
|
||||
<div className="form-group profile-form-spec">
|
||||
<label htmlFor="tab-profile-spec">Специальность</label>
|
||||
<select
|
||||
<CustomSelect
|
||||
id="tab-profile-spec"
|
||||
required
|
||||
disabled={profileForm.editing}
|
||||
@@ -721,7 +722,7 @@ export function UniversityStructureTab() {
|
||||
{specialtyOptionLabel(speciality)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</CustomSelect>
|
||||
</div>
|
||||
<div className="form-group profile-form-name">
|
||||
<label htmlFor="tab-profile-name">Название профиля</label>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { CustomSelect } from '../../shared/ui/CustomSelect.jsx';
|
||||
|
||||
import { readUrlState, writeUrlState } from '../../../admin/js/url-state.js';
|
||||
import { trackUxEvent } from '../../../telemetry.js';
|
||||
@@ -64,6 +65,8 @@ export function UsersTab() {
|
||||
}, []);
|
||||
|
||||
async function loadUsers(nextState) {
|
||||
stateRef.current = nextState;
|
||||
setState(nextState);
|
||||
const currentRequest = requestNumber.current + 1;
|
||||
requestNumber.current = currentRequest;
|
||||
setTableLoading(true);
|
||||
@@ -204,7 +207,7 @@ export function UsersTab() {
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="new-role">Роль</label>
|
||||
<select
|
||||
<CustomSelect
|
||||
id="new-role"
|
||||
value={createRole}
|
||||
onChange={event => setCreateRole(event.target.value)}
|
||||
@@ -215,7 +218,7 @@ export function UsersTab() {
|
||||
<option value="EDUCATION_OFFICE">Учебный отдел</option>
|
||||
<option value="SCHEDULE_VIEWER">Просмотр расписаний</option>
|
||||
<option value="ADMIN">Администратор</option>
|
||||
</select>
|
||||
</CustomSelect>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="new-fullname">ФИО пользователя</label>
|
||||
@@ -276,7 +279,7 @@ export function UsersTab() {
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="users-role-filter">Роль</label>
|
||||
<select
|
||||
<CustomSelect
|
||||
id="users-role-filter"
|
||||
value={state.role}
|
||||
onChange={event => applyState({ role: event.target.value, page: 0 })}
|
||||
@@ -285,11 +288,11 @@ export function UsersTab() {
|
||||
{ROLE_FILTER_OPTIONS.map(option => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</CustomSelect>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="users-sort">Сортировка</label>
|
||||
<select
|
||||
<CustomSelect
|
||||
id="users-sort"
|
||||
value={state.sort}
|
||||
onChange={event => applyState({ sort: event.target.value, page: 0 })}
|
||||
@@ -297,7 +300,7 @@ export function UsersTab() {
|
||||
{USER_SORT_OPTIONS.map(option => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</CustomSelect>
|
||||
</div>
|
||||
</div>
|
||||
<div className="table-wrap">
|
||||
|
||||
@@ -119,7 +119,8 @@ export function preferenceDescription(item) {
|
||||
}
|
||||
|
||||
export function absenceSemesterLabel(semester) {
|
||||
const type = semester.semesterType === 'AUTUMN' ? 'осенний' : semester.semesterType === 'SPRING' ? 'весенний' : semester.semesterType;
|
||||
const semesterType = String(semester.semesterType || '').toUpperCase();
|
||||
const type = semesterType === 'AUTUMN' ? 'осенний' : semesterType === 'SPRING' ? 'весенний' : 'семестр';
|
||||
return `${semester.academicYearTitle} · ${type} · ${formatAbsenceDate(semester.startDate)}–${formatAbsenceDate(semester.endDate)}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ 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 { useSidebar } from '../shared/use-sidebar.js';
|
||||
import { SidebarToggle } from '../shared/ui/SidebarToggle.jsx';
|
||||
import { DatabaseTab } from './DatabaseTab.jsx';
|
||||
import { EduFormsTab } from './EduFormsTab.jsx';
|
||||
import { GeneralTab } from './GeneralTab.jsx';
|
||||
@@ -15,7 +17,6 @@ import {
|
||||
} from './settings-service.js';
|
||||
|
||||
const THEME_STORAGE_KEY = 'theme';
|
||||
const SIDEBAR_STORAGE_KEY = 'sidebar-collapsed';
|
||||
|
||||
const TAB_ICONS = Object.freeze({
|
||||
database: (
|
||||
@@ -72,19 +73,7 @@ export function SettingsApp() {
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
}
|
||||
const { collapsed, mobileOpen, toggleSidebar, closeSidebar } = useSidebar();
|
||||
|
||||
function toggleTheme() {
|
||||
const next = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';
|
||||
@@ -99,7 +88,7 @@ function SettingsShell({ title, sidebar, children }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<aside className={`sidebar${collapsed ? ' collapsed' : ''}`}>
|
||||
<aside id="app-sidebar" className={`sidebar${collapsed && !mobileOpen ? ' collapsed' : ''}${mobileOpen ? ' open' : ''}`}>
|
||||
<div className="sidebar-header">
|
||||
<div className="logo">
|
||||
<svg width="32" height="32" viewBox="0 0 40 40" fill="none">
|
||||
@@ -127,7 +116,9 @@ function SettingsShell({ title, sidebar, children }) {
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{sidebar}
|
||||
<div className="sidebar-nav-container" onClick={event => {
|
||||
if (event.target.closest('.nav-item')) closeSidebar();
|
||||
}}>{sidebar}</div>
|
||||
<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"
|
||||
@@ -149,10 +140,11 @@ function SettingsShell({ title, sidebar, children }) {
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="sidebar-overlay" />
|
||||
<div className={`sidebar-overlay${mobileOpen ? ' open' : ''}`} onClick={closeSidebar} />
|
||||
|
||||
<main className={`main${collapsed ? ' sidebar-collapsed' : ''}`}>
|
||||
<header className="topbar">
|
||||
<SidebarToggle open={mobileOpen} onClick={toggleSidebar} />
|
||||
<h1>{title}</h1>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -19,6 +19,7 @@ export function AsyncCombobox({
|
||||
const inputRef = useRef(null);
|
||||
const debounceTimer = useRef(null);
|
||||
const requestNumber = useRef(0);
|
||||
const restoringFocus = useRef(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const [items, setItems] = useState([]);
|
||||
@@ -38,8 +39,8 @@ export function AsyncCombobox({
|
||||
close();
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handlePointerDown);
|
||||
return () => document.removeEventListener('mousedown', handlePointerDown);
|
||||
document.addEventListener('pointerdown', handlePointerDown);
|
||||
return () => document.removeEventListener('pointerdown', handlePointerDown);
|
||||
});
|
||||
|
||||
async function search(nextQuery) {
|
||||
@@ -73,16 +74,24 @@ export function AsyncCombobox({
|
||||
function choose(item) {
|
||||
onSelect(item);
|
||||
close();
|
||||
restoreInputFocus();
|
||||
}
|
||||
|
||||
function restoreInputFocus() {
|
||||
restoringFocus.current = true;
|
||||
inputRef.current?.focus();
|
||||
restoringFocus.current = false;
|
||||
}
|
||||
|
||||
function clear() {
|
||||
onSelect(null);
|
||||
setQuery('');
|
||||
inputRef.current?.focus();
|
||||
restoreInputFocus();
|
||||
void search('');
|
||||
}
|
||||
|
||||
function handleFocus() {
|
||||
if (restoringFocus.current || open) return;
|
||||
setQuery(selectedLabel);
|
||||
void search(selectedLabel);
|
||||
}
|
||||
@@ -90,6 +99,12 @@ export function AsyncCombobox({
|
||||
function handleInput(event) {
|
||||
const value = event.target.value;
|
||||
setQuery(value);
|
||||
// Старый ответ не должен стать доступен во время задержки нового поиска.
|
||||
requestNumber.current += 1;
|
||||
setOpen(true);
|
||||
setItems([]);
|
||||
setLoading(true);
|
||||
setError('');
|
||||
window.clearTimeout(debounceTimer.current);
|
||||
debounceTimer.current = window.setTimeout(() => {
|
||||
void search(value);
|
||||
@@ -98,6 +113,7 @@ export function AsyncCombobox({
|
||||
|
||||
function handleInputKeydown(event) {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
close();
|
||||
} else if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
@@ -111,7 +127,7 @@ export function AsyncCombobox({
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
close();
|
||||
inputRef.current?.focus();
|
||||
restoreInputFocus();
|
||||
} else if (event.key === 'ArrowDown' && current >= 0) {
|
||||
event.preventDefault();
|
||||
options[Math.min(current + 1, options.length - 1)]?.focus();
|
||||
@@ -122,7 +138,13 @@ export function AsyncCombobox({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`async-combobox${open ? ' is-open' : ''}`} ref={rootRef}>
|
||||
<div
|
||||
className={`async-combobox${open ? ' is-open' : ''}`}
|
||||
ref={rootRef}
|
||||
onBlur={event => {
|
||||
if (!event.currentTarget.contains(event.relatedTarget)) close();
|
||||
}}
|
||||
>
|
||||
<div className="async-combobox-control">
|
||||
<input
|
||||
type="search"
|
||||
|
||||
171
frontend/react/shared/ui/CustomSelect.jsx
Normal file
171
frontend/react/shared/ui/CustomSelect.jsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import { useEffect, useId, useLayoutEffect, useRef, useState } from 'react';
|
||||
|
||||
/** Селект дизайн-системы: разметкой и состоянием полностью управляет React. */
|
||||
export function CustomSelect({ children, ...props }) {
|
||||
const rootRef = useRef(null);
|
||||
const selectRef = useRef(null);
|
||||
const triggerRef = useRef(null);
|
||||
const listId = useId();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const [invalid, setInvalid] = useState(false);
|
||||
const [snapshot, setSnapshot] = useState({ options: [], selectedIndex: -1, label: '' });
|
||||
|
||||
// Читаем итоговые option после commit: children могут содержать массивы и фрагменты.
|
||||
// Контролируемые value, disabled и подписи синхронизируются при каждом рендере.
|
||||
useLayoutEffect(() => {
|
||||
const select = selectRef.current;
|
||||
const next = {
|
||||
options: Array.from(select.options, option => ({
|
||||
value: option.value,
|
||||
text: option.text,
|
||||
disabled: option.disabled || Boolean(option.closest('optgroup')?.disabled)
|
||||
})),
|
||||
selectedIndex: select.selectedIndex,
|
||||
label: Array.from(select.labels || [], label => {
|
||||
const caption = label.cloneNode(true);
|
||||
caption.querySelectorAll('select, .custom-select-wrapper').forEach(node => node.remove());
|
||||
return caption.textContent.trim();
|
||||
}).join(' ')
|
||||
};
|
||||
setSnapshot(current => JSON.stringify(current) === JSON.stringify(next) ? current : next);
|
||||
if (select.validity.valid) setInvalid(false);
|
||||
});
|
||||
|
||||
const expanded = open && !props.disabled;
|
||||
useEffect(() => {
|
||||
if (!expanded) return undefined;
|
||||
function closeOutside(event) {
|
||||
if (!rootRef.current?.contains(event.target)) setOpen(false);
|
||||
}
|
||||
document.addEventListener('pointerdown', closeOutside);
|
||||
return () => document.removeEventListener('pointerdown', closeOutside);
|
||||
}, [expanded]);
|
||||
|
||||
const selected = snapshot.options[snapshot.selectedIndex];
|
||||
const searchable = snapshot.options.length > 6;
|
||||
|
||||
function choose(index) {
|
||||
const select = selectRef.current;
|
||||
select.selectedIndex = index;
|
||||
select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
setOpen(false);
|
||||
triggerRef.current?.focus();
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
setQuery('');
|
||||
setOpen(current => !current);
|
||||
}
|
||||
|
||||
function handleKeydown(event) {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setOpen(false);
|
||||
triggerRef.current?.focus();
|
||||
} else if (['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) {
|
||||
event.preventDefault();
|
||||
if (!expanded) {
|
||||
setQuery('');
|
||||
setOpen(true);
|
||||
return;
|
||||
}
|
||||
const options = Array.from(rootRef.current.querySelectorAll('[role="option"]:not(:disabled)'));
|
||||
const current = options.indexOf(document.activeElement);
|
||||
const index = event.key === 'Home' ? 0
|
||||
: event.key === 'End' ? options.length - 1
|
||||
: event.key === 'ArrowUp' ? (current < 0 ? options.length - 1 : Math.max(0, current - 1))
|
||||
: Math.min(options.length - 1, current + 1);
|
||||
options[index]?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`react-custom-select${expanded ? ' select-layer-active' : ''}`}
|
||||
ref={rootRef}
|
||||
onKeyDown={handleKeydown}
|
||||
onBlur={event => {
|
||||
if (!event.currentTarget.contains(event.relatedTarget)) setOpen(false);
|
||||
}}
|
||||
>
|
||||
<select
|
||||
{...props}
|
||||
ref={selectRef}
|
||||
hidden
|
||||
aria-hidden="true"
|
||||
tabIndex={-1}
|
||||
data-native-select="true"
|
||||
onInvalid={event => {
|
||||
props.onInvalid?.(event);
|
||||
event.preventDefault();
|
||||
setInvalid(true);
|
||||
triggerRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</select>
|
||||
<div className={`custom-select-wrapper${expanded ? ' open' : ''}${props.disabled ? ' disabled' : ''}`}>
|
||||
<button
|
||||
type="button"
|
||||
className={`custom-select-trigger${!selected?.value ? ' placeholder-active' : ''}`}
|
||||
ref={triggerRef}
|
||||
disabled={props.disabled}
|
||||
role="combobox"
|
||||
aria-expanded={expanded}
|
||||
aria-controls={listId}
|
||||
aria-haspopup="listbox"
|
||||
aria-required={props.required || undefined}
|
||||
aria-invalid={invalid || undefined}
|
||||
aria-describedby={invalid ? `${listId}-error` : undefined}
|
||||
aria-label={props['aria-label'] || snapshot.label || 'Выбор значения'}
|
||||
onClick={toggle}
|
||||
>
|
||||
<span className="custom-select-text">{selected?.text || '—'}</span>
|
||||
<span className="custom-select-icon" aria-hidden="true">
|
||||
<svg width="12" height="8" viewBox="0 0 12 8" fill="none">
|
||||
<path d="M1 1.5L6 6.5L11 1.5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="custom-select-menu">
|
||||
{searchable && (
|
||||
<input
|
||||
className="custom-select-search-input"
|
||||
type="search"
|
||||
placeholder="Поиск..."
|
||||
aria-label="Поиск по списку"
|
||||
value={query}
|
||||
onChange={event => setQuery(event.target.value)}
|
||||
/>
|
||||
)}
|
||||
<ul className="react-select-options" role="listbox" id={listId}>
|
||||
{snapshot.options.filter(option => option.text.toLocaleLowerCase('ru').includes(query.toLocaleLowerCase('ru'))).length === 0 && (
|
||||
<li className="custom-select-item disabled">Ничего не найдено</li>
|
||||
)}
|
||||
{snapshot.options.map((option, index) => (
|
||||
option.text.toLocaleLowerCase('ru').includes(query.toLocaleLowerCase('ru')) && (
|
||||
<li key={index} role="presentation">
|
||||
<button
|
||||
type="button"
|
||||
className={`custom-select-item${index === snapshot.selectedIndex ? ' selected' : ''}${option.disabled ? ' disabled' : ''}`}
|
||||
role="option"
|
||||
aria-selected={index === snapshot.selectedIndex}
|
||||
disabled={option.disabled}
|
||||
onClick={() => choose(index)}
|
||||
>
|
||||
{option.text}
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{invalid && <span id={`${listId}-error`} className="form-alert error" role="alert">Выберите значение</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CustomSelect } from './CustomSelect.jsx';
|
||||
const PAGE_SIZES = [25, 50, 100];
|
||||
|
||||
export function Pagination({
|
||||
@@ -25,14 +26,14 @@ export function Pagination({
|
||||
<div className="pagination-actions">
|
||||
<label className="pagination-size">
|
||||
<span>На странице</span>
|
||||
<select
|
||||
<CustomSelect
|
||||
value={pageSize}
|
||||
onChange={event => onSize?.(Number(event.target.value))}
|
||||
>
|
||||
{PAGE_SIZES.map(value => (
|
||||
<option key={value} value={value}>{value}</option>
|
||||
))}
|
||||
</select>
|
||||
</CustomSelect>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
16
frontend/react/shared/ui/SidebarToggle.jsx
Normal file
16
frontend/react/shared/ui/SidebarToggle.jsx
Normal file
@@ -0,0 +1,16 @@
|
||||
export function SidebarToggle({ open, onClick }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-icon-md btn-secondary menu-toggle"
|
||||
aria-label={open ? 'Закрыть меню' : 'Открыть меню'}
|
||||
aria-expanded={open}
|
||||
aria-controls="app-sidebar"
|
||||
onClick={onClick}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
|
||||
<path d="M3 6h18M3 12h18M3 18h18" />
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
40
frontend/react/shared/use-sidebar.js
Normal file
40
frontend/react/shared/use-sidebar.js
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const STORAGE_KEY = 'sidebar-collapsed';
|
||||
|
||||
/** Сворачивание на десктопе и открытие мобильного меню — независимые состояния. */
|
||||
export function useSidebar() {
|
||||
const [collapsed, setCollapsed] = useState(() => (
|
||||
typeof window !== 'undefined' && localStorage.getItem(STORAGE_KEY) === 'true'
|
||||
));
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const media = window.matchMedia('(max-width: 768px)');
|
||||
const close = () => setMobileOpen(false);
|
||||
media.addEventListener('change', close);
|
||||
return () => media.removeEventListener('change', close);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mobileOpen) return undefined;
|
||||
const close = event => {
|
||||
if (event.key === 'Escape') setMobileOpen(false);
|
||||
};
|
||||
document.addEventListener('keydown', close);
|
||||
return () => document.removeEventListener('keydown', close);
|
||||
}, [mobileOpen]);
|
||||
|
||||
function toggleSidebar() {
|
||||
if (window.matchMedia('(max-width: 768px)').matches) {
|
||||
setMobileOpen(current => !current);
|
||||
} else {
|
||||
setCollapsed(current => {
|
||||
localStorage.setItem(STORAGE_KEY, String(!current));
|
||||
return !current;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { collapsed, mobileOpen, toggleSidebar, closeSidebar: () => setMobileOpen(false) };
|
||||
}
|
||||
Reference in New Issue
Block a user