баги
This commit is contained in:
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
Проект представляет собой систему управления университетским расписанием.
|
Проект представляет собой систему управления университетским расписанием.
|
||||||
- **Backend**: Java 17, Spring Boot 3.5.16 (Мультитенантная архитектура: отдельная БД для каждого клиента)
|
- **Backend**: Java 17, Spring Boot 3.5.16 (Мультитенантная архитектура: отдельная БД для каждого клиента)
|
||||||
- **Frontend**: поэтапный переход с Vanilla JavaScript на React 19 + HTML/CSS
|
- **Frontend**: React 19 + HTML/CSS (все пользовательские страницы и вкладки)
|
||||||
- **Database**: PostgreSQL (множество БД, управляются через Flyway)
|
- **Database**: PostgreSQL (множество БД, управляются через Flyway)
|
||||||
- **Локальный URL**: localhost:80
|
- **Локальный URL**: localhost:80
|
||||||
- **Продакшн URL**: https://magistr.zuev.company
|
- **Продакшн URL**: https://magistr.zuev.company
|
||||||
@@ -25,7 +25,7 @@ magistr/
|
|||||||
│ ├── utils/ # Утилиты
|
│ ├── utils/ # Утилиты
|
||||||
│ └── src/main/resources/db/migration/ # Flyway SQL миграции (версионирование схемы БД)
|
│ └── src/main/resources/db/migration/ # Flyway SQL миграции (версионирование схемы БД)
|
||||||
├── frontend/ # Статические файлы
|
├── frontend/ # Статические файлы
|
||||||
│ ├── react/ # React entrypoint и компоненты уже перенесённых страниц
|
│ ├── react/ # React entrypoint, страницы и общие компоненты
|
||||||
│ ├── admin/ # Интерфейс администратора
|
│ ├── admin/ # Интерфейс администратора
|
||||||
│ │ └── settings/ # Страница настроек (отдельный SPA)
|
│ │ └── settings/ # Страница настроек (отдельный SPA)
|
||||||
│ ├── department/ # Redirect в кабинет кафедры внутри admin SPA
|
│ ├── department/ # Redirect в кабинет кафедры внутри admin SPA
|
||||||
|
|||||||
@@ -85,7 +85,11 @@ public class JwtTokenService {
|
|||||||
|
|
||||||
Long userId = asLong(jwt.getClaim("userId")).orElseGet(() -> Long.valueOf(jwt.getSubject()));
|
Long userId = asLong(jwt.getClaim("userId")).orElseGet(() -> Long.valueOf(jwt.getSubject()));
|
||||||
String username = jwt.getClaimAsString("username");
|
String username = jwt.getClaimAsString("username");
|
||||||
Role role = Role.valueOf(jwt.getClaimAsString("role"));
|
String roleClaim = jwt.getClaimAsString("role");
|
||||||
|
if (userId <= 0 || username == null || username.isBlank() || roleClaim == null) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
Role role = Role.valueOf(roleClaim);
|
||||||
Long departmentId = asLong(jwt.getClaim("departmentId")).orElse(null);
|
Long departmentId = asLong(jwt.getClaim("departmentId")).orElse(null);
|
||||||
|
|
||||||
return Optional.of(new AuthenticatedUser(userId, username, role, departmentId));
|
return Optional.of(new AuthenticatedUser(userId, username, role, departmentId));
|
||||||
|
|||||||
@@ -59,6 +59,42 @@ class JwtTokenServiceTest {
|
|||||||
assertThat(service.authenticate(token, "magistr")).isEmpty();
|
assertThat(service.authenticate(token, "magistr")).isEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsTokenWithMissingRole() throws JOSEException {
|
||||||
|
String secret = "12345678901234567890123456789012";
|
||||||
|
JwtTokenService service = new JwtTokenService(properties(secret, Duration.ofMinutes(15)));
|
||||||
|
assertThat(service.authenticate(signedToken(secret, null, "admin", 10L), "magistr")).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsTokenWithMissingUsername() throws JOSEException {
|
||||||
|
String secret = "12345678901234567890123456789012";
|
||||||
|
JwtTokenService service = new JwtTokenService(properties(secret, Duration.ofMinutes(15)));
|
||||||
|
assertThat(service.authenticate(signedToken(secret, "ADMIN", null, 10L), "magistr")).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsTokenWithInvalidUserId() throws JOSEException {
|
||||||
|
String secret = "12345678901234567890123456789012";
|
||||||
|
JwtTokenService service = new JwtTokenService(properties(secret, Duration.ofMinutes(15)));
|
||||||
|
assertThat(service.authenticate(signedToken(secret, "ADMIN", "admin", -1L), "magistr")).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String signedToken(String secret, String role, String username, long userId) throws JOSEException {
|
||||||
|
JWTClaimsSet claims = new JWTClaimsSet.Builder()
|
||||||
|
.issuer("magistr")
|
||||||
|
.subject(Long.toString(userId))
|
||||||
|
.expirationTime(Date.from(Instant.now().plusSeconds(900)))
|
||||||
|
.claim("tenant", "magistr")
|
||||||
|
.claim("userId", userId)
|
||||||
|
.claim("username", username)
|
||||||
|
.claim("role", role)
|
||||||
|
.build();
|
||||||
|
SignedJWT jwt = new SignedJWT(new JWSHeader(JWSAlgorithm.HS256), claims);
|
||||||
|
jwt.sign(new MACSigner(secret.getBytes(StandardCharsets.UTF_8)));
|
||||||
|
return jwt.serialize();
|
||||||
|
}
|
||||||
|
|
||||||
private String expiredToken(String secret) {
|
private String expiredToken(String secret) {
|
||||||
try {
|
try {
|
||||||
Instant now = Instant.now();
|
Instant now = Instant.now();
|
||||||
|
|||||||
@@ -425,6 +425,9 @@ Access JWT содержит claim'ы `tenant`, `userId`, `username`, `role`, `de
|
|||||||
frontend восстанавливает access JWT и профиль в памяти через refresh-cookie; Web Storage
|
frontend восстанавливает access JWT и профиль в памяти через refresh-cookie; Web Storage
|
||||||
для данных авторизации не используется. Backend возвращает `401`, если токен отсутствует
|
для данных авторизации не используется. Backend возвращает `401`, если токен отсутствует
|
||||||
или некорректен, и `403`, если роли недостаточно.
|
или некорректен, и `403`, если роли недостаточно.
|
||||||
|
Подписанный токен с отсутствующей ролью или пустым логином, а также с неположительным
|
||||||
|
ID пользователя отклоняется как некорректный. Отсутствие обязательных данных профиля
|
||||||
|
не должно приводить к внутренней ошибке сервера.
|
||||||
|
|
||||||
`AuthContext` существует только в границах одного servlet-запроса. Интерцептор очищает
|
`AuthContext` существует только в границах одного servlet-запроса. Интерцептор очищает
|
||||||
`ThreadLocal` до любых ранних выходов, устанавливает пользователя только после успешной
|
`ThreadLocal` до любых ранних выходов, устанавливает пользователя только после успешной
|
||||||
|
|||||||
@@ -4,15 +4,15 @@
|
|||||||
|
|
||||||
| Параметр | Значение |
|
| Параметр | Значение |
|
||||||
|----------|----------|
|
|----------|----------|
|
||||||
| **Фреймворк** | Переходный режим: React 19 для страницы входа, кабинетов студента и преподавателя, страницы настроек и общего слоя `react/shared`, Vanilla JavaScript для остальных разделов |
|
| **Фреймворк** | React 19 для всех пользовательских страниц и вкладок; общие сервисы и чистые вспомогательные функции остаются ES-модулями |
|
||||||
| **Модульная система** | ES6 Modules (`import`/`export`) |
|
| **Модульная система** | ES6 Modules (`import`/`export`) |
|
||||||
| **Стили** | CSS (модульный подход) |
|
| **Стили** | CSS (модульный подход) |
|
||||||
| **Шрифт** | Системный стек без внешних font-CDN |
|
| **Шрифт** | Системный стек без внешних font-CDN |
|
||||||
| **Веб-сервер** | Apache httpd на Alpine со строгим CSP |
|
| **Веб-сервер** | Apache httpd на Alpine со строгим CSP |
|
||||||
|
|
||||||
Пошаговый план и актуальная точка продолжения находятся в
|
Пошаговый план и актуальная точка продолжения находятся в
|
||||||
[`REACT_MIGRATION.md`](REACT_MIGRATION.md). Миграция выполняется по целым страницам и
|
[`REACT_MIGRATION.md`](REACT_MIGRATION.md). Все страницы и вкладки переведены на React
|
||||||
разделам без изменения действующих URL и REST-контрактов.
|
с сохранением действующих URL и REST-контрактов.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -102,14 +102,15 @@ frontend/
|
|||||||
│ │ │ └── TeacherAbsencesTab.jsx # Отсутствия: реестр, мастер замены, пожелания и заявки на изменение
|
│ │ │ └── TeacherAbsencesTab.jsx # Отсутствия: реестр, мастер замены, пожелания и заявки на изменение
|
||||||
│ │ ├── main.jsx # React entrypoint для `/admin/`
|
│ │ ├── main.jsx # React entrypoint для `/admin/`
|
||||||
│ │ └── testing/ # render-admin.jsx для DOM-проверок
|
│ │ └── testing/ # render-admin.jsx для DOM-проверок
|
||||||
│ └── shared/ # Общий React-слой для следующих переносимых страниц
|
│ └── shared/ # Общий React-слой всех кабинетов
|
||||||
│ ├── api-client.js # requestJson/apiClient поверх auth-транспорта
|
│ ├── api-client.js # requestJson/apiClient поверх auth-транспорта
|
||||||
│ ├── auth-client.js # Восстановление сессии и logout с redirect
|
│ ├── auth-client.js # Восстановление сессии и logout с redirect
|
||||||
|
│ ├── use-sidebar.js # Десктопное сворачивание и мобильное меню
|
||||||
│ ├── dates.js # Дата-математика (недели, ISO, форматирование)
|
│ ├── dates.js # Дата-математика (недели, ISO, форматирование)
|
||||||
│ ├── session-policy.js # Фазы loading/ready/unauthorized/error и русские тексты
|
│ ├── session-policy.js # Фазы loading/ready/unauthorized/error и русские тексты
|
||||||
│ ├── use-authorized-session.js # Хук проверки роли при монтировании страницы
|
│ ├── use-authorized-session.js # Хук проверки роли при монтировании страницы
|
||||||
│ ├── runtime-entry.jsx # Единая точка экспорта общего runtime-chunk
|
│ ├── runtime-entry.jsx # Единая точка экспорта общего runtime-chunk
|
||||||
│ ├── ui/ # AppErrorBoundary, AsyncState, FormAlert, AsyncCombobox, ScheduleOverview, ConfirmDialog, Pagination
|
│ ├── ui/ # CustomSelect, SidebarToggle, AppErrorBoundary, AsyncState, FormAlert, AsyncCombobox, ScheduleOverview, ConfirmDialog, Pagination
|
||||||
│ └── testing/ # render-shared.jsx для DOM-проверок через renderToStaticMarkup
|
│ └── testing/ # render-shared.jsx для DOM-проверок через renderToStaticMarkup
|
||||||
├── tests/
|
├── tests/
|
||||||
│ ├── academic-calendar-grid.test.mjs # Неполная первая неделя календарного графика
|
│ ├── academic-calendar-grid.test.mjs # Неполная первая неделя календарного графика
|
||||||
@@ -132,6 +133,7 @@ frontend/
|
|||||||
│ ├── schedule-view-semesters.test.mjs # Выбор семестра и расчёт двухнедельного диапазона просмотра
|
│ ├── schedule-view-semesters.test.mjs # Выбор семестра и расчёт двухнедельного диапазона просмотра
|
||||||
│ ├── teacher-absences.test.mjs # Payload мастера замены и доступность вкладки по ролям
|
│ ├── teacher-absences.test.mjs # Payload мастера замены и доступность вкладки по ролям
|
||||||
│ ├── teacher-preferences.test.mjs # Календарь пожеланий, заявки и подсказки конструктора
|
│ ├── teacher-preferences.test.mjs # Календарь пожеланий, заявки и подсказки конструктора
|
||||||
|
│ ├── browser/ # Playwright: наведение, селекты и мобильное меню
|
||||||
│ └── security-policy.test.mjs # Web Storage, XSS, пароли, язык, CSP и Dockerfile
|
│ └── security-policy.test.mjs # Web Storage, XSS, пароли, язык, CSP и Dockerfile
|
||||||
├── index.html # 🔐 Страница авторизации (общая)
|
├── index.html # 🔐 Страница авторизации (общая)
|
||||||
├── style.css # Стили страницы авторизации
|
├── style.css # Стили страницы авторизации
|
||||||
@@ -330,9 +332,15 @@ settings SPA не содержат неработающей кнопки бур
|
|||||||
`PageResponse`; размер страницы — 25, 50 или 100. `view-state.js` унифицирует
|
`PageResponse`; размер страницы — 25, 50 или 100. `view-state.js` унифицирует
|
||||||
загрузку со spinner, пустой результат и ошибку с кнопкой повторного запроса.
|
загрузку со spinner, пустой результат и ошибку с кнопкой повторного запроса.
|
||||||
- `AsyncCombobox` загружает варианты с сервера, сохраняет выбранный ID, поддерживает
|
- `AsyncCombobox` загружает варианты с сервера, сохраняет выбранный ID, поддерживает
|
||||||
клавиатуру и роли `combobox/listbox`. Он используется для групп, преподавателей,
|
клавиатуру и роли `combobox/listbox`. При новом вводе сразу исключаются устаревшие
|
||||||
|
результаты, включая ответы во время задержки поиска. Escape закрывает меню с возвратом
|
||||||
|
фокуса без нового запроса; уход фокуса за пределы компонента отменяет отложенный поиск.
|
||||||
|
Возврат стрелкой вверх сохраняет введённый текст. Он используется для групп, преподавателей,
|
||||||
аудиторий и совместимых календарных графиков; предварительная загрузка вариантов не
|
аудиторий и совместимых календарных графиков; предварительная загрузка вариантов не
|
||||||
раскрывает меню. Студент больше не загружает полный список групп.
|
раскрывает меню. Студент больше не загружает полный список групп.
|
||||||
|
- Реестры пользователей, групп, дисциплин и заявок сохраняют выбранные параметры до
|
||||||
|
завершения запроса, чтобы быстрые изменения фильтров и сортировки не отменяли друг друга.
|
||||||
|
Сброс фильтров групп также отменяет отложенный поиск.
|
||||||
- `dialog.js` заменяет нативные `confirm`, `prompt` и `alert`: возвращает фокус, удерживает
|
- `dialog.js` заменяет нативные `confirm`, `prompt` и `alert`: возвращает фокус, удерживает
|
||||||
его внутри окна, закрывается по Escape и может требовать причину. Нативных диалогов в
|
его внутри окна, закрывается по Escape и может требовать причину. Нативных диалогов в
|
||||||
admin/settings-модулях нет.
|
admin/settings-модулях нет.
|
||||||
@@ -414,11 +422,28 @@ SHA-256 style-хэшей с CSP. Там же закреплены безопас
|
|||||||
npm run build # собрать dist/vendor/otel.js и dist/react/login.js
|
npm run build # собрать dist/vendor/otel.js и dist/react/login.js
|
||||||
npm test # frontend unit/static tests
|
npm test # frontend unit/static tests
|
||||||
npm run check # React-сборка, синтаксис auth/UI-модулей + все frontend-тесты
|
npm run check # React-сборка, синтаксис auth/UI-модулей + все frontend-тесты
|
||||||
|
npm run check:browser # Проверка UI, асинхронного поиска и валидации в локальном Chromium
|
||||||
```
|
```
|
||||||
|
|
||||||
React-сборка выполняется существующим esbuild без отдельного dev-сервера. Для страницы
|
React-сборка выполняется esbuild без отдельного dev-сервера. Общие зависимости вынесены
|
||||||
входа установлен лимит 75 000 байт gzip; превышение завершает сборку ошибкой. При появлении
|
в кэшируемые chunk'и, вкладки админки загружаются через `React.lazy`. Холодный бюджет
|
||||||
нескольких React entrypoint общий runtime будет вынесен в отдельный кэшируемый chunk.
|
входа — 85 000 байт gzip, остальных кабинетов — 90 000 байт. Для каждого entrypoint
|
||||||
|
считаются только его статические зависимости: lazy-вкладки и модули других страниц
|
||||||
|
не включаются в холодную загрузку. Превышение лимита завершает сборку ошибкой.
|
||||||
|
|
||||||
|
Браузерные проверки требуют запущенной локальной версии в Docker и Chromium
|
||||||
|
(`/usr/bin/chromium`, другой путь задаётся через `CHROMIUM_PATH`). По умолчанию
|
||||||
|
используются `https://localhost` и демо-аккаунт `admin`; настройки можно передать через
|
||||||
|
`FRONTEND_TEST_BASE_URL`, `FRONTEND_TEST_LOGIN`, `FRONTEND_TEST_PASSWORD`.
|
||||||
|
Проверка пожеланий использует демо-аккаунт преподавателя; для другого аккаунта
|
||||||
|
задаются `FRONTEND_TEST_TEACHER_LOGIN` и `FRONTEND_TEST_TEACHER_PASSWORD`.
|
||||||
|
Проверки не сохраняют изменения данных: проверяются обе темы, доступные вкладки,
|
||||||
|
края кнопок, смена роли в форме, сброс фильтра, мобильное меню админки/настроек
|
||||||
|
и стабильность ячеек пожеланий преподавателя. Отдельные сценарии задерживают ответы
|
||||||
|
API для проверки совместного изменения роли и сортировки и отмены поиска после сброса.
|
||||||
|
`shared-controls.test.mjs` собирает текущие компоненты в изолированную браузерную форму:
|
||||||
|
проверяет устаревшие ответы и ошибки поиска, клавиатурное управление, уход фокуса
|
||||||
|
и валидацию обязательного селекта без записи данных в API.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -549,6 +574,32 @@ ID преподавателя берётся из восстановленног
|
|||||||
4. **`modals.css`** — Модальные окна
|
4. **`modals.css`** — Модальные окна
|
||||||
5. **`departments-data.css`** — Стили создания кафедры/специальности
|
5. **`departments-data.css`** — Стили создания кафедры/специальности
|
||||||
|
|
||||||
|
### Общие элементы управления после миграции
|
||||||
|
|
||||||
|
Кнопки сохраняют область наведения: hover и active меняют цвет, границу и тень,
|
||||||
|
не смещая сам элемент через `translateY`. Это исключает мерцание у нижней кромки.
|
||||||
|
Пункты бокового меню и ячейки пожеланий преподавателя также не смещаются при наведении.
|
||||||
|
Карточки и боковая панель админки/настроек используют непрозрачные фоновые токены
|
||||||
|
без `backdrop-filter`; у карточек отключена анимация с сохраняемым `transform`.
|
||||||
|
Это убирает фоновые фильтруемые слои, на которых наблюдались цветные полосы при
|
||||||
|
перерисовке после наведения на меню. В развёрнутой панели не создаются избыточные
|
||||||
|
нативные подсказки `title`; подсказки остаются у скрытых подписей свёрнутого меню.
|
||||||
|
Волна нажатия страницы входа ограничена `.btn-submit`; админка больше не добавляет
|
||||||
|
ripple-узлы в React-разметку старым глобальным обработчиком.
|
||||||
|
|
||||||
|
Одинарные селекты вкладок и пагинации используют React-компонент `CustomSelect`.
|
||||||
|
Подписи, пункты и `disabled` синхронизируются с нативным контролом после рендера;
|
||||||
|
пустой пункт доступен для сброса фильтров. Автоматическое оборачивание селектов
|
||||||
|
через `admin/js/dropdown.js` отключено. При отправке формы пустой обязательный
|
||||||
|
селект показывает «Выберите значение», получает фокус на видимой кнопке и помечается
|
||||||
|
`aria-invalid`; выбор значения снимает ошибку. Пример — в `docs/UI_COMPONENTS.md`.
|
||||||
|
|
||||||
|
Оболочки админки и настроек используют `useSidebar` и `SidebarToggle`:
|
||||||
|
сворачивание десктопной панели хранится в `localStorage`, открытие мобильного
|
||||||
|
меню — отдельное временное состояние. Мобильная панель закрывается при выборе
|
||||||
|
раздела, клике по затемнению, Escape и смене размера экрана. Навигация развёрнутой
|
||||||
|
панели прокручивается, а её заголовок и нижние действия остаются доступными.
|
||||||
|
|
||||||
### Темизация
|
### Темизация
|
||||||
|
|
||||||
Общий `ui-foundation.css` подключается последним во всех пяти оболочках.
|
Общий `ui-foundation.css` подключается последним во всех пяти оболочках.
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
| Компонент | Технология |
|
| Компонент | Технология |
|
||||||
|-----------|-----------|
|
|-----------|-----------|
|
||||||
| **Backend** | Java 17, Spring Boot 3.5.16 |
|
| **Backend** | Java 17, Spring Boot 3.5.16 |
|
||||||
| **Frontend** | React 19 + переходные Vanilla JavaScript ES-модули + HTML/CSS |
|
| **Frontend** | React 19 для всех пользовательских страниц + общие ES-модули + HTML/CSS |
|
||||||
| **База данных** | PostgreSQL (через Flyway миграции) |
|
| **База данных** | PostgreSQL (через Flyway миграции) |
|
||||||
| **Контейнеризация** | Docker, Docker Compose |
|
| **Контейнеризация** | Docker, Docker Compose |
|
||||||
| **Продакшн** | Kubernetes, Caddy (реверс-прокси) |
|
| **Продакшн** | Kubernetes, Caddy (реверс-прокси) |
|
||||||
|
|||||||
@@ -4,45 +4,53 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. Стандартные одинарные списки (Custom Select Wrapper)
|
## 1. Одинарные списки на React
|
||||||
|
|
||||||
Этот компонент автоматически "оборачивает" любые стандартные теги `<select>` на всём сайте, превращая их в красивые выпадающие меню. Вам **не нужно** писать сложный HTML, всё работает автоматически!
|
В админке используется `frontend/react/shared/ui/CustomSelect.jsx`. Он сохраняет
|
||||||
|
стили `.custom-select-wrapper`, нативный `select` для формы и событие `change`,
|
||||||
|
но разметкой меню, выбранной подписью и состоянием `disabled` управляет React.
|
||||||
|
Глобальный `MutationObserver` из `admin/js/dropdown.js` больше не запускается.
|
||||||
|
Его прежнее применение к React-дереву оставляло устаревшие подписи и лишние DOM-узлы.
|
||||||
|
|
||||||
### Как добавить новый одинарный список:
|
```jsx
|
||||||
|
import { CustomSelect } from '../../shared/ui/CustomSelect.jsx';
|
||||||
|
|
||||||
Просто добавьте обычный тег `<select>` в HTML:
|
<div className="form-group">
|
||||||
|
<label htmlFor="my-new-select">Выберите опцию</label>
|
||||||
```html
|
<CustomSelect
|
||||||
<div class="form-group">
|
id="my-new-select"
|
||||||
<label for="my-new-select">Выберите опцию</label>
|
value={selectedId}
|
||||||
<select id="my-new-select">
|
disabled={loading}
|
||||||
<option value="">Выберите...</option>
|
onChange={event => setSelectedId(event.target.value)}
|
||||||
<option value="1">Опция 1</option>
|
>
|
||||||
<option value="2">Опция 2</option>
|
<option value="">Все значения</option>
|
||||||
</select>
|
{options.map(option => (
|
||||||
|
<option key={option.id} value={option.id}>{option.name}</option>
|
||||||
|
))}
|
||||||
|
</CustomSelect>
|
||||||
</div>
|
</div>
|
||||||
```
|
```
|
||||||
|
|
||||||
### Как это работает:
|
Список обновляется через `options` и `value`, без `innerHTML`. Пустое значение
|
||||||
1. В файле `frontend/admin/js/dropdown.js` инициализируется глобальный **`MutationObserver`**.
|
можно выбрать для сброса фильтра; недоступные пункты помечаются `disabled`.
|
||||||
2. Как только любой скрипт или загрузка страницы добавляет `<select>` в DOM, скрипт автоматически:
|
При количестве вариантов больше шести появляется поиск. Поддерживаются стрелки,
|
||||||
- Скрывает оригинальный `<select>` (но оставляет его доступным из JS!).
|
Home/End, Escape, закрытие снаружи и снятие обработчиков при размонтировании.
|
||||||
- Рисует поверх него красивый `div.custom-select-wrapper` с нужным текстом, иконкой-шевроном и эффектом размытия фона.
|
Для обязательного поля передайте `required`: пустое значение блокирует отправку,
|
||||||
- Синхронизирует состояния (если вы выберете элемент в кастомном UI, он автоматически изменит `select.value` и кинет событие `change`).
|
показывает «Выберите значение» и переводит фокус на видимую кнопку селекта.
|
||||||
|
Ошибка связана с кнопкой через `aria-describedby` и снимается после выбора.
|
||||||
### Динамическое обновление списка (через JS):
|
Для поиска по API используется отдельный `AsyncCombobox`.
|
||||||
Если вы подгружаете список с API, просто обновите `innerHTML` **нативного селекта**, как обычно:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const select = document.getElementById('my-new-select');
|
|
||||||
select.innerHTML = '<option value="99">Новое значение с API</option>';
|
|
||||||
```
|
|
||||||
**Магия!** Экземпляр `CustomSelect` использует свой собственный внутренний `MutationObserver` для отслеживания изменений `<option>`, поэтому он **автоматически перестроит красивый кастомный выпадающий список**. Никаких дополнительных вызовов для перерисовки не требуется.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. Множественный выбор (Multi-Select с чекбоксами)
|
## 2. Множественный выбор (Multi-Select с чекбоксами)
|
||||||
|
|
||||||
|
На React-страницах меню и закрытие снаружи управляются состоянием компонентов
|
||||||
|
(`EquipmentMultiSelect` в `ClassroomsTab.jsx`, `WorkloadMultiSelect` в
|
||||||
|
`AuditoriumWorkloadTab.jsx` и аналогичные компоненты остальных вкладок).
|
||||||
|
Глобальные `initMultiSelect` и `closeAllDropdownsOnOutsideClick` к React-дереву
|
||||||
|
не применяются. HTML-примеры ниже описывают прежний контракт CSS-классов;
|
||||||
|
новые элементы следует создавать в JSX и управлять ими через React-состояние.
|
||||||
|
|
||||||
Этот UI-компонент позволяет выбирать сразу несколько элементов из выпадающего списка. Он включает в себя кастомные красивые галочки (checkmarks) с неоновой подсветкой и кастомный скроллбар.
|
Этот UI-компонент позволяет выбирать сразу несколько элементов из выпадающего списка. Он включает в себя кастомные красивые галочки (checkmarks) с неоновой подсветкой и кастомный скроллбар.
|
||||||
|
|
||||||
Этот компонент требует написания определённой HTML-структуры, так как нативного тега `select multiple` с похожей функциональностью не существует.
|
Этот компонент требует написания определённой HTML-структуры, так как нативного тега `select multiple` с похожей функциональностью не существует.
|
||||||
|
|||||||
@@ -51,15 +51,13 @@
|
|||||||
/* ===== Cards ===== */
|
/* ===== Cards ===== */
|
||||||
.card {
|
.card {
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
backdrop-filter: blur(20px);
|
|
||||||
-webkit-backdrop-filter: blur(20px);
|
|
||||||
border: 1px solid var(--bg-card-border);
|
border: 1px solid var(--bg-card-border);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
padding: 1.75rem;
|
padding: 1.75rem;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
transition: all 0.4s cubic-bezier(0.25, 0.8, 0.25, 1);
|
/* Непрозрачная поверхность не требует фильтрации и трансформации фонового слоя. */
|
||||||
animation: slideUpCard 0.5s cubic-bezier(0.25, 0.8, 0.25, 1) both;
|
transition: background-color var(--transition), border-color var(--transition), box-shadow var(--transition);
|
||||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05);
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2870,6 +2868,51 @@ input[type="number"] {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* React-селекты не создают соседние DOM-узлы через MutationObserver. */
|
||||||
|
.react-custom-select {
|
||||||
|
position: relative;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-custom-select select[hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:has(.react-custom-select.select-layer-active),
|
||||||
|
.settings-card:has(.react-custom-select.select-layer-active) {
|
||||||
|
position: relative;
|
||||||
|
z-index: 10001;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-custom-select .custom-select-trigger {
|
||||||
|
font-family: inherit;
|
||||||
|
text-align: left;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-custom-select .custom-select-item {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
font-family: inherit;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-custom-select .custom-select-item.selected {
|
||||||
|
background: var(--accent-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] .react-custom-select .custom-select-item.selected {
|
||||||
|
background: rgba(99, 102, 241, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-select-options {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.custom-select-icon {
|
.custom-select-icon {
|
||||||
margin-left: 0.75rem;
|
margin-left: 0.75rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -3941,8 +3984,6 @@ tbody tr:hover {
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
backdrop-filter: blur(12px);
|
|
||||||
-webkit-backdrop-filter: blur(12px);
|
|
||||||
padding: 0;
|
padding: 0;
|
||||||
transition: background var(--transition), border-color var(--transition), color var(--transition), box-shadow var(--transition), transform var(--transition);
|
transition: background var(--transition), border-color var(--transition), color var(--transition), box-shadow var(--transition), transform var(--transition);
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
@@ -4187,9 +4228,9 @@ tbody tr:hover {
|
|||||||
.project-dialog-field textarea { width: 100%; resize: vertical; }
|
.project-dialog-field textarea { width: 100%; resize: vertical; }
|
||||||
.project-dialog-error { min-height: 1.3rem; margin-top: 0.35rem; color: var(--danger, #dc2626); font-size: 0.85rem; }
|
.project-dialog-error { min-height: 1.3rem; margin-top: 0.35rem; color: var(--danger, #dc2626); font-size: 0.85rem; }
|
||||||
|
|
||||||
/* ===== Êëàññû ýòàïà 5 (âîëíà 1): çàìåíà inline-ñòèëåé ïåðåíåñ¸ííûõ âêëàäîê ===== */
|
/* ===== Классы этапа 5 (волна 1): замена inline-стилей перенесённых вкладок ===== */
|
||||||
|
|
||||||
/* Çàãîëîâîê êàðòî÷êè ñ äåéñòâèåì ñïðàâà (àóäèòîðèè) */
|
/* Заголовок карточки с действием справа (аудитории) */
|
||||||
.card-toolbar {
|
.card-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -4203,7 +4244,7 @@ tbody tr:hover {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Ñòðîêè è ãðóïïû ôîðì */
|
/* Строки и группы форм */
|
||||||
.form-row--spacing {
|
.form-row--spacing {
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
@@ -4234,7 +4275,7 @@ tbody tr:hover {
|
|||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ß÷åéêè òàáëèö */
|
/* Ячейки таблиц */
|
||||||
.th-actions {
|
.th-actions {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
@@ -4258,7 +4299,7 @@ tbody tr:hover {
|
|||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Ìîäàëüíûå îêíà ïåðåíåñ¸ííûõ âêëàäîê */
|
/* Модальные окна перенесённых вкладок */
|
||||||
.modal-content--medium {
|
.modal-content--medium {
|
||||||
max-width: 600px;
|
max-width: 600px;
|
||||||
width: 90%;
|
width: 90%;
|
||||||
@@ -4287,7 +4328,7 @@ tbody tr:hover {
|
|||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Ñîñòîÿíèå çàãðóçêè lazy-âêëàäêè */
|
/* Состояние загрузки lazy-вкладки */
|
||||||
.tab-loading {
|
.tab-loading {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -4297,7 +4338,7 @@ tbody tr:hover {
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Äàøáîðä: ìåòðèêè è ìîíèòîðèíã */
|
/* Дашборд: метрики и мониторинг */
|
||||||
.dashboard-metric-label {
|
.dashboard-metric-label {
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
@@ -4350,7 +4391,7 @@ tbody tr:hover {
|
|||||||
border: 1px solid rgba(16, 185, 129, 0.2);
|
border: 1px solid rgba(16, 185, 129, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Äàøáîðä: öåíòð ïðåäóïðåæäåíèé (Red Zone) */
|
/* Дашборд: центр предупреждений (Red Zone) */
|
||||||
.red-zone-card {
|
.red-zone-card {
|
||||||
border-top: 4px solid var(--error, #ef4444);
|
border-top: 4px solid var(--error, #ef4444);
|
||||||
}
|
}
|
||||||
@@ -4390,9 +4431,9 @@ tbody tr:hover {
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== Ýòàï 5 (âîëíà 2): êëàññû inline-ñòèëåé ïåðåíåñ¸ííûõ âêëàäîê ===== */
|
/* ===== Этап 5 (волна 2): классы inline-стилей перенесённых вкладок ===== */
|
||||||
|
|
||||||
/* Øèðîêèå ìîäàëüíûå îêíà (ïîäãðóïïû, ïðîôèëè, êàëåíäàðíûå ãðàôèêè) */
|
/* Широкие модальные окна (подгруппы, профили, календарные графики) */
|
||||||
.modal-content--wide {
|
.modal-content--wide {
|
||||||
max-width: 700px;
|
max-width: 700px;
|
||||||
width: 90%;
|
width: 90%;
|
||||||
@@ -4408,7 +4449,7 @@ tbody tr:hover {
|
|||||||
width: 90%;
|
width: 90%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Ôîðìà ïðîôèëÿ íà âêëàäêå ñòðóêòóðû âóçà */
|
/* Форма профиля на вкладке структуры вуза */
|
||||||
.profile-form-spec {
|
.profile-form-spec {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 200px;
|
min-width: 200px;
|
||||||
@@ -4424,13 +4465,13 @@ tbody tr:hover {
|
|||||||
min-width: 250px;
|
min-width: 250px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Ðÿä êíîïîê ôîðìû, âûðîâíåííûõ ïî íèæíåé êðîìêå */
|
/* Ряд кнопок формы, выровненных по нижней кромке */
|
||||||
.form-actions-row {
|
.form-actions-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
}
|
}
|
||||||
/* Âîëíà 3 (ýòàï 5): çàìåíà inline-ñòèëÿ èç legacy department-workspace.html */
|
/* Волна 3 (этап 5): замена inline-стиля из legacy department-workspace.html */
|
||||||
.schedule-section-label--flush {
|
.schedule-section-label--flush {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,8 @@
|
|||||||
/* ===== Sidebar ===== */
|
/* ===== Sidebar ===== */
|
||||||
.sidebar {
|
.sidebar {
|
||||||
width: 260px;
|
width: 260px;
|
||||||
min-height: 100vh;
|
min-height: 0;
|
||||||
background: var(--bg-sidebar);
|
background: var(--bg-sidebar);
|
||||||
backdrop-filter: blur(20px);
|
|
||||||
-webkit-backdrop-filter: blur(20px);
|
|
||||||
border-right: 1px solid var(--bg-card-border);
|
border-right: 1px solid var(--bg-card-border);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -12,7 +10,7 @@
|
|||||||
left: 0;
|
left: 0;
|
||||||
top: 0;
|
top: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
z-index: 1000;
|
z-index: 11001;
|
||||||
transition: width 0.4s cubic-bezier(0.25, 0.8, 0.25, 1), background 0.4s ease, border-color 0.4s ease, transform 0.4s cubic-bezier(0.25, 0.8, 0.25, 1);
|
transition: width 0.4s cubic-bezier(0.25, 0.8, 0.25, 1), background 0.4s ease, border-color 0.4s ease, transform 0.4s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,9 +43,19 @@
|
|||||||
|
|
||||||
.sidebar-nav {
|
.sidebar-nav {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar:not(.collapsed) .sidebar-nav {
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header,
|
||||||
|
.sidebar-footer {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.nav-item {
|
.nav-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -90,7 +98,6 @@
|
|||||||
.nav-item:hover {
|
.nav-item:hover {
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
transform: translateX(4px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item.active {
|
.nav-item.active {
|
||||||
@@ -335,7 +342,7 @@
|
|||||||
transform: translateY(-50%) translateX(0);
|
transform: translateY(-50%) translateX(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar.collapsed .sidebar-close-btn {
|
.sidebar.collapsed .sidebar-close-btn svg {
|
||||||
transform: rotate(180deg);
|
transform: rotate(180deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -375,7 +382,7 @@
|
|||||||
margin-left: 74px;
|
margin-left: 74px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.main.sidebar-collapsed .menu-toggle {
|
.main.sidebar-collapsed .menu-toggle.btn {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -441,7 +448,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ===== Mobile Menu Toggle ===== */
|
/* ===== Mobile Menu Toggle ===== */
|
||||||
.menu-toggle {
|
.menu-toggle.btn {
|
||||||
display: none;
|
display: none;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
transition: background var(--transition);
|
transition: background var(--transition);
|
||||||
@@ -460,7 +467,7 @@
|
|||||||
bottom: 0;
|
bottom: 0;
|
||||||
background: rgba(0, 0, 0, 0.5);
|
background: rgba(0, 0, 0, 0.5);
|
||||||
backdrop-filter: blur(2px);
|
backdrop-filter: blur(2px);
|
||||||
z-index: 9;
|
z-index: 11000;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
@@ -489,7 +496,10 @@
|
|||||||
padding: 1.25rem;
|
padding: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-toggle,
|
.menu-toggle.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar-overlay {
|
.sidebar-overlay {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,8 @@
|
|||||||
:root {
|
:root {
|
||||||
/* Deep dark premium background */
|
/* Deep dark premium background */
|
||||||
--bg-primary: #0a0a0f;
|
--bg-primary: #0a0a0f;
|
||||||
--bg-sidebar: rgba(255, 255, 255, 0.02);
|
--bg-sidebar: #0f0f14;
|
||||||
--bg-card: rgba(255, 255, 255, 0.03);
|
--bg-card: #111116;
|
||||||
--bg-card-border: rgba(255, 255, 255, 0.05);
|
--bg-card-border: rgba(255, 255, 255, 0.05);
|
||||||
--bg-input: rgba(255, 255, 255, 0.04);
|
--bg-input: rgba(255, 255, 255, 0.04);
|
||||||
--bg-input-focus: rgba(255, 255, 255, 0.08);
|
--bg-input-focus: rgba(255, 255, 255, 0.08);
|
||||||
@@ -42,8 +42,8 @@
|
|||||||
/* ===== Light Theme ===== */
|
/* ===== Light Theme ===== */
|
||||||
[data-theme="light"] {
|
[data-theme="light"] {
|
||||||
--bg-primary: #f8fafc;
|
--bg-primary: #f8fafc;
|
||||||
--bg-sidebar: rgba(255, 255, 255, 0.7);
|
--bg-sidebar: #fdfefe;
|
||||||
--bg-card: rgba(255, 255, 255, 0.75);
|
--bg-card: #fdfefe;
|
||||||
--bg-card-border: rgba(0, 0, 0, 0.08);
|
--bg-card-border: rgba(0, 0, 0, 0.08);
|
||||||
--bg-input: rgba(0, 0, 0, 0.03);
|
--bg-input: rgba(0, 0, 0, 0.03);
|
||||||
--bg-input-focus: rgba(0, 0, 0, 0.06);
|
--bg-input-focus: rgba(0, 0, 0, 0.06);
|
||||||
|
|||||||
@@ -11,10 +11,8 @@
|
|||||||
/* ===== Sidebar ===== */
|
/* ===== Sidebar ===== */
|
||||||
.sidebar {
|
.sidebar {
|
||||||
width: 260px;
|
width: 260px;
|
||||||
min-height: 100vh;
|
min-height: 0;
|
||||||
background: var(--bg-sidebar);
|
background: var(--bg-sidebar);
|
||||||
backdrop-filter: blur(20px);
|
|
||||||
-webkit-backdrop-filter: blur(20px);
|
|
||||||
border-right: 1px solid var(--bg-card-border);
|
border-right: 1px solid var(--bg-card-border);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -22,7 +20,7 @@
|
|||||||
left: 0;
|
left: 0;
|
||||||
top: 0;
|
top: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
z-index: 1000;
|
z-index: 11001;
|
||||||
transition: width 0.4s cubic-bezier(0.25, 0.8, 0.25, 1), background 0.4s ease, border-color 0.4s ease, transform 0.4s cubic-bezier(0.25, 0.8, 0.25, 1);
|
transition: width 0.4s cubic-bezier(0.25, 0.8, 0.25, 1), background 0.4s ease, border-color 0.4s ease, transform 0.4s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,6 +56,20 @@
|
|||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar-nav-container {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar:not(.collapsed) .sidebar-nav-container {
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header,
|
||||||
|
.sidebar-footer {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.nav-item {
|
.nav-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -95,7 +107,6 @@
|
|||||||
.nav-item:hover {
|
.nav-item:hover {
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
transform: translateX(4px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item.active {
|
.nav-item.active {
|
||||||
@@ -204,7 +215,7 @@
|
|||||||
transform: translateY(-50%) translateX(0);
|
transform: translateY(-50%) translateX(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar.collapsed .sidebar-close-btn {
|
.sidebar.collapsed .sidebar-close-btn svg {
|
||||||
transform: rotate(180deg);
|
transform: rotate(180deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,7 +245,7 @@
|
|||||||
margin-left: 74px;
|
margin-left: 74px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.main.sidebar-collapsed .menu-toggle {
|
.main.sidebar-collapsed .menu-toggle.btn {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -265,7 +276,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ===== Mobile Menu Toggle ===== */
|
/* ===== Mobile Menu Toggle ===== */
|
||||||
.menu-toggle {
|
.menu-toggle.btn {
|
||||||
display: none;
|
display: none;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
transition: background var(--transition);
|
transition: background var(--transition);
|
||||||
@@ -284,7 +295,7 @@
|
|||||||
bottom: 0;
|
bottom: 0;
|
||||||
background: rgba(0, 0, 0, 0.5);
|
background: rgba(0, 0, 0, 0.5);
|
||||||
backdrop-filter: blur(2px);
|
backdrop-filter: blur(2px);
|
||||||
z-index: 9;
|
z-index: 11000;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
@@ -313,7 +324,10 @@
|
|||||||
padding: 1.25rem;
|
padding: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-toggle,
|
.menu-toggle.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar-overlay {
|
.sidebar-overlay {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,8 @@
|
|||||||
|
|
||||||
:root {
|
:root {
|
||||||
--bg-primary: #0a0a0f;
|
--bg-primary: #0a0a0f;
|
||||||
--bg-sidebar: rgba(255, 255, 255, 0.02);
|
--bg-sidebar: #0f0f14;
|
||||||
--bg-card: rgba(255, 255, 255, 0.03);
|
--bg-card: #111116;
|
||||||
--bg-card-border: rgba(255, 255, 255, 0.05);
|
--bg-card-border: rgba(255, 255, 255, 0.05);
|
||||||
--bg-input: rgba(255, 255, 255, 0.04);
|
--bg-input: rgba(255, 255, 255, 0.04);
|
||||||
--bg-input-focus: rgba(255, 255, 255, 0.08);
|
--bg-input-focus: rgba(255, 255, 255, 0.08);
|
||||||
@@ -37,8 +37,8 @@
|
|||||||
/* ===== Светлая тема ===== */
|
/* ===== Светлая тема ===== */
|
||||||
[data-theme="light"] {
|
[data-theme="light"] {
|
||||||
--bg-primary: #f8fafc;
|
--bg-primary: #f8fafc;
|
||||||
--bg-sidebar: rgba(255, 255, 255, 0.7);
|
--bg-sidebar: #fdfefe;
|
||||||
--bg-card: rgba(255, 255, 255, 0.75);
|
--bg-card: #fdfefe;
|
||||||
--bg-card-border: rgba(0, 0, 0, 0.08);
|
--bg-card-border: rgba(0, 0, 0, 0.08);
|
||||||
--bg-input: rgba(0, 0, 0, 0.03);
|
--bg-input: rgba(0, 0, 0, 0.03);
|
||||||
--bg-input-focus: rgba(0, 0, 0, 0.06);
|
--bg-input-focus: rgba(0, 0, 0, 0.06);
|
||||||
@@ -86,8 +86,6 @@ body {
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
backdrop-filter: blur(12px);
|
|
||||||
-webkit-backdrop-filter: blur(12px);
|
|
||||||
padding: 0;
|
padding: 0;
|
||||||
transition: background var(--transition), border-color var(--transition), color var(--transition), box-shadow var(--transition), transform var(--transition);
|
transition: background var(--transition), border-color var(--transition), color var(--transition), box-shadow var(--transition), transform var(--transition);
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
@@ -206,8 +204,6 @@ body {
|
|||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
backdrop-filter: blur(16px);
|
|
||||||
-webkit-backdrop-filter: blur(16px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-card-compact {
|
.settings-card-compact {
|
||||||
|
|||||||
48
frontend/package-lock.json
generated
48
frontend/package-lock.json
generated
@@ -20,6 +20,7 @@
|
|||||||
"@opentelemetry/sdk-trace-web": "2.9.0",
|
"@opentelemetry/sdk-trace-web": "2.9.0",
|
||||||
"@opentelemetry/semantic-conventions": "1.43.0",
|
"@opentelemetry/semantic-conventions": "1.43.0",
|
||||||
"esbuild": "0.25.12",
|
"esbuild": "0.25.12",
|
||||||
|
"playwright": "1.55.1",
|
||||||
"zone.js": "0.16.0"
|
"zone.js": "0.16.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -899,6 +900,21 @@
|
|||||||
"@esbuild/win32-x64": "0.25.12"
|
"@esbuild/win32-x64": "0.25.12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/import-in-the-middle": {
|
"node_modules/import-in-the-middle": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-3.3.1.tgz",
|
||||||
@@ -928,6 +944,38 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.55.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.1.tgz",
|
||||||
|
"integrity": "sha512-cJW4Xd/G3v5ovXtJJ52MAOclqeac9S/aGGgRzLabuF8TnIb6xHvMzKIa6JmrRzUkeXJgfL1MhukP0NK6l39h3A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.55.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.55.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.1.tgz",
|
||||||
|
"integrity": "sha512-Z6Mh9mkwX+zxSlHqdr5AOcJnfp+xUWLCt9uKV18fhzA8eyxUd8NUWzAjxUh55RZKSYwDGX0cfaySdhZJGMoJ+w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react": {
|
"node_modules/react": {
|
||||||
"version": "19.3.0",
|
"version": "19.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz",
|
||||||
|
|||||||
@@ -11,7 +11,8 @@
|
|||||||
"build:react-tests": "node scripts/build-react-tests.mjs",
|
"build:react-tests": "node scripts/build-react-tests.mjs",
|
||||||
"build:vendor": "node scripts/build-vendor.mjs",
|
"build:vendor": "node scripts/build-vendor.mjs",
|
||||||
"test": "npm run build:react-tests && node --test tests/*.test.mjs",
|
"test": "npm run build:react-tests && node --test tests/*.test.mjs",
|
||||||
"check": "npm run build:react && node --check auth-session.js && node --check telemetry.js && node --check schedule-overview.js && node --check react/login/login-service.js && node --check react/shared/api-client.js && node --check react/shared/auth-client.js && node --check react/shared/session-policy.js && node --check react/student/student-service.js && node --check react/teacher/teacher-service.js && node --check react/settings/settings-service.js && node --check react/admin/admin-service.js && node --check react/admin/react-tabs.js && node --check react/admin/classrooms-service.js && node --check react/admin/subjects-service.js && node --check react/admin/dashboard-service.js && node --check react/admin/users-service.js && node --check react/admin/groups-service.js && node --check react/admin/university-structure-service.js && node --check react/admin/teacher-requests-service.js && node --check react/admin/department-workspace-service.js && node --check react/admin/auditorium-workload-service.js && node --check react/admin/academic-calendar-service.js && node --check react/admin/schedule-view-service.js && node --check react/admin/schedule-versions-service.js && node --check react/admin/schedule-service.js && node --check react/admin/schedule-override-service.js && node --check react/admin/schedule-quality-service.js && node --check react/admin/teacher-absences-service.js && node --check admin/js/api.js && node --check admin/js/academic-period.js && node --check admin/js/dialog.js && node --check admin/js/dirty-state.js && node --check admin/js/url-state.js && node --check admin/js/view-state.js && npm test"
|
"check": "npm run build:react && node --check auth-session.js && node --check telemetry.js && node --check schedule-overview.js && node --check react/login/login-service.js && node --check react/shared/api-client.js && node --check react/shared/auth-client.js && node --check react/shared/session-policy.js && node --check react/student/student-service.js && node --check react/teacher/teacher-service.js && node --check react/settings/settings-service.js && node --check react/admin/admin-service.js && node --check react/admin/react-tabs.js && node --check react/admin/classrooms-service.js && node --check react/admin/subjects-service.js && node --check react/admin/dashboard-service.js && node --check react/admin/users-service.js && node --check react/admin/groups-service.js && node --check react/admin/university-structure-service.js && node --check react/admin/teacher-requests-service.js && node --check react/admin/department-workspace-service.js && node --check react/admin/auditorium-workload-service.js && node --check react/admin/academic-calendar-service.js && node --check react/admin/schedule-view-service.js && node --check react/admin/schedule-versions-service.js && node --check react/admin/schedule-service.js && node --check react/admin/schedule-override-service.js && node --check react/admin/schedule-quality-service.js && node --check react/admin/teacher-absences-service.js && node --check admin/js/api.js && node --check admin/js/academic-period.js && node --check admin/js/dialog.js && node --check admin/js/dirty-state.js && node --check admin/js/url-state.js && node --check admin/js/view-state.js && npm test",
|
||||||
|
"check:browser": "node --test tests/browser/*.test.mjs"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@opentelemetry/api": "1.9.0",
|
"@opentelemetry/api": "1.9.0",
|
||||||
@@ -24,6 +25,7 @@
|
|||||||
"@opentelemetry/sdk-trace-web": "2.9.0",
|
"@opentelemetry/sdk-trace-web": "2.9.0",
|
||||||
"@opentelemetry/semantic-conventions": "1.43.0",
|
"@opentelemetry/semantic-conventions": "1.43.0",
|
||||||
"esbuild": "0.25.12",
|
"esbuild": "0.25.12",
|
||||||
|
"playwright": "1.55.1",
|
||||||
"zone.js": "0.16.0"
|
"zone.js": "0.16.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { Suspense, useEffect, useRef, useState } from 'react';
|
import { Suspense, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { ADMIN_APP_ROLES, SETTINGS_ROLES } from '../../admin/js/role-capabilities.js';
|
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 { confirmAbandonDirtyChanges } from '../../admin/js/dirty-state.js';
|
||||||
import { trackUxEvent } from '../../telemetry.js';
|
import { trackUxEvent } from '../../telemetry.js';
|
||||||
import { logoutAndRedirect } from '../shared/auth-client.js';
|
import { logoutAndRedirect } from '../shared/auth-client.js';
|
||||||
import { SESSION_PHASES } from '../shared/session-policy.js';
|
import { SESSION_PHASES } from '../shared/session-policy.js';
|
||||||
import { useAuthorizedSession } from '../shared/use-authorized-session.js';
|
import { useAuthorizedSession } from '../shared/use-authorized-session.js';
|
||||||
import { AsyncState } from '../shared/ui/AsyncState.jsx';
|
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 { REACT_TABS } from './react-tabs.js';
|
||||||
import {
|
import {
|
||||||
ADMIN_ROUTES,
|
ADMIN_ROUTES,
|
||||||
@@ -24,7 +24,6 @@ import {
|
|||||||
} from './admin-service.js';
|
} from './admin-service.js';
|
||||||
|
|
||||||
const THEME_STORAGE_KEY = 'theme';
|
const THEME_STORAGE_KEY = 'theme';
|
||||||
const SIDEBAR_STORAGE_KEY = 'sidebar-collapsed';
|
|
||||||
|
|
||||||
const ICONS = Object.freeze({
|
const ICONS = Object.freeze({
|
||||||
dashboard: (
|
dashboard: (
|
||||||
@@ -185,22 +184,11 @@ export function AdminWorkspace({ role }) {
|
|||||||
: tabFromHash(window.location.hash, role)
|
: tabFromHash(window.location.hash, role)
|
||||||
));
|
));
|
||||||
const activeTabRef = useRef(activeTab);
|
const activeTabRef = useRef(activeTab);
|
||||||
const [collapsed, setCollapsed] = useState(() => (
|
const { collapsed, mobileOpen, toggleSidebar, closeSidebar } = useSidebar();
|
||||||
typeof window !== 'undefined'
|
|
||||||
&& window.innerWidth > 768
|
|
||||||
&& localStorage.getItem(SIDEBAR_STORAGE_KEY) === 'true'
|
|
||||||
));
|
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
const [pendingCount, setPendingCount] = useState(null);
|
const [pendingCount, setPendingCount] = useState(null);
|
||||||
const settingsRef = useRef(null);
|
const settingsRef = useRef(null);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
applyRippleEffect();
|
|
||||||
closeAllDropdownsOnOutsideClick();
|
|
||||||
initAllCustomDropdowns(document.body);
|
|
||||||
startDropdownAutoObserver();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.documentElement.dataset.theme = localStorage.getItem(THEME_STORAGE_KEY) || 'light';
|
document.documentElement.dataset.theme = localStorage.getItem(THEME_STORAGE_KEY) || 'light';
|
||||||
}, []);
|
}, []);
|
||||||
@@ -255,25 +243,22 @@ export function AdminWorkspace({ role }) {
|
|||||||
}, [role]);
|
}, [role]);
|
||||||
|
|
||||||
async function requestTabSwitch(tab) {
|
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;
|
if (activeTabRef.current && !(await confirmAbandonDirtyChanges())) return;
|
||||||
|
|
||||||
activeTabRef.current = tab;
|
activeTabRef.current = tab;
|
||||||
setActiveTab(tab);
|
setActiveTab(tab);
|
||||||
|
closeSidebar();
|
||||||
trackUxEvent('раздел_открыт', { tab, role });
|
trackUxEvent('раздел_открыт', { tab, role });
|
||||||
if (window.location.hash !== `#${tab}`) {
|
if (window.location.hash !== `#${tab}`) {
|
||||||
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() {
|
function toggleTheme() {
|
||||||
const next = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';
|
const next = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';
|
||||||
document.documentElement.dataset.theme = next;
|
document.documentElement.dataset.theme = next;
|
||||||
@@ -291,7 +276,7 @@ export function AdminWorkspace({ role }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<aside className={`sidebar${collapsed ? ' collapsed' : ''}`}>
|
<aside id="app-sidebar" className={`sidebar${collapsed && !mobileOpen ? ' collapsed' : ''}${mobileOpen ? ' open' : ''}`}>
|
||||||
<div className="sidebar-header">
|
<div className="sidebar-header">
|
||||||
<div className="logo">
|
<div className="logo">
|
||||||
<svg width="32" height="32" viewBox="0 0 40 40" fill="none">
|
<svg width="32" height="32" viewBox="0 0 40 40" fill="none">
|
||||||
@@ -332,6 +317,7 @@ export function AdminWorkspace({ role }) {
|
|||||||
<a
|
<a
|
||||||
key={item.id}
|
key={item.id}
|
||||||
href={`#${item.id}`}
|
href={`#${item.id}`}
|
||||||
|
title={collapsed ? item.label : undefined}
|
||||||
className={`nav-item${item.id === activeTab ? ' active' : ''}`}
|
className={`nav-item${item.id === activeTab ? ' active' : ''}`}
|
||||||
data-tab={item.id}
|
data-tab={item.id}
|
||||||
onClick={event => {
|
onClick={event => {
|
||||||
@@ -406,10 +392,11 @@ export function AdminWorkspace({ role }) {
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<div className="sidebar-overlay" />
|
<div className={`sidebar-overlay${mobileOpen ? ' open' : ''}`} onClick={closeSidebar} />
|
||||||
|
|
||||||
<main className={`main${collapsed ? ' sidebar-collapsed' : ''}`}>
|
<main className={`main${collapsed ? ' sidebar-collapsed' : ''}`}>
|
||||||
<header className="topbar">
|
<header className="topbar">
|
||||||
|
<SidebarToggle open={mobileOpen} onClick={toggleSidebar} />
|
||||||
<div className="topbar-title-group">
|
<div className="topbar-title-group">
|
||||||
<h1 id="page-title">{title}</h1>
|
<h1 id="page-title">{title}</h1>
|
||||||
{badge && !badge.hidden && (
|
{badge && !badge.hidden && (
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { CustomSelect } from '../../shared/ui/CustomSelect.jsx';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
import { confirmAbandonDirtyChanges, createDirtyScope } from '../../../admin/js/dirty-state.js';
|
import { confirmAbandonDirtyChanges, createDirtyScope } from '../../../admin/js/dirty-state.js';
|
||||||
@@ -237,17 +238,17 @@ function CalendarDayDialog({
|
|||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="calendar-day-dialog-activity">Код активности</label>
|
<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>
|
<option value="">Выберите</option>
|
||||||
{activityTypes.map(type => (
|
{activityTypes.map(type => (
|
||||||
<option key={type.id} value={type.id}>{`${type.code} - ${type.name}`}</option>
|
<option key={type.id} value={type.id}>{`${type.code} - ${type.name}`}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</CustomSelect>
|
||||||
</div>
|
</div>
|
||||||
<div className="calendar-day-dialog-time-grid">
|
<div className="calendar-day-dialog-time-grid">
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="calendar-day-dialog-time-scope">Временная сетка даты</label>
|
<label htmlFor="calendar-day-dialog-time-scope">Временная сетка даты</label>
|
||||||
<select
|
<CustomSelect
|
||||||
id="calendar-day-dialog-time-scope"
|
id="calendar-day-dialog-time-scope"
|
||||||
value={scopeValue}
|
value={scopeValue}
|
||||||
disabled={!isSingle}
|
disabled={!isSingle}
|
||||||
@@ -263,7 +264,7 @@ function CalendarDayDialog({
|
|||||||
) : (
|
) : (
|
||||||
<option value="">Для выделения доступен только код дня</option>
|
<option value="">Для выделения доступен только код дня</option>
|
||||||
)}
|
)}
|
||||||
</select>
|
</CustomSelect>
|
||||||
</div>
|
</div>
|
||||||
<div className="calendar-day-dialog-actions">
|
<div className="calendar-day-dialog-actions">
|
||||||
<button
|
<button
|
||||||
@@ -1447,7 +1448,7 @@ export function AcademicCalendarTab() {
|
|||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="academic-calendar-year">Учебный год</label>
|
<label htmlFor="academic-calendar-year">Учебный год</label>
|
||||||
<select
|
<CustomSelect
|
||||||
id="academic-calendar-year"
|
id="academic-calendar-year"
|
||||||
required
|
required
|
||||||
value={calendarForm.yearId}
|
value={calendarForm.yearId}
|
||||||
@@ -1466,11 +1467,11 @@ export function AcademicCalendarTab() {
|
|||||||
) : (
|
) : (
|
||||||
<option value="">Загрузка...</option>
|
<option value="">Загрузка...</option>
|
||||||
)}
|
)}
|
||||||
</select>
|
</CustomSelect>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="academic-calendar-specialty">Специальность</label>
|
<label htmlFor="academic-calendar-specialty">Специальность</label>
|
||||||
<select
|
<CustomSelect
|
||||||
id="academic-calendar-specialty"
|
id="academic-calendar-specialty"
|
||||||
required
|
required
|
||||||
value={calendarForm.specialtyId}
|
value={calendarForm.specialtyId}
|
||||||
@@ -1492,11 +1493,11 @@ export function AcademicCalendarTab() {
|
|||||||
) : (
|
) : (
|
||||||
<option value="">Загрузка...</option>
|
<option value="">Загрузка...</option>
|
||||||
)}
|
)}
|
||||||
</select>
|
</CustomSelect>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="academic-calendar-profile">Профиль</label>
|
<label htmlFor="academic-calendar-profile">Профиль</label>
|
||||||
<select
|
<CustomSelect
|
||||||
id="academic-calendar-profile"
|
id="academic-calendar-profile"
|
||||||
required
|
required
|
||||||
value={calendarForm.profileId}
|
value={calendarForm.profileId}
|
||||||
@@ -1515,11 +1516,11 @@ export function AcademicCalendarTab() {
|
|||||||
) : (
|
) : (
|
||||||
<option value="">{profileEmptyLabel}</option>
|
<option value="">{profileEmptyLabel}</option>
|
||||||
)}
|
)}
|
||||||
</select>
|
</CustomSelect>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="academic-calendar-study-form">Форма обучения</label>
|
<label htmlFor="academic-calendar-study-form">Форма обучения</label>
|
||||||
<select
|
<CustomSelect
|
||||||
id="academic-calendar-study-form"
|
id="academic-calendar-study-form"
|
||||||
required
|
required
|
||||||
value={calendarForm.studyFormId}
|
value={calendarForm.studyFormId}
|
||||||
@@ -1538,7 +1539,7 @@ export function AcademicCalendarTab() {
|
|||||||
) : (
|
) : (
|
||||||
<option value="">Загрузка...</option>
|
<option value="">Загрузка...</option>
|
||||||
)}
|
)}
|
||||||
</select>
|
</CustomSelect>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="academic-calendar-course-count">Курсов</label>
|
<label htmlFor="academic-calendar-course-count">Курсов</label>
|
||||||
@@ -1590,7 +1591,7 @@ export function AcademicCalendarTab() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="academic-calendars-year-filter">Учебный год</label>
|
<label htmlFor="academic-calendars-year-filter">Учебный год</label>
|
||||||
<select
|
<CustomSelect
|
||||||
id="academic-calendars-year-filter"
|
id="academic-calendars-year-filter"
|
||||||
value={calendarYearFilter}
|
value={calendarYearFilter}
|
||||||
onChange={event => {
|
onChange={event => {
|
||||||
@@ -1604,7 +1605,7 @@ export function AcademicCalendarTab() {
|
|||||||
{academicYears.map(year => (
|
{academicYears.map(year => (
|
||||||
<option key={year.id} value={year.id}>{year.title}</option>
|
<option key={year.id} value={year.id}>{year.title}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</CustomSelect>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="table-wrap">
|
<div className="table-wrap">
|
||||||
@@ -1899,7 +1900,7 @@ export function AcademicCalendarTab() {
|
|||||||
<div className="calendar-fill-panel">
|
<div className="calendar-fill-panel">
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="calendar-fill-course">Курс</label>
|
<label htmlFor="calendar-fill-course">Курс</label>
|
||||||
<select
|
<CustomSelect
|
||||||
id="calendar-fill-course"
|
id="calendar-fill-course"
|
||||||
value={fillForm.course}
|
value={fillForm.course}
|
||||||
onChange={event => setFillForm(current => ({
|
onChange={event => setFillForm(current => ({
|
||||||
@@ -1911,7 +1912,7 @@ export function AcademicCalendarTab() {
|
|||||||
{fillCourseOptions.map(course => (
|
{fillCourseOptions.map(course => (
|
||||||
<option key={course} value={course}>{course} курс</option>
|
<option key={course} value={course}>{course} курс</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</CustomSelect>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="calendar-fill-start">С даты</label>
|
<label htmlFor="calendar-fill-start">С даты</label>
|
||||||
@@ -1931,7 +1932,7 @@ export function AcademicCalendarTab() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="calendar-fill-activity">Код</label>
|
<label htmlFor="calendar-fill-activity">Код</label>
|
||||||
<select
|
<CustomSelect
|
||||||
id="calendar-fill-activity"
|
id="calendar-fill-activity"
|
||||||
value={fillForm.activityId}
|
value={fillForm.activityId}
|
||||||
onChange={event => setFillForm(current => ({
|
onChange={event => setFillForm(current => ({
|
||||||
@@ -1951,7 +1952,7 @@ export function AcademicCalendarTab() {
|
|||||||
) : (
|
) : (
|
||||||
<option value="">Загрузка...</option>
|
<option value="">Загрузка...</option>
|
||||||
)}
|
)}
|
||||||
</select>
|
</CustomSelect>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -2053,7 +2054,7 @@ export function AcademicCalendarTab() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="calendar-subject-semester">Семестр</label>
|
<label htmlFor="calendar-subject-semester">Семестр</label>
|
||||||
<select
|
<CustomSelect
|
||||||
id="calendar-subject-semester"
|
id="calendar-subject-semester"
|
||||||
value={subjectSemester}
|
value={subjectSemester}
|
||||||
onChange={event => setSubjectSemester(event.target.value)}
|
onChange={event => setSubjectSemester(event.target.value)}
|
||||||
@@ -2070,11 +2071,11 @@ export function AcademicCalendarTab() {
|
|||||||
) : (
|
) : (
|
||||||
<option value="">Выберите график</option>
|
<option value="">Выберите график</option>
|
||||||
)}
|
)}
|
||||||
</select>
|
</CustomSelect>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="calendar-subject-id">Дисциплина</label>
|
<label htmlFor="calendar-subject-id">Дисциплина</label>
|
||||||
<select
|
<CustomSelect
|
||||||
id="calendar-subject-id"
|
id="calendar-subject-id"
|
||||||
value={sortedSubjects.some(item => String(item.id) === subjectIdValue)
|
value={sortedSubjects.some(item => String(item.id) === subjectIdValue)
|
||||||
? subjectIdValue
|
? subjectIdValue
|
||||||
@@ -2097,7 +2098,7 @@ export function AcademicCalendarTab() {
|
|||||||
) : (
|
) : (
|
||||||
<option value="">Загрузка...</option>
|
<option value="">Загрузка...</option>
|
||||||
)}
|
)}
|
||||||
</select>
|
</CustomSelect>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { CustomSelect } from '../../shared/ui/CustomSelect.jsx';
|
||||||
|
|
||||||
import { toIsoDate } from '../../shared/dates.js';
|
import { toIsoDate } from '../../shared/dates.js';
|
||||||
import { FormAlert } from '../../shared/ui/FormAlert.jsx';
|
import { FormAlert } from '../../shared/ui/FormAlert.jsx';
|
||||||
@@ -406,7 +407,7 @@ export function AuditoriumWorkloadTab() {
|
|||||||
<div className="filter-row workload-filter-row">
|
<div className="filter-row workload-filter-row">
|
||||||
<div className="form-group workload-target-control">
|
<div className="form-group workload-target-control">
|
||||||
<label htmlFor="workload-target-select">Что смотреть</label>
|
<label htmlFor="workload-target-select">Что смотреть</label>
|
||||||
<select
|
<CustomSelect
|
||||||
id="workload-target-select"
|
id="workload-target-select"
|
||||||
className="workload-display-select"
|
className="workload-display-select"
|
||||||
value={currentTarget}
|
value={currentTarget}
|
||||||
@@ -415,12 +416,12 @@ export function AuditoriumWorkloadTab() {
|
|||||||
<option value={TARGET_CLASSROOMS}>Аудитории</option>
|
<option value={TARGET_CLASSROOMS}>Аудитории</option>
|
||||||
<option value={TARGET_TEACHERS}>Преподаватели</option>
|
<option value={TARGET_TEACHERS}>Преподаватели</option>
|
||||||
<option value={TARGET_DEPARTMENTS}>Кафедры</option>
|
<option value={TARGET_DEPARTMENTS}>Кафедры</option>
|
||||||
</select>
|
</CustomSelect>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group workload-display-control">
|
<div className="form-group workload-display-control">
|
||||||
<label htmlFor="workload-display-select">Отображение</label>
|
<label htmlFor="workload-display-select">Отображение</label>
|
||||||
<select
|
<CustomSelect
|
||||||
id="workload-display-select"
|
id="workload-display-select"
|
||||||
className="workload-display-select"
|
className="workload-display-select"
|
||||||
value={displayValue}
|
value={displayValue}
|
||||||
@@ -429,7 +430,7 @@ export function AuditoriumWorkloadTab() {
|
|||||||
{displayOptions.map(option => (
|
{displayOptions.map(option => (
|
||||||
<option key={option.value} value={option.value}>{option.label}</option>
|
<option key={option.value} value={option.value}>{option.label}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</CustomSelect>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showOverviewFilters && (
|
{showOverviewFilters && (
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { CustomSelect } from '../../shared/ui/CustomSelect.jsx';
|
||||||
|
|
||||||
import { getSession } from '../../../admin/js/api.js';
|
import { getSession } from '../../../admin/js/api.js';
|
||||||
import { addDays, toIsoDate } from '../../shared/dates.js';
|
import { addDays, toIsoDate } from '../../shared/dates.js';
|
||||||
@@ -356,7 +357,7 @@ export function DepartmentWorkspaceTab() {
|
|||||||
{!isDepartmentRole && (
|
{!isDepartmentRole && (
|
||||||
<div className="form-group" id="department-workspace-department-field">
|
<div className="form-group" id="department-workspace-department-field">
|
||||||
<label htmlFor="department-workspace-department">Кафедра</label>
|
<label htmlFor="department-workspace-department">Кафедра</label>
|
||||||
<select
|
<CustomSelect
|
||||||
id="department-workspace-department"
|
id="department-workspace-department"
|
||||||
value={departmentId}
|
value={departmentId}
|
||||||
onChange={event => handleDepartmentChange(event.target.value)}
|
onChange={event => handleDepartmentChange(event.target.value)}
|
||||||
@@ -367,7 +368,7 @@ export function DepartmentWorkspaceTab() {
|
|||||||
{departmentOptionLabel(department)}
|
{departmentOptionLabel(department)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</CustomSelect>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
@@ -471,7 +472,7 @@ export function DepartmentWorkspaceTab() {
|
|||||||
<form id="department-teacher-assign-form" className="stack-form" onSubmit={submitAssign}>
|
<form id="department-teacher-assign-form" className="stack-form" onSubmit={submitAssign}>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="department-teacher-select">Преподаватель</label>
|
<label htmlFor="department-teacher-select">Преподаватель</label>
|
||||||
<select
|
<CustomSelect
|
||||||
id="department-teacher-select"
|
id="department-teacher-select"
|
||||||
value={assignTeacherId}
|
value={assignTeacherId}
|
||||||
onChange={event => setAssignTeacherId(event.target.value)}
|
onChange={event => setAssignTeacherId(event.target.value)}
|
||||||
@@ -492,7 +493,7 @@ export function DepartmentWorkspaceTab() {
|
|||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</select>
|
</CustomSelect>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="department-teacher-assign-comment">Комментарий</label>
|
<label htmlFor="department-teacher-assign-comment">Комментарий</label>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { CustomSelect } from '../../shared/ui/CustomSelect.jsx';
|
||||||
|
|
||||||
import { readUrlState, readUrlValues, writeUrlState } from '../../../admin/js/url-state.js';
|
import { readUrlState, readUrlValues, writeUrlState } from '../../../admin/js/url-state.js';
|
||||||
import { trackUxEvent } from '../../../telemetry.js';
|
import { trackUxEvent } from '../../../telemetry.js';
|
||||||
@@ -134,6 +135,8 @@ export function GroupsTab() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function loadGroups(nextState) {
|
async function loadGroups(nextState) {
|
||||||
|
stateRef.current = nextState;
|
||||||
|
setState(nextState);
|
||||||
const currentRequest = requestNumber.current + 1;
|
const currentRequest = requestNumber.current + 1;
|
||||||
requestNumber.current = currentRequest;
|
requestNumber.current = currentRequest;
|
||||||
setTableLoading(true);
|
setTableLoading(true);
|
||||||
@@ -174,6 +177,7 @@ export function GroupsTab() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resetFilters() {
|
function resetFilters() {
|
||||||
|
window.clearTimeout(searchTimer.current);
|
||||||
setSearchInput('');
|
setSearchInput('');
|
||||||
void loadGroups({
|
void loadGroups({
|
||||||
...stateRef.current,
|
...stateRef.current,
|
||||||
@@ -546,7 +550,7 @@ export function GroupsTab() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="new-group-ef">Форма обучения</label>
|
<label htmlFor="new-group-ef">Форма обучения</label>
|
||||||
<select
|
<CustomSelect
|
||||||
id="new-group-ef"
|
id="new-group-ef"
|
||||||
value={createForm.educationFormId}
|
value={createForm.educationFormId}
|
||||||
onChange={event => updateCreateForm({ educationFormId: event.target.value })}
|
onChange={event => updateCreateForm({ educationFormId: event.target.value })}
|
||||||
@@ -554,11 +558,11 @@ export function GroupsTab() {
|
|||||||
{(educationForms || []).map(form => (
|
{(educationForms || []).map(form => (
|
||||||
<option key={form.id} value={form.id}>{form.name}</option>
|
<option key={form.id} value={form.id}>{form.name}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</CustomSelect>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="new-group-department">Кафедра</label>
|
<label htmlFor="new-group-department">Кафедра</label>
|
||||||
<select
|
<CustomSelect
|
||||||
id="new-group-department"
|
id="new-group-department"
|
||||||
value={createForm.departmentId}
|
value={createForm.departmentId}
|
||||||
onChange={event => updateCreateForm({ departmentId: event.target.value })}
|
onChange={event => updateCreateForm({ departmentId: event.target.value })}
|
||||||
@@ -569,7 +573,7 @@ export function GroupsTab() {
|
|||||||
{departmentOptionLabel(department)}
|
{departmentOptionLabel(department)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</CustomSelect>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="new-group-yearStartStudy">Год начала обучения</label>
|
<label htmlFor="new-group-yearStartStudy">Год начала обучения</label>
|
||||||
@@ -586,7 +590,7 @@ export function GroupsTab() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="new-group-speciality-code">Специальность</label>
|
<label htmlFor="new-group-speciality-code">Специальность</label>
|
||||||
<select
|
<CustomSelect
|
||||||
id="new-group-speciality-code"
|
id="new-group-speciality-code"
|
||||||
value={createForm.specialtyId}
|
value={createForm.specialtyId}
|
||||||
onChange={event => updateCreateForm({ specialtyId: event.target.value, specialtyProfileId: '' })}
|
onChange={event => updateCreateForm({ specialtyId: event.target.value, specialtyProfileId: '' })}
|
||||||
@@ -597,11 +601,11 @@ export function GroupsTab() {
|
|||||||
{specialityLabel(specialties, speciality.id)}
|
{specialityLabel(specialties, speciality.id)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</CustomSelect>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="new-group-profile">Профиль обучения</label>
|
<label htmlFor="new-group-profile">Профиль обучения</label>
|
||||||
<select
|
<CustomSelect
|
||||||
id="new-group-profile"
|
id="new-group-profile"
|
||||||
value={createForm.specialtyProfileId}
|
value={createForm.specialtyProfileId}
|
||||||
onChange={event => updateCreateForm({ specialtyProfileId: event.target.value })}
|
onChange={event => updateCreateForm({ specialtyProfileId: event.target.value })}
|
||||||
@@ -616,7 +620,7 @@ export function GroupsTab() {
|
|||||||
) : (
|
) : (
|
||||||
<option value="">Нет профилей</option>
|
<option value="">Нет профилей</option>
|
||||||
)}
|
)}
|
||||||
</select>
|
</CustomSelect>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" className="btn btn-md btn-primary">Создать</button>
|
<button type="submit" className="btn btn-md btn-primary">Создать</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -654,7 +658,7 @@ export function GroupsTab() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="groups-sort">Сортировка</label>
|
<label htmlFor="groups-sort">Сортировка</label>
|
||||||
<select
|
<CustomSelect
|
||||||
id="groups-sort"
|
id="groups-sort"
|
||||||
value={state.sort}
|
value={state.sort}
|
||||||
onChange={event => applyState({ sort: event.target.value, page: 0 })}
|
onChange={event => applyState({ sort: event.target.value, page: 0 })}
|
||||||
@@ -662,7 +666,7 @@ export function GroupsTab() {
|
|||||||
{GROUP_SORT_OPTIONS.map(option => (
|
{GROUP_SORT_OPTIONS.map(option => (
|
||||||
<option key={option.value} value={option.value}>{option.label}</option>
|
<option key={option.value} value={option.value}>{option.label}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</CustomSelect>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" className="btn btn-md btn-ghost" onClick={resetFilters}>Сбросить фильтры</button>
|
<button type="button" className="btn btn-md btn-ghost" onClick={resetFilters}>Сбросить фильтры</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -819,7 +823,7 @@ export function GroupsTab() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="edit-group-ef">Форма обучения</label>
|
<label htmlFor="edit-group-ef">Форма обучения</label>
|
||||||
<select
|
<CustomSelect
|
||||||
id="edit-group-ef"
|
id="edit-group-ef"
|
||||||
value={editModal.form.educationFormId}
|
value={editModal.form.educationFormId}
|
||||||
onChange={event => setEditModal(current => (
|
onChange={event => setEditModal(current => (
|
||||||
@@ -829,13 +833,13 @@ export function GroupsTab() {
|
|||||||
{(educationForms || []).map(form => (
|
{(educationForms || []).map(form => (
|
||||||
<option key={form.id} value={form.id}>{form.name}</option>
|
<option key={form.id} value={form.id}>{form.name}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</CustomSelect>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-row edit-group-form-row edit-group-form-row-details">
|
<div className="form-row edit-group-form-row edit-group-form-row-details">
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="edit-group-department">Кафедра</label>
|
<label htmlFor="edit-group-department">Кафедра</label>
|
||||||
<select
|
<CustomSelect
|
||||||
id="edit-group-department"
|
id="edit-group-department"
|
||||||
value={editModal.form.departmentId}
|
value={editModal.form.departmentId}
|
||||||
onChange={event => setEditModal(current => (
|
onChange={event => setEditModal(current => (
|
||||||
@@ -848,7 +852,7 @@ export function GroupsTab() {
|
|||||||
{departmentOptionLabel(department)}
|
{departmentOptionLabel(department)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</CustomSelect>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="edit-group-yearStartStudy">Год начала обучения</label>
|
<label htmlFor="edit-group-yearStartStudy">Год начала обучения</label>
|
||||||
@@ -866,7 +870,7 @@ export function GroupsTab() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="edit-group-speciality-code">Специальность</label>
|
<label htmlFor="edit-group-speciality-code">Специальность</label>
|
||||||
<select
|
<CustomSelect
|
||||||
id="edit-group-speciality-code"
|
id="edit-group-speciality-code"
|
||||||
value={editModal.form.specialtyId}
|
value={editModal.form.specialtyId}
|
||||||
onChange={event => setEditModal(current => (
|
onChange={event => setEditModal(current => (
|
||||||
@@ -881,11 +885,11 @@ export function GroupsTab() {
|
|||||||
{specialityLabel(specialties, speciality.id)}
|
{specialityLabel(specialties, speciality.id)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</CustomSelect>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="edit-group-profile">Профиль обучения</label>
|
<label htmlFor="edit-group-profile">Профиль обучения</label>
|
||||||
<select
|
<CustomSelect
|
||||||
id="edit-group-profile"
|
id="edit-group-profile"
|
||||||
value={editModal.form.specialtyProfileId}
|
value={editModal.form.specialtyProfileId}
|
||||||
onChange={event => setEditModal(current => (
|
onChange={event => setEditModal(current => (
|
||||||
@@ -902,7 +906,7 @@ export function GroupsTab() {
|
|||||||
) : (
|
) : (
|
||||||
<option value="">Нет профилей</option>
|
<option value="">Нет профилей</option>
|
||||||
)}
|
)}
|
||||||
</select>
|
</CustomSelect>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group edit-group-submit">
|
<div className="form-group edit-group-submit">
|
||||||
<button type="submit" className="btn btn-md btn-primary">Сохранить</button>
|
<button type="submit" className="btn btn-md btn-primary">Сохранить</button>
|
||||||
@@ -949,7 +953,7 @@ export function GroupsTab() {
|
|||||||
<div className="subgroup-config-grid">
|
<div className="subgroup-config-grid">
|
||||||
<div className="form-group subgroup-count-field">
|
<div className="form-group subgroup-count-field">
|
||||||
<label htmlFor="manage-subgroup-count">Количество подгрупп</label>
|
<label htmlFor="manage-subgroup-count">Количество подгрупп</label>
|
||||||
<select
|
<CustomSelect
|
||||||
id="manage-subgroup-count"
|
id="manage-subgroup-count"
|
||||||
value={subgroupsModal.count}
|
value={subgroupsModal.count}
|
||||||
onChange={event => changeSubgroupCount(event.target.value)}
|
onChange={event => changeSubgroupCount(event.target.value)}
|
||||||
@@ -957,7 +961,7 @@ export function GroupsTab() {
|
|||||||
{SUBGROUP_COUNT_OPTIONS.map(option => (
|
{SUBGROUP_COUNT_OPTIONS.map(option => (
|
||||||
<option key={option.value} value={option.value}>{option.label}</option>
|
<option key={option.value} value={option.value}>{option.label}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</CustomSelect>
|
||||||
</div>
|
</div>
|
||||||
<div className={`subgroup-capacity-fields subgroup-capacity-fields-${subgroupsModal.count}`}>
|
<div className={`subgroup-capacity-fields subgroup-capacity-fields-${subgroupsModal.count}`}>
|
||||||
{subgroupsModal.capacities.map((value, index) => (
|
{subgroupsModal.capacities.map((value, index) => (
|
||||||
@@ -1074,7 +1078,7 @@ export function GroupsTab() {
|
|||||||
<div className="form-row form-row--bottom">
|
<div className="form-row form-row--bottom">
|
||||||
<div className="form-group form-group--flush">
|
<div className="form-group form-group--flush">
|
||||||
<label htmlFor="manage-calendar-year">Учебный год</label>
|
<label htmlFor="manage-calendar-year">Учебный год</label>
|
||||||
<select
|
<CustomSelect
|
||||||
id="manage-calendar-year"
|
id="manage-calendar-year"
|
||||||
required
|
required
|
||||||
value={calendarModal.yearId}
|
value={calendarModal.yearId}
|
||||||
@@ -1084,7 +1088,7 @@ export function GroupsTab() {
|
|||||||
{(academicYears || []).map(year => (
|
{(academicYears || []).map(year => (
|
||||||
<option key={year.id} value={year.id}>{year.title}</option>
|
<option key={year.id} value={year.id}>{year.title}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</CustomSelect>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group form-group--flush form-group--wide">
|
<div className="form-group form-group--flush form-group--wide">
|
||||||
<label htmlFor="manage-calendar-id">Календарный график</label>
|
<label htmlFor="manage-calendar-id">Календарный график</label>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { CustomSelect } from '../../shared/ui/CustomSelect.jsx';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
academicYearsFromSemesters,
|
academicYearsFromSemesters,
|
||||||
@@ -238,17 +239,17 @@ export function ScheduleQualityTab() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="quality-run-panel">
|
<div className="quality-run-panel">
|
||||||
<label htmlFor="quality-year">Учебный год</label>
|
<label htmlFor="quality-year">Учебный год</label>
|
||||||
<select id="quality-year" value={academicYearId} onChange={handleYearChange}>
|
<CustomSelect id="quality-year" value={academicYearId} onChange={handleYearChange}>
|
||||||
{yearOptions}
|
{yearOptions}
|
||||||
</select>
|
</CustomSelect>
|
||||||
<label htmlFor="quality-semester">Семестр</label>
|
<label htmlFor="quality-semester">Семестр</label>
|
||||||
<select id="quality-semester" value={semesterId} onChange={handleSemesterChange}>
|
<CustomSelect id="quality-semester" value={semesterId} onChange={handleSemesterChange}>
|
||||||
{semesterOptions}
|
{semesterOptions}
|
||||||
</select>
|
</CustomSelect>
|
||||||
<label htmlFor="quality-version">Версия</label>
|
<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}
|
{versionOptions}
|
||||||
</select>
|
</CustomSelect>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-md btn-primary"
|
className="btn btn-md btn-primary"
|
||||||
@@ -335,7 +336,7 @@ export function ScheduleQualityTab() {
|
|||||||
<div className="quality-filters">
|
<div className="quality-filters">
|
||||||
<label>
|
<label>
|
||||||
Важность
|
Важность
|
||||||
<select
|
<CustomSelect
|
||||||
id="quality-severity-filter"
|
id="quality-severity-filter"
|
||||||
value={severityFilter}
|
value={severityFilter}
|
||||||
onChange={event => setSeverityFilter(event.target.value)}
|
onChange={event => setSeverityFilter(event.target.value)}
|
||||||
@@ -345,11 +346,11 @@ export function ScheduleQualityTab() {
|
|||||||
<option value="HIGH">Высокие</option>
|
<option value="HIGH">Высокие</option>
|
||||||
<option value="MEDIUM">Средние</option>
|
<option value="MEDIUM">Средние</option>
|
||||||
<option value="LOW">Низкие</option>
|
<option value="LOW">Низкие</option>
|
||||||
</select>
|
</CustomSelect>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Категория
|
Категория
|
||||||
<select
|
<CustomSelect
|
||||||
id="quality-type-filter"
|
id="quality-type-filter"
|
||||||
value={categoryFilter}
|
value={categoryFilter}
|
||||||
onChange={event => setCategoryFilter(event.target.value)}
|
onChange={event => setCategoryFilter(event.target.value)}
|
||||||
@@ -359,7 +360,7 @@ export function ScheduleQualityTab() {
|
|||||||
<option value="LOAD">Дневная нагрузка</option>
|
<option value="LOAD">Дневная нагрузка</option>
|
||||||
<option value="ROOM">Аудитории</option>
|
<option value="ROOM">Аудитории</option>
|
||||||
<option value="PREFERENCE">Пожелания</option>
|
<option value="PREFERENCE">Пожелания</option>
|
||||||
</select>
|
</CustomSelect>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div className="quality-problems" id="quality-problems">
|
<div className="quality-problems" id="quality-problems">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { CustomSelect } from '../../shared/ui/CustomSelect.jsx';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
academicYearsFromSemesters,
|
academicYearsFromSemesters,
|
||||||
@@ -1368,7 +1369,7 @@ export function ScheduleTab() {
|
|||||||
<div className="version-constructor-controls">
|
<div className="version-constructor-controls">
|
||||||
<div className="version-constructor-field">
|
<div className="version-constructor-field">
|
||||||
<label htmlFor="schedule-version-year">Учебный год</label>
|
<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' ? (
|
{initialPhase === 'loading' ? (
|
||||||
<option value="">Загрузка годов…</option>
|
<option value="">Загрузка годов…</option>
|
||||||
) : years.length ? (
|
) : years.length ? (
|
||||||
@@ -1376,11 +1377,11 @@ export function ScheduleTab() {
|
|||||||
) : (
|
) : (
|
||||||
<option value="">Учебные годы не настроены</option>
|
<option value="">Учебные годы не настроены</option>
|
||||||
)}
|
)}
|
||||||
</select>
|
</CustomSelect>
|
||||||
</div>
|
</div>
|
||||||
<div className="version-constructor-field">
|
<div className="version-constructor-field">
|
||||||
<label htmlFor="schedule-version-semester">Семестр</label>
|
<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' ? (
|
{initialPhase === 'loading' ? (
|
||||||
<option value="">Сначала выберите год</option>
|
<option value="">Сначала выберите год</option>
|
||||||
) : yearSemesters.length ? (
|
) : yearSemesters.length ? (
|
||||||
@@ -1390,11 +1391,11 @@ export function ScheduleTab() {
|
|||||||
) : (
|
) : (
|
||||||
<option value="">Семестры не настроены</option>
|
<option value="">Семестры не настроены</option>
|
||||||
)}
|
)}
|
||||||
</select>
|
</CustomSelect>
|
||||||
</div>
|
</div>
|
||||||
<div className="version-constructor-field version-constructor-field-wide">
|
<div className="version-constructor-field version-constructor-field-wide">
|
||||||
<label htmlFor="schedule-version-select">Версия расписания</label>
|
<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 ? (
|
{initialPhase === 'loading' || !versionSemesterId ? (
|
||||||
<option value="">Сначала выберите семестр</option>
|
<option value="">Сначала выберите семестр</option>
|
||||||
) : scheduleVersions.length ? (
|
) : scheduleVersions.length ? (
|
||||||
@@ -1410,7 +1411,7 @@ export function ScheduleTab() {
|
|||||||
) : (
|
) : (
|
||||||
<option value="">Расписания не созданы</option>
|
<option value="">Расписания не созданы</option>
|
||||||
)}
|
)}
|
||||||
</select>
|
</CustomSelect>
|
||||||
</div>
|
</div>
|
||||||
<div className="version-constructor-actions">
|
<div className="version-constructor-actions">
|
||||||
<button type="button" className="btn btn-sm btn-primary" id="schedule-version-create-draft" onClick={openDraftModal} disabled={!selectedVersion}>Создать черновик</button>
|
<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-row">
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="schedule-rule-subject">Дисциплина</label>
|
<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 ? (
|
{!subjects.length ? (
|
||||||
<option value="">Загрузка...</option>
|
<option value="">Загрузка...</option>
|
||||||
) : (
|
) : (
|
||||||
@@ -1475,11 +1476,11 @@ export function ScheduleTab() {
|
|||||||
{subjects.map(subject => (
|
{subjects.map(subject => (
|
||||||
<option key={subject.id} value={subject.id}>{subject.name}</option>
|
<option key={subject.id} value={subject.id}>{subject.name}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</CustomSelect>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="schedule-rule-semester">Семестр</label>
|
<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 ? (
|
{!semesters.length ? (
|
||||||
<option value="">Загрузка...</option>
|
<option value="">Загрузка...</option>
|
||||||
) : (
|
) : (
|
||||||
@@ -1488,7 +1489,7 @@ export function ScheduleTab() {
|
|||||||
{semesters.map(semester => (
|
{semesters.map(semester => (
|
||||||
<option key={semester.id} value={semester.id}>{semesterLabel(semester)}</option>
|
<option key={semester.id} value={semester.id}>{semesterLabel(semester)}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</CustomSelect>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1723,7 +1724,7 @@ export function ScheduleTab() {
|
|||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="schedule-move-slot-day">День</label>
|
<label htmlFor="schedule-move-slot-day">День</label>
|
||||||
<select
|
<CustomSelect
|
||||||
id="schedule-move-slot-day"
|
id="schedule-move-slot-day"
|
||||||
required
|
required
|
||||||
value={moveSlot.dayOfWeek}
|
value={moveSlot.dayOfWeek}
|
||||||
@@ -1733,11 +1734,11 @@ export function ScheduleTab() {
|
|||||||
>
|
>
|
||||||
<option value="">Выберите</option>
|
<option value="">Выберите</option>
|
||||||
{DAY_OPTIONS.map(day => <option key={day.value} value={day.value}>{day.label}</option>)}
|
{DAY_OPTIONS.map(day => <option key={day.value} value={day.value}>{day.label}</option>)}
|
||||||
</select>
|
</CustomSelect>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="schedule-move-slot-time">Пара</label>
|
<label htmlFor="schedule-move-slot-time">Пара</label>
|
||||||
<select
|
<CustomSelect
|
||||||
id="schedule-move-slot-time"
|
id="schedule-move-slot-time"
|
||||||
required
|
required
|
||||||
value={moveSlot.timeSlotId}
|
value={moveSlot.timeSlotId}
|
||||||
@@ -1749,7 +1750,7 @@ export function ScheduleTab() {
|
|||||||
{timeSlotOptions.map(option => (
|
{timeSlotOptions.map(option => (
|
||||||
<option key={option.value} value={option.value}>{option.label}</option>
|
<option key={option.value} value={option.value}>{option.label}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</CustomSelect>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<FormAlert id="schedule-move-slot-alert" alert={moveSlotAlert} />
|
<FormAlert id="schedule-move-slot-alert" alert={moveSlotAlert} />
|
||||||
@@ -1863,14 +1864,14 @@ export function ScheduleTab() {
|
|||||||
<div className="schedule-visual-period-grid">
|
<div className="schedule-visual-period-grid">
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="schedule-visual-year">Учебный год</label>
|
<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>
|
<option value="">Выберите учебный год</option>
|
||||||
{academicYears.map(year => <option key={year.id} value={year.id}>{year.title}</option>)}
|
{academicYears.map(year => <option key={year.id} value={year.id}>{year.title}</option>)}
|
||||||
</select>
|
</CustomSelect>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="schedule-visual-semester">Семестр</label>
|
<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 ? (
|
{effectiveVisualPeriod.yearSemesters.length ? (
|
||||||
<>
|
<>
|
||||||
<option value="">Выберите семестр</option>
|
<option value="">Выберите семестр</option>
|
||||||
@@ -1881,7 +1882,7 @@ export function ScheduleTab() {
|
|||||||
) : (
|
) : (
|
||||||
<option value="">Нет семестров</option>
|
<option value="">Нет семестров</option>
|
||||||
)}
|
)}
|
||||||
</select>
|
</CustomSelect>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="schedule-visual-filter-head">
|
<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={`schedule-slot-row${laboratory ? ' has-subgroup' : ''}`} data-index={index} data-slot-id={slot.id || ''}>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>День</label>
|
<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>
|
<option value="">Выберите</option>
|
||||||
{DAY_OPTIONS.map(day => <option key={day.value} value={day.value}>{day.label}</option>)}
|
{DAY_OPTIONS.map(day => <option key={day.value} value={day.value}>{day.label}</option>)}
|
||||||
</select>
|
</CustomSelect>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Чётность</label>
|
<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>
|
<option value="">Выберите</option>
|
||||||
{PARITY_OPTIONS.map(option => <option key={option.value} value={option.value}>{option.label}</option>)}
|
{PARITY_OPTIONS.map(option => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||||
</select>
|
</CustomSelect>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Пара</label>
|
<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>
|
<option value="">Выберите</option>
|
||||||
{timeSlotOptions.map(option => <option key={option.value} value={option.value}>{option.label}</option>)}
|
{timeSlotOptions.map(option => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||||
</select>
|
</CustomSelect>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Преподаватель</label>
|
<label>Преподаватель</label>
|
||||||
@@ -2005,10 +2006,10 @@ function RuleSlotRow({
|
|||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Тип</label>
|
<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>
|
<option value="">Выберите</option>
|
||||||
{lessonTypeOptions.map(option => <option key={option.value} value={option.value}>{option.label}</option>)}
|
{lessonTypeOptions.map(option => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||||
</select>
|
</CustomSelect>
|
||||||
</div>
|
</div>
|
||||||
{laboratory && (selectedGroupIds.length > 1 ? (
|
{laboratory && (selectedGroupIds.length > 1 ? (
|
||||||
<div className="form-group slot-subgroup-multi">
|
<div className="form-group slot-subgroup-multi">
|
||||||
@@ -2068,7 +2069,7 @@ function RuleSlotRow({
|
|||||||
) : (
|
) : (
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Подгруппа</label>
|
<label>Подгруппа</label>
|
||||||
<select
|
<CustomSelect
|
||||||
className="slot-subgroup"
|
className="slot-subgroup"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
value={slot.subgroupIds[0] == null ? WHOLE_GROUP_SUBGROUP_VALUE : String(slot.subgroupIds[0])}
|
value={slot.subgroupIds[0] == null ? WHOLE_GROUP_SUBGROUP_VALUE : String(slot.subgroupIds[0])}
|
||||||
@@ -2088,15 +2089,15 @@ function RuleSlotRow({
|
|||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</select>
|
</CustomSelect>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Формат</label>
|
<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>
|
<option value="">Выберите</option>
|
||||||
{LESSON_FORMAT_OPTIONS.map(option => <option key={option.value} value={option.value}>{option.label}</option>)}
|
{LESSON_FORMAT_OPTIONS.map(option => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||||
</select>
|
</CustomSelect>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -2142,24 +2143,24 @@ function ConflictSlotRow({ slot, index, conflictFields, timeSlotOptions, onSlotC
|
|||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>День</label>
|
<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>
|
<option value="">Выберите</option>
|
||||||
{DAY_OPTIONS.map(day => <option key={day.value} value={day.value}>{day.label}</option>)}
|
{DAY_OPTIONS.map(day => <option key={day.value} value={day.value}>{day.label}</option>)}
|
||||||
</select>
|
</CustomSelect>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Чётность</label>
|
<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>
|
<option value="">Выберите</option>
|
||||||
{PARITY_OPTIONS.map(option => <option key={option.value} value={option.value}>{option.label}</option>)}
|
{PARITY_OPTIONS.map(option => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||||
</select>
|
</CustomSelect>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Пара</label>
|
<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>
|
<option value="">Выберите</option>
|
||||||
{timeSlotOptions.map(option => <option key={option.value} value={option.value}>{option.label}</option>)}
|
{timeSlotOptions.map(option => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||||
</select>
|
</CustomSelect>
|
||||||
</div>
|
</div>
|
||||||
<div className={`form-group ${conflictFieldClass('teacher', conflictFields)}`}>
|
<div className={`form-group ${conflictFieldClass('teacher', conflictFields)}`}>
|
||||||
<label>Преподаватель</label>
|
<label>Преподаватель</label>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { CustomSelect } from '../../shared/ui/CustomSelect.jsx';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
academicYearsFromSemesters,
|
academicYearsFromSemesters,
|
||||||
@@ -301,15 +302,15 @@ export function ScheduleVersionsTab() {
|
|||||||
<div className="version-hero-period-grid">
|
<div className="version-hero-period-grid">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="version-year">Учебный год</label>
|
<label htmlFor="version-year">Учебный год</label>
|
||||||
<select id="version-year" value={academicYearId} onChange={handleYearChange}>
|
<CustomSelect id="version-year" value={academicYearId} onChange={handleYearChange}>
|
||||||
{yearOptions}
|
{yearOptions}
|
||||||
</select>
|
</CustomSelect>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="version-semester">Рабочий семестр</label>
|
<label htmlFor="version-semester">Рабочий семестр</label>
|
||||||
<select id="version-semester" value={semesterId} onChange={handleSemesterChange}>
|
<CustomSelect id="version-semester" value={semesterId} onChange={handleSemesterChange}>
|
||||||
{semesterOptions}
|
{semesterOptions}
|
||||||
</select>
|
</CustomSelect>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" className="btn btn-md btn-primary" id="version-create" onClick={openCreateModal}>
|
<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 { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
|
||||||
|
import { CustomSelect } from '../../shared/ui/CustomSelect.jsx';
|
||||||
|
|
||||||
import { getSession } from '../../../admin/js/api.js';
|
import { getSession } from '../../../admin/js/api.js';
|
||||||
import {
|
import {
|
||||||
@@ -710,7 +711,7 @@ export function ScheduleViewTab() {
|
|||||||
<div className="schedule-view-control-grid">
|
<div className="schedule-view-control-grid">
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="schedule-view-target">Что смотреть</label>
|
<label htmlFor="schedule-view-target">Что смотреть</label>
|
||||||
<select
|
<CustomSelect
|
||||||
id="schedule-view-target"
|
id="schedule-view-target"
|
||||||
value={activeTargetValue}
|
value={activeTargetValue}
|
||||||
disabled={isDepartment}
|
disabled={isDepartment}
|
||||||
@@ -720,7 +721,7 @@ export function ScheduleViewTab() {
|
|||||||
<option value={TARGET_TEACHERS}>Преподаватель</option>
|
<option value={TARGET_TEACHERS}>Преподаватель</option>
|
||||||
<option value={TARGET_CLASSROOMS}>Аудитория</option>
|
<option value={TARGET_CLASSROOMS}>Аудитория</option>
|
||||||
<option value={TARGET_DEPARTMENTS}>Кафедра</option>
|
<option value={TARGET_DEPARTMENTS}>Кафедра</option>
|
||||||
</select>
|
</CustomSelect>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -793,7 +794,7 @@ export function ScheduleViewTab() {
|
|||||||
hidden={activeTargetValue !== TARGET_DEPARTMENTS}
|
hidden={activeTargetValue !== TARGET_DEPARTMENTS}
|
||||||
>
|
>
|
||||||
<label htmlFor="schedule-view-department">Кафедра</label>
|
<label htmlFor="schedule-view-department">Кафедра</label>
|
||||||
<select
|
<CustomSelect
|
||||||
id="schedule-view-department"
|
id="schedule-view-department"
|
||||||
value={departmentId}
|
value={departmentId}
|
||||||
disabled={isDepartment}
|
disabled={isDepartment}
|
||||||
@@ -801,7 +802,7 @@ export function ScheduleViewTab() {
|
|||||||
>
|
>
|
||||||
<option value="">Все кафедры</option>
|
<option value="">Все кафедры</option>
|
||||||
{departmentOptions}
|
{departmentOptions}
|
||||||
</select>
|
</CustomSelect>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
@@ -818,7 +819,7 @@ export function ScheduleViewTab() {
|
|||||||
<div className="schedule-view-extra-grid">
|
<div className="schedule-view-extra-grid">
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="schedule-view-year">Учебный год</label>
|
<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 ? (
|
{!baseLoaded ? (
|
||||||
<option value="">Загрузка годов…</option>
|
<option value="">Загрузка годов…</option>
|
||||||
) : (
|
) : (
|
||||||
@@ -828,11 +829,11 @@ export function ScheduleViewTab() {
|
|||||||
))
|
))
|
||||||
: <option value="">Учебные годы не настроены</option>
|
: <option value="">Учебные годы не настроены</option>
|
||||||
)}
|
)}
|
||||||
</select>
|
</CustomSelect>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="schedule-view-semester">Семестр</label>
|
<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 ? (
|
{!baseLoaded ? (
|
||||||
<option value="">Сначала выберите год</option>
|
<option value="">Сначала выберите год</option>
|
||||||
) : (
|
) : (
|
||||||
@@ -844,11 +845,11 @@ export function ScheduleViewTab() {
|
|||||||
))
|
))
|
||||||
: <option value="">Семестры не настроены</option>
|
: <option value="">Семестры не настроены</option>
|
||||||
)}
|
)}
|
||||||
</select>
|
</CustomSelect>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="schedule-view-subject">Дисциплина</label>
|
<label htmlFor="schedule-view-subject">Дисциплина</label>
|
||||||
<select
|
<CustomSelect
|
||||||
id="schedule-view-subject"
|
id="schedule-view-subject"
|
||||||
value={subjectId}
|
value={subjectId}
|
||||||
onChange={handleSimpleFilterChange('subjectId', setSubjectId)}
|
onChange={handleSimpleFilterChange('subjectId', setSubjectId)}
|
||||||
@@ -857,11 +858,11 @@ export function ScheduleViewTab() {
|
|||||||
{subjects.map(subject => (
|
{subjects.map(subject => (
|
||||||
<option key={String(subject.id)} value={String(subject.id)}>{subject.name}</option>
|
<option key={String(subject.id)} value={String(subject.id)}>{subject.name}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</CustomSelect>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="schedule-view-lesson-type">Тип занятия</label>
|
<label htmlFor="schedule-view-lesson-type">Тип занятия</label>
|
||||||
<select
|
<CustomSelect
|
||||||
id="schedule-view-lesson-type"
|
id="schedule-view-lesson-type"
|
||||||
value={lessonTypeId}
|
value={lessonTypeId}
|
||||||
onChange={handleSimpleFilterChange('lessonTypeId', setLessonTypeId)}
|
onChange={handleSimpleFilterChange('lessonTypeId', setLessonTypeId)}
|
||||||
@@ -872,11 +873,11 @@ export function ScheduleViewTab() {
|
|||||||
{lessonType.name}
|
{lessonType.name}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</CustomSelect>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="schedule-view-parity">Неделя</label>
|
<label htmlFor="schedule-view-parity">Неделя</label>
|
||||||
<select
|
<CustomSelect
|
||||||
id="schedule-view-parity"
|
id="schedule-view-parity"
|
||||||
value={parity}
|
value={parity}
|
||||||
onChange={handleSimpleFilterChange('parity', setParity)}
|
onChange={handleSimpleFilterChange('parity', setParity)}
|
||||||
@@ -885,7 +886,7 @@ export function ScheduleViewTab() {
|
|||||||
<option value="BOTH">Каждая</option>
|
<option value="BOTH">Каждая</option>
|
||||||
<option value="ODD">Нечётная</option>
|
<option value="ODD">Нечётная</option>
|
||||||
<option value="EVEN">Чётная</option>
|
<option value="EVEN">Чётная</option>
|
||||||
</select>
|
</CustomSelect>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
@@ -1635,7 +1636,7 @@ const OverrideDrawer = forwardRef(function OverrideDrawer({
|
|||||||
<div className="schedule-override-fields">
|
<div className="schedule-override-fields">
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="schedule-override-time">Время</label>
|
<label htmlFor="schedule-override-time">Время</label>
|
||||||
<select
|
<CustomSelect
|
||||||
id="schedule-override-time"
|
id="schedule-override-time"
|
||||||
required
|
required
|
||||||
disabled={timeSlotsLoading}
|
disabled={timeSlotsLoading}
|
||||||
@@ -1652,7 +1653,7 @@ const OverrideDrawer = forwardRef(function OverrideDrawer({
|
|||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</select>
|
</CustomSelect>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="schedule-override-teacher">Преподаватель</label>
|
<label htmlFor="schedule-override-teacher">Преподаватель</label>
|
||||||
@@ -1692,14 +1693,14 @@ const OverrideDrawer = forwardRef(function OverrideDrawer({
|
|||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="schedule-override-format">Формат</label>
|
<label htmlFor="schedule-override-format">Формат</label>
|
||||||
<select
|
<CustomSelect
|
||||||
id="schedule-override-format"
|
id="schedule-override-format"
|
||||||
value={lessonFormat}
|
value={lessonFormat}
|
||||||
onChange={event => setLessonFormat(event.target.value)}
|
onChange={event => setLessonFormat(event.target.value)}
|
||||||
>
|
>
|
||||||
<option value="Очно">Очно</option>
|
<option value="Очно">Очно</option>
|
||||||
<option value="Онлайн">Онлайн</option>
|
<option value="Онлайн">Онлайн</option>
|
||||||
</select>
|
</CustomSelect>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { CustomSelect } from '../../shared/ui/CustomSelect.jsx';
|
||||||
|
|
||||||
import { readUrlState, writeUrlState } from '../../../admin/js/url-state.js';
|
import { readUrlState, writeUrlState } from '../../../admin/js/url-state.js';
|
||||||
import { trackUxEvent } from '../../../telemetry.js';
|
import { trackUxEvent } from '../../../telemetry.js';
|
||||||
@@ -83,6 +84,8 @@ export function SubjectsTab() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function loadSubjects(nextState) {
|
async function loadSubjects(nextState) {
|
||||||
|
stateRef.current = nextState;
|
||||||
|
setState(nextState);
|
||||||
const currentRequest = requestNumber.current + 1;
|
const currentRequest = requestNumber.current + 1;
|
||||||
requestNumber.current = currentRequest;
|
requestNumber.current = currentRequest;
|
||||||
setTableLoading(true);
|
setTableLoading(true);
|
||||||
@@ -267,7 +270,7 @@ export function SubjectsTab() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="new-subject-department">Кафедра</label>
|
<label htmlFor="new-subject-department">Кафедра</label>
|
||||||
<select
|
<CustomSelect
|
||||||
id="new-subject-department"
|
id="new-subject-department"
|
||||||
required
|
required
|
||||||
value={createDepartmentId}
|
value={createDepartmentId}
|
||||||
@@ -279,7 +282,7 @@ export function SubjectsTab() {
|
|||||||
{departmentOptionLabel(department)}
|
{departmentOptionLabel(department)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</CustomSelect>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" className="btn btn-md btn-primary">Добавить</button>
|
<button type="submit" className="btn btn-md btn-primary">Добавить</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -307,7 +310,7 @@ export function SubjectsTab() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="subjects-department-filter">Кафедра</label>
|
<label htmlFor="subjects-department-filter">Кафедра</label>
|
||||||
<select
|
<CustomSelect
|
||||||
id="subjects-department-filter"
|
id="subjects-department-filter"
|
||||||
value={state.departmentId}
|
value={state.departmentId}
|
||||||
onChange={event => applyState({ departmentId: event.target.value, page: 0 })}
|
onChange={event => applyState({ departmentId: event.target.value, page: 0 })}
|
||||||
@@ -318,11 +321,11 @@ export function SubjectsTab() {
|
|||||||
{department.departmentName || department.name || department.id}
|
{department.departmentName || department.name || department.id}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</CustomSelect>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="subjects-sort">Сортировка</label>
|
<label htmlFor="subjects-sort">Сортировка</label>
|
||||||
<select
|
<CustomSelect
|
||||||
id="subjects-sort"
|
id="subjects-sort"
|
||||||
value={state.sort}
|
value={state.sort}
|
||||||
onChange={event => applyState({ sort: event.target.value, page: 0 })}
|
onChange={event => applyState({ sort: event.target.value, page: 0 })}
|
||||||
@@ -330,7 +333,7 @@ export function SubjectsTab() {
|
|||||||
{SUBJECT_SORT_OPTIONS.map(option => (
|
{SUBJECT_SORT_OPTIONS.map(option => (
|
||||||
<option key={option.value} value={option.value}>{option.label}</option>
|
<option key={option.value} value={option.value}>{option.label}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</CustomSelect>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="table-wrap">
|
<div className="table-wrap">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { CustomSelect } from '../../shared/ui/CustomSelect.jsx';
|
||||||
|
|
||||||
import { getSession } from '../../../admin/js/api.js';
|
import { getSession } from '../../../admin/js/api.js';
|
||||||
import { AsyncCombobox } from '../../shared/ui/AsyncCombobox.jsx';
|
import { AsyncCombobox } from '../../shared/ui/AsyncCombobox.jsx';
|
||||||
@@ -621,7 +622,7 @@ export function TeacherAbsencesTab() {
|
|||||||
<h2>Реестр инцидентов</h2>
|
<h2>Реестр инцидентов</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="absence-registry-actions">
|
<div className="absence-registry-actions">
|
||||||
<select
|
<CustomSelect
|
||||||
id="teacher-absence-status-filter"
|
id="teacher-absence-status-filter"
|
||||||
aria-label="Фильтр по статусу"
|
aria-label="Фильтр по статусу"
|
||||||
value={statusFilter}
|
value={statusFilter}
|
||||||
@@ -636,7 +637,7 @@ export function TeacherAbsencesTab() {
|
|||||||
<option value="RESOLVED">Разрешены</option>
|
<option value="RESOLVED">Разрешены</option>
|
||||||
<option value="REJECTED">Отклонены</option>
|
<option value="REJECTED">Отклонены</option>
|
||||||
<option value="CANCELLED">Отменены</option>
|
<option value="CANCELLED">Отменены</option>
|
||||||
</select>
|
</CustomSelect>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-md btn-secondary"
|
className="btn btn-md btn-secondary"
|
||||||
@@ -668,7 +669,7 @@ export function TeacherAbsencesTab() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div className="teacher-dialogue-toolbar">
|
<div className="teacher-dialogue-toolbar">
|
||||||
<select
|
<CustomSelect
|
||||||
id="teacher-preference-semester-filter"
|
id="teacher-preference-semester-filter"
|
||||||
aria-label="Семестр пожеланий"
|
aria-label="Семестр пожеланий"
|
||||||
value={preferenceSemesterId}
|
value={preferenceSemesterId}
|
||||||
@@ -683,8 +684,8 @@ export function TeacherAbsencesTab() {
|
|||||||
{absenceSemesterLabel(semester)}
|
{absenceSemesterLabel(semester)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</CustomSelect>
|
||||||
<select
|
<CustomSelect
|
||||||
id="teacher-preference-status-filter"
|
id="teacher-preference-status-filter"
|
||||||
aria-label="Статус пожеланий"
|
aria-label="Статус пожеланий"
|
||||||
value={preferenceStatusFilter}
|
value={preferenceStatusFilter}
|
||||||
@@ -696,7 +697,7 @@ export function TeacherAbsencesTab() {
|
|||||||
<option value="PENDING">На рассмотрении</option>
|
<option value="PENDING">На рассмотрении</option>
|
||||||
<option value="APPROVED">Согласованные</option>
|
<option value="APPROVED">Согласованные</option>
|
||||||
<option value="">Все статусы</option>
|
<option value="">Все статусы</option>
|
||||||
</select>
|
</CustomSelect>
|
||||||
<button
|
<button
|
||||||
className="btn btn-md btn-secondary"
|
className="btn btn-md btn-secondary"
|
||||||
id="teacher-preference-refresh"
|
id="teacher-preference-refresh"
|
||||||
@@ -726,7 +727,7 @@ export function TeacherAbsencesTab() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div className="teacher-dialogue-toolbar">
|
<div className="teacher-dialogue-toolbar">
|
||||||
<select
|
<CustomSelect
|
||||||
id="teacher-change-status-filter"
|
id="teacher-change-status-filter"
|
||||||
aria-label="Статус заявок"
|
aria-label="Статус заявок"
|
||||||
value={changeStatusFilter}
|
value={changeStatusFilter}
|
||||||
@@ -739,7 +740,7 @@ export function TeacherAbsencesTab() {
|
|||||||
<option value="APPROVED">Одобренные</option>
|
<option value="APPROVED">Одобренные</option>
|
||||||
<option value="REJECTED">Отклонённые</option>
|
<option value="REJECTED">Отклонённые</option>
|
||||||
<option value="">Все статусы</option>
|
<option value="">Все статусы</option>
|
||||||
</select>
|
</CustomSelect>
|
||||||
<button
|
<button
|
||||||
className="btn btn-md btn-secondary"
|
className="btn btn-md btn-secondary"
|
||||||
id="teacher-change-refresh"
|
id="teacher-change-refresh"
|
||||||
@@ -919,7 +920,7 @@ function LessonDecision({ lesson, index, locked, value, onChange }) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="absence-lesson-decision">
|
<div className="absence-lesson-decision">
|
||||||
<label htmlFor={`absence-decision-${index}`}>Решение</label>
|
<label htmlFor={`absence-decision-${index}`}>Решение</label>
|
||||||
<select
|
<CustomSelect
|
||||||
id={`absence-decision-${index}`}
|
id={`absence-decision-${index}`}
|
||||||
className="absence-decision-select"
|
className="absence-decision-select"
|
||||||
disabled={locked}
|
disabled={locked}
|
||||||
@@ -930,7 +931,7 @@ function LessonDecision({ lesson, index, locked, value, onChange }) {
|
|||||||
{options.map(option => (
|
{options.map(option => (
|
||||||
<option key={option.value} value={option.value}>{option.label}</option>
|
<option key={option.value} value={option.value}>{option.label}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</CustomSelect>
|
||||||
{lesson.decisionStatus
|
{lesson.decisionStatus
|
||||||
? <span className="absence-decision-fixed">{decisionStatusLabel(lesson.decisionStatus)}</span>
|
? <span className="absence-decision-fixed">{decisionStatusLabel(lesson.decisionStatus)}</span>
|
||||||
: null}
|
: null}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { CustomSelect } from '../../shared/ui/CustomSelect.jsx';
|
||||||
|
|
||||||
import { readUrlState, writeUrlState } from '../../../admin/js/url-state.js';
|
import { readUrlState, writeUrlState } from '../../../admin/js/url-state.js';
|
||||||
import { trackUxEvent } from '../../../telemetry.js';
|
import { trackUxEvent } from '../../../telemetry.js';
|
||||||
@@ -71,6 +72,8 @@ export function TeacherRequestsTab() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function loadRequests(nextState) {
|
async function loadRequests(nextState) {
|
||||||
|
stateRef.current = nextState;
|
||||||
|
setState(nextState);
|
||||||
const currentRequest = requestNumber.current + 1;
|
const currentRequest = requestNumber.current + 1;
|
||||||
requestNumber.current = currentRequest;
|
requestNumber.current = currentRequest;
|
||||||
setTableLoading(true);
|
setTableLoading(true);
|
||||||
@@ -180,7 +183,7 @@ export function TeacherRequestsTab() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="teacher-requests-status">Статус</label>
|
<label htmlFor="teacher-requests-status">Статус</label>
|
||||||
<select
|
<CustomSelect
|
||||||
id="teacher-requests-status"
|
id="teacher-requests-status"
|
||||||
value={state.status}
|
value={state.status}
|
||||||
onChange={event => applyState({ status: event.target.value, page: 0 })}
|
onChange={event => applyState({ status: event.target.value, page: 0 })}
|
||||||
@@ -188,7 +191,7 @@ export function TeacherRequestsTab() {
|
|||||||
{REQUEST_STATUS_OPTIONS.map(option => (
|
{REQUEST_STATUS_OPTIONS.map(option => (
|
||||||
<option key={option.value} value={option.value}>{option.label}</option>
|
<option key={option.value} value={option.value}>{option.label}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</CustomSelect>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="table-wrap">
|
<div className="table-wrap">
|
||||||
@@ -296,7 +299,7 @@ function TeacherRequestRow({ request, departments, onApprove, onReject }) {
|
|||||||
return (
|
return (
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<select
|
<CustomSelect
|
||||||
value={departmentId}
|
value={departmentId}
|
||||||
onChange={event => setDepartmentId(event.target.value)}
|
onChange={event => setDepartmentId(event.target.value)}
|
||||||
>
|
>
|
||||||
@@ -305,7 +308,7 @@ function TeacherRequestRow({ request, departments, onApprove, onReject }) {
|
|||||||
{requestDepartmentLabel(department)}
|
{requestDepartmentLabel(department)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</CustomSelect>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { CustomSelect } from '../../shared/ui/CustomSelect.jsx';
|
||||||
|
|
||||||
import { AlertDialog, ConfirmDialog } from '../../shared/ui/ConfirmDialog.jsx';
|
import { AlertDialog, ConfirmDialog } from '../../shared/ui/ConfirmDialog.jsx';
|
||||||
import { Pagination } from '../../shared/ui/Pagination.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-row form-row--bottom">
|
||||||
<div className="form-group profile-form-spec">
|
<div className="form-group profile-form-spec">
|
||||||
<label htmlFor="tab-profile-spec">Специальность</label>
|
<label htmlFor="tab-profile-spec">Специальность</label>
|
||||||
<select
|
<CustomSelect
|
||||||
id="tab-profile-spec"
|
id="tab-profile-spec"
|
||||||
required
|
required
|
||||||
disabled={profileForm.editing}
|
disabled={profileForm.editing}
|
||||||
@@ -721,7 +722,7 @@ export function UniversityStructureTab() {
|
|||||||
{specialtyOptionLabel(speciality)}
|
{specialtyOptionLabel(speciality)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</CustomSelect>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group profile-form-name">
|
<div className="form-group profile-form-name">
|
||||||
<label htmlFor="tab-profile-name">Название профиля</label>
|
<label htmlFor="tab-profile-name">Название профиля</label>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { CustomSelect } from '../../shared/ui/CustomSelect.jsx';
|
||||||
|
|
||||||
import { readUrlState, writeUrlState } from '../../../admin/js/url-state.js';
|
import { readUrlState, writeUrlState } from '../../../admin/js/url-state.js';
|
||||||
import { trackUxEvent } from '../../../telemetry.js';
|
import { trackUxEvent } from '../../../telemetry.js';
|
||||||
@@ -64,6 +65,8 @@ export function UsersTab() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function loadUsers(nextState) {
|
async function loadUsers(nextState) {
|
||||||
|
stateRef.current = nextState;
|
||||||
|
setState(nextState);
|
||||||
const currentRequest = requestNumber.current + 1;
|
const currentRequest = requestNumber.current + 1;
|
||||||
requestNumber.current = currentRequest;
|
requestNumber.current = currentRequest;
|
||||||
setTableLoading(true);
|
setTableLoading(true);
|
||||||
@@ -204,7 +207,7 @@ export function UsersTab() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="new-role">Роль</label>
|
<label htmlFor="new-role">Роль</label>
|
||||||
<select
|
<CustomSelect
|
||||||
id="new-role"
|
id="new-role"
|
||||||
value={createRole}
|
value={createRole}
|
||||||
onChange={event => setCreateRole(event.target.value)}
|
onChange={event => setCreateRole(event.target.value)}
|
||||||
@@ -215,7 +218,7 @@ export function UsersTab() {
|
|||||||
<option value="EDUCATION_OFFICE">Учебный отдел</option>
|
<option value="EDUCATION_OFFICE">Учебный отдел</option>
|
||||||
<option value="SCHEDULE_VIEWER">Просмотр расписаний</option>
|
<option value="SCHEDULE_VIEWER">Просмотр расписаний</option>
|
||||||
<option value="ADMIN">Администратор</option>
|
<option value="ADMIN">Администратор</option>
|
||||||
</select>
|
</CustomSelect>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="new-fullname">ФИО пользователя</label>
|
<label htmlFor="new-fullname">ФИО пользователя</label>
|
||||||
@@ -276,7 +279,7 @@ export function UsersTab() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="users-role-filter">Роль</label>
|
<label htmlFor="users-role-filter">Роль</label>
|
||||||
<select
|
<CustomSelect
|
||||||
id="users-role-filter"
|
id="users-role-filter"
|
||||||
value={state.role}
|
value={state.role}
|
||||||
onChange={event => applyState({ role: event.target.value, page: 0 })}
|
onChange={event => applyState({ role: event.target.value, page: 0 })}
|
||||||
@@ -285,11 +288,11 @@ export function UsersTab() {
|
|||||||
{ROLE_FILTER_OPTIONS.map(option => (
|
{ROLE_FILTER_OPTIONS.map(option => (
|
||||||
<option key={option.value} value={option.value}>{option.label}</option>
|
<option key={option.value} value={option.value}>{option.label}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</CustomSelect>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="users-sort">Сортировка</label>
|
<label htmlFor="users-sort">Сортировка</label>
|
||||||
<select
|
<CustomSelect
|
||||||
id="users-sort"
|
id="users-sort"
|
||||||
value={state.sort}
|
value={state.sort}
|
||||||
onChange={event => applyState({ sort: event.target.value, page: 0 })}
|
onChange={event => applyState({ sort: event.target.value, page: 0 })}
|
||||||
@@ -297,7 +300,7 @@ export function UsersTab() {
|
|||||||
{USER_SORT_OPTIONS.map(option => (
|
{USER_SORT_OPTIONS.map(option => (
|
||||||
<option key={option.value} value={option.value}>{option.label}</option>
|
<option key={option.value} value={option.value}>{option.label}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</CustomSelect>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="table-wrap">
|
<div className="table-wrap">
|
||||||
|
|||||||
@@ -119,7 +119,8 @@ export function preferenceDescription(item) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function absenceSemesterLabel(semester) {
|
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)}`;
|
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 { SESSION_PHASES } from '../shared/session-policy.js';
|
||||||
import { useAuthorizedSession } from '../shared/use-authorized-session.js';
|
import { useAuthorizedSession } from '../shared/use-authorized-session.js';
|
||||||
import { AsyncState } from '../shared/ui/AsyncState.jsx';
|
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 { DatabaseTab } from './DatabaseTab.jsx';
|
||||||
import { EduFormsTab } from './EduFormsTab.jsx';
|
import { EduFormsTab } from './EduFormsTab.jsx';
|
||||||
import { GeneralTab } from './GeneralTab.jsx';
|
import { GeneralTab } from './GeneralTab.jsx';
|
||||||
@@ -15,7 +17,6 @@ import {
|
|||||||
} from './settings-service.js';
|
} from './settings-service.js';
|
||||||
|
|
||||||
const THEME_STORAGE_KEY = 'theme';
|
const THEME_STORAGE_KEY = 'theme';
|
||||||
const SIDEBAR_STORAGE_KEY = 'sidebar-collapsed';
|
|
||||||
|
|
||||||
const TAB_ICONS = Object.freeze({
|
const TAB_ICONS = Object.freeze({
|
||||||
database: (
|
database: (
|
||||||
@@ -72,19 +73,7 @@ export function SettingsApp() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function SettingsShell({ title, sidebar, children }) {
|
function SettingsShell({ title, sidebar, children }) {
|
||||||
const [collapsed, setCollapsed] = useState(() => (
|
const { collapsed, mobileOpen, toggleSidebar, closeSidebar } = useSidebar();
|
||||||
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() {
|
function toggleTheme() {
|
||||||
const next = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';
|
const next = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';
|
||||||
@@ -99,7 +88,7 @@ function SettingsShell({ title, sidebar, children }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<aside className={`sidebar${collapsed ? ' collapsed' : ''}`}>
|
<aside id="app-sidebar" className={`sidebar${collapsed && !mobileOpen ? ' collapsed' : ''}${mobileOpen ? ' open' : ''}`}>
|
||||||
<div className="sidebar-header">
|
<div className="sidebar-header">
|
||||||
<div className="logo">
|
<div className="logo">
|
||||||
<svg width="32" height="32" viewBox="0 0 40 40" fill="none">
|
<svg width="32" height="32" viewBox="0 0 40 40" fill="none">
|
||||||
@@ -127,7 +116,9 @@ function SettingsShell({ title, sidebar, children }) {
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{sidebar}
|
<div className="sidebar-nav-container" onClick={event => {
|
||||||
|
if (event.target.closest('.nav-item')) closeSidebar();
|
||||||
|
}}>{sidebar}</div>
|
||||||
<div className="sidebar-footer">
|
<div className="sidebar-footer">
|
||||||
<a href="/admin/" className="btn-back">
|
<a href="/admin/" className="btn-back">
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
|
<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>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<div className="sidebar-overlay" />
|
<div className={`sidebar-overlay${mobileOpen ? ' open' : ''}`} onClick={closeSidebar} />
|
||||||
|
|
||||||
<main className={`main${collapsed ? ' sidebar-collapsed' : ''}`}>
|
<main className={`main${collapsed ? ' sidebar-collapsed' : ''}`}>
|
||||||
<header className="topbar">
|
<header className="topbar">
|
||||||
|
<SidebarToggle open={mobileOpen} onClick={toggleSidebar} />
|
||||||
<h1>{title}</h1>
|
<h1>{title}</h1>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export function AsyncCombobox({
|
|||||||
const inputRef = useRef(null);
|
const inputRef = useRef(null);
|
||||||
const debounceTimer = useRef(null);
|
const debounceTimer = useRef(null);
|
||||||
const requestNumber = useRef(0);
|
const requestNumber = useRef(0);
|
||||||
|
const restoringFocus = useRef(false);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const [items, setItems] = useState([]);
|
const [items, setItems] = useState([]);
|
||||||
@@ -38,8 +39,8 @@ export function AsyncCombobox({
|
|||||||
close();
|
close();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
document.addEventListener('mousedown', handlePointerDown);
|
document.addEventListener('pointerdown', handlePointerDown);
|
||||||
return () => document.removeEventListener('mousedown', handlePointerDown);
|
return () => document.removeEventListener('pointerdown', handlePointerDown);
|
||||||
});
|
});
|
||||||
|
|
||||||
async function search(nextQuery) {
|
async function search(nextQuery) {
|
||||||
@@ -73,16 +74,24 @@ export function AsyncCombobox({
|
|||||||
function choose(item) {
|
function choose(item) {
|
||||||
onSelect(item);
|
onSelect(item);
|
||||||
close();
|
close();
|
||||||
|
restoreInputFocus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreInputFocus() {
|
||||||
|
restoringFocus.current = true;
|
||||||
|
inputRef.current?.focus();
|
||||||
|
restoringFocus.current = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function clear() {
|
function clear() {
|
||||||
onSelect(null);
|
onSelect(null);
|
||||||
setQuery('');
|
setQuery('');
|
||||||
inputRef.current?.focus();
|
restoreInputFocus();
|
||||||
void search('');
|
void search('');
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleFocus() {
|
function handleFocus() {
|
||||||
|
if (restoringFocus.current || open) return;
|
||||||
setQuery(selectedLabel);
|
setQuery(selectedLabel);
|
||||||
void search(selectedLabel);
|
void search(selectedLabel);
|
||||||
}
|
}
|
||||||
@@ -90,6 +99,12 @@ export function AsyncCombobox({
|
|||||||
function handleInput(event) {
|
function handleInput(event) {
|
||||||
const value = event.target.value;
|
const value = event.target.value;
|
||||||
setQuery(value);
|
setQuery(value);
|
||||||
|
// Старый ответ не должен стать доступен во время задержки нового поиска.
|
||||||
|
requestNumber.current += 1;
|
||||||
|
setOpen(true);
|
||||||
|
setItems([]);
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
window.clearTimeout(debounceTimer.current);
|
window.clearTimeout(debounceTimer.current);
|
||||||
debounceTimer.current = window.setTimeout(() => {
|
debounceTimer.current = window.setTimeout(() => {
|
||||||
void search(value);
|
void search(value);
|
||||||
@@ -98,6 +113,7 @@ export function AsyncCombobox({
|
|||||||
|
|
||||||
function handleInputKeydown(event) {
|
function handleInputKeydown(event) {
|
||||||
if (event.key === 'Escape') {
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
close();
|
close();
|
||||||
} else if (event.key === 'ArrowDown') {
|
} else if (event.key === 'ArrowDown') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -111,7 +127,7 @@ export function AsyncCombobox({
|
|||||||
if (event.key === 'Escape') {
|
if (event.key === 'Escape') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
close();
|
close();
|
||||||
inputRef.current?.focus();
|
restoreInputFocus();
|
||||||
} else if (event.key === 'ArrowDown' && current >= 0) {
|
} else if (event.key === 'ArrowDown' && current >= 0) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
options[Math.min(current + 1, options.length - 1)]?.focus();
|
options[Math.min(current + 1, options.length - 1)]?.focus();
|
||||||
@@ -122,7 +138,13 @@ export function AsyncCombobox({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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">
|
<div className="async-combobox-control">
|
||||||
<input
|
<input
|
||||||
type="search"
|
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];
|
const PAGE_SIZES = [25, 50, 100];
|
||||||
|
|
||||||
export function Pagination({
|
export function Pagination({
|
||||||
@@ -25,14 +26,14 @@ export function Pagination({
|
|||||||
<div className="pagination-actions">
|
<div className="pagination-actions">
|
||||||
<label className="pagination-size">
|
<label className="pagination-size">
|
||||||
<span>На странице</span>
|
<span>На странице</span>
|
||||||
<select
|
<CustomSelect
|
||||||
value={pageSize}
|
value={pageSize}
|
||||||
onChange={event => onSize?.(Number(event.target.value))}
|
onChange={event => onSize?.(Number(event.target.value))}
|
||||||
>
|
>
|
||||||
{PAGE_SIZES.map(value => (
|
{PAGE_SIZES.map(value => (
|
||||||
<option key={value} value={value}>{value}</option>
|
<option key={value} value={value}>{value}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</CustomSelect>
|
||||||
</label>
|
</label>
|
||||||
<button
|
<button
|
||||||
type="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) };
|
||||||
|
}
|
||||||
@@ -80,7 +80,7 @@ const ENTRY_OUTPUT_PATHS = Object.freeze({
|
|||||||
runtime: 'dist/react/shared/runtime.js'
|
runtime: 'dist/react/shared/runtime.js'
|
||||||
});
|
});
|
||||||
|
|
||||||
function chunkReachability() {
|
function chunkReachability(includeDynamic = true) {
|
||||||
const reach = new Map();
|
const reach = new Map();
|
||||||
for (const [entryName, entryPath] of Object.entries(ENTRY_OUTPUT_PATHS)) {
|
for (const [entryName, entryPath] of Object.entries(ENTRY_OUTPUT_PATHS)) {
|
||||||
const stack = [entryPath];
|
const stack = [entryPath];
|
||||||
@@ -90,6 +90,7 @@ function chunkReachability() {
|
|||||||
if (seen.has(path) || !outputs[path]) continue;
|
if (seen.has(path) || !outputs[path]) continue;
|
||||||
seen.add(path);
|
seen.add(path);
|
||||||
for (const imported of outputs[path].imports || []) {
|
for (const imported of outputs[path].imports || []) {
|
||||||
|
if (!includeDynamic && imported.kind === 'dynamic-import') continue;
|
||||||
stack.push(imported.path);
|
stack.push(imported.path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -102,6 +103,7 @@ function chunkReachability() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const reach = chunkReachability();
|
const reach = chunkReachability();
|
||||||
|
const coldReach = chunkReachability(false);
|
||||||
const adminLazyChunkPaths = sharedChunkPaths.filter(path => {
|
const adminLazyChunkPaths = sharedChunkPaths.filter(path => {
|
||||||
const entries = reach.get(path);
|
const entries = reach.get(path);
|
||||||
return entries !== undefined && entries.size > 0 && [...entries].every(name => name === 'admin');
|
return entries !== undefined && entries.size > 0 && [...entries].every(name => name === 'admin');
|
||||||
@@ -128,7 +130,12 @@ for (const [name, path, limit, coldLimit] of [
|
|||||||
]) {
|
]) {
|
||||||
const rawBytes = outputBytes(path);
|
const rawBytes = outputBytes(path);
|
||||||
const gzipBytes = await gzipSize(path);
|
const gzipBytes = await gzipSize(path);
|
||||||
const coldLoadGzipBytes = gzipBytes + sharedChunkGzipBytes;
|
// Холодная загрузка включает только статические зависимости этой страницы.
|
||||||
|
// Общий пул содержит также модули других кабинетов и не загружается целиком.
|
||||||
|
const entryName = Object.keys(ENTRY_OUTPUT_PATHS).find(name => ENTRY_OUTPUT_PATHS[name] === path);
|
||||||
|
const coldChunkPaths = sharedChunkPaths.filter(chunk => coldReach.get(chunk)?.has(entryName));
|
||||||
|
const coldLoadGzipBytes = gzipBytes + (await Promise.all(coldChunkPaths.map(gzipSize)))
|
||||||
|
.reduce((total, bytes) => total + bytes, 0);
|
||||||
entryReports.push({ name, path, rawBytes, gzipBytes, coldLoadGzipBytes, limit, coldLimit });
|
entryReports.push({ name, path, rawBytes, gzipBytes, coldLoadGzipBytes, limit, coldLimit });
|
||||||
|
|
||||||
if (gzipBytes > limit) {
|
if (gzipBytes > limit) {
|
||||||
|
|||||||
@@ -498,8 +498,6 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
backdrop-filter: blur(12px);
|
|
||||||
-webkit-backdrop-filter: blur(12px);
|
|
||||||
transition: background 0.3s ease, transform 0.3s ease, box-shadow 0.3s ease;
|
transition: background 0.3s ease, transform 0.3s ease, box-shadow 0.3s ease;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|||||||
@@ -680,10 +680,10 @@ body {
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: transform .16s ease, border-color .16s ease, background .16s ease;
|
transition: border-color .16s ease, background .16s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preference-cell:hover { transform: translateY(-2px); border-color: var(--accent); }
|
.preference-cell:hover { border-color: var(--accent); }
|
||||||
.preference-cell span { font-size: 19px; font-weight: 900; }
|
.preference-cell span { font-size: 19px; font-weight: 900; }
|
||||||
.preference-cell.is-preferred { border-color: #10b981; background: color-mix(in srgb, #10b981 15%, transparent); color: #047857; }
|
.preference-cell.is-preferred { border-color: #10b981; background: color-mix(in srgb, #10b981 15%, transparent); color: #047857; }
|
||||||
.preference-cell.is-unwanted { border-color: #f59e0b; background: color-mix(in srgb, #f59e0b 15%, transparent); color: #b45309; }
|
.preference-cell.is-unwanted { border-color: #f59e0b; background: color-mix(in srgb, #f59e0b 15%, transparent); color: #b45309; }
|
||||||
@@ -945,4 +945,3 @@ body {
|
|||||||
.request-ledger-side { grid-column: 2; align-items: start; }
|
.request-ledger-side { grid-column: 2; align-items: start; }
|
||||||
.request-dialog footer { flex-direction: column-reverse; }
|
.request-dialog footer { flex-direction: column-reverse; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
152
frontend/tests/browser/shared-controls.test.mjs
Normal file
152
frontend/tests/browser/shared-controls.test.mjs
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { after, before, test } from 'node:test';
|
||||||
|
import { build } from 'esbuild';
|
||||||
|
import { chromium } from 'playwright';
|
||||||
|
|
||||||
|
let browser;
|
||||||
|
let bundle;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
const result = await build({
|
||||||
|
stdin: {
|
||||||
|
contents: `
|
||||||
|
import React from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { AsyncCombobox } from './react/shared/ui/AsyncCombobox.jsx';
|
||||||
|
import { CustomSelect } from './react/shared/ui/CustomSelect.jsx';
|
||||||
|
window.requests = [];
|
||||||
|
window.selected = null;
|
||||||
|
window.submitted = false;
|
||||||
|
function RequiredSelectForm() {
|
||||||
|
const [value, setValue] = React.useState('');
|
||||||
|
return <form onSubmit={event => { event.preventDefault(); window.submitted = true; }}>
|
||||||
|
<label htmlFor="required-select">Обязательный выбор</label>
|
||||||
|
<CustomSelect id="required-select" name="choice" required value={value} onChange={event => setValue(event.target.value)}>
|
||||||
|
<option value="">Выберите вариант</option>
|
||||||
|
<option value="valid">Правильный вариант</option>
|
||||||
|
</CustomSelect>
|
||||||
|
<button type="submit">Отправить</button>
|
||||||
|
</form>;
|
||||||
|
}
|
||||||
|
createRoot(document.getElementById('root')).render(
|
||||||
|
<><AsyncCombobox
|
||||||
|
selectedLabel="Выбранная группа"
|
||||||
|
selectedValue="1"
|
||||||
|
load={query => new Promise((resolve, reject) => {
|
||||||
|
window.requests.push({ query, resolve, reject });
|
||||||
|
})}
|
||||||
|
onSelect={item => { window.selected = item; }}
|
||||||
|
/><RequiredSelectForm /></>
|
||||||
|
);
|
||||||
|
`,
|
||||||
|
resolveDir: process.cwd(),
|
||||||
|
loader: 'jsx'
|
||||||
|
},
|
||||||
|
bundle: true,
|
||||||
|
write: false,
|
||||||
|
format: 'iife',
|
||||||
|
jsx: 'automatic',
|
||||||
|
define: { 'process.env.NODE_ENV': '"test"' }
|
||||||
|
});
|
||||||
|
bundle = result.outputFiles[0].text;
|
||||||
|
browser = await chromium.launch({ executablePath: process.env.CHROMIUM_PATH || '/usr/bin/chromium' });
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => { await browser?.close(); });
|
||||||
|
|
||||||
|
async function fixture(t) {
|
||||||
|
const page = await browser.newPage();
|
||||||
|
const errors = [];
|
||||||
|
page.on('pageerror', error => errors.push(error.message));
|
||||||
|
t.after(async () => {
|
||||||
|
await page.close();
|
||||||
|
assert.deepEqual(errors, [], 'Компонент вызвал ошибку JavaScript');
|
||||||
|
});
|
||||||
|
await page.setContent('<style>.async-combobox-menu { position: absolute; }</style><div id="root"></div><button id="outside">Другой элемент</button>');
|
||||||
|
await page.addScriptTag({ content: bundle });
|
||||||
|
const input = page.getByRole('combobox', { name: 'Поисковый список', exact: true });
|
||||||
|
await input.focus();
|
||||||
|
await page.waitForFunction(() => window.requests.length === 1);
|
||||||
|
return { page, input };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('устаревший ответ или ошибка не заменяют новый поиск во время задержки', async t => {
|
||||||
|
for (const outcome of ['resolve', 'reject']) {
|
||||||
|
const { page, input } = await fixture(t);
|
||||||
|
await input.fill('Новая группа');
|
||||||
|
await page.evaluate(outcome => {
|
||||||
|
const request = window.requests[0];
|
||||||
|
if (outcome === 'resolve') request.resolve([{ id: 2, name: 'Устаревшая группа' }]);
|
||||||
|
else request.reject(new Error('Ошибка старого запроса'));
|
||||||
|
}, outcome);
|
||||||
|
assert.equal(await page.getByRole('option').count(), 0, 'Устаревший вариант доступен для выбора');
|
||||||
|
await page.getByText('Поиск...', { exact: true }).waitFor();
|
||||||
|
await page.waitForFunction(() => window.requests.length === 2);
|
||||||
|
await page.evaluate(() => window.requests[1].resolve([{ id: 3, name: 'Новая группа' }]));
|
||||||
|
await page.getByRole('option', { name: 'Новая группа' }).waitFor();
|
||||||
|
assert.equal(await page.getByRole('option').count(), 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Escape из списка возвращает фокус и оставляет меню закрытым', async t => {
|
||||||
|
const { page, input } = await fixture(t);
|
||||||
|
await page.evaluate(() => window.requests[0].resolve([{ id: 2, name: 'Группа' }]));
|
||||||
|
await page.getByRole('option').waitFor();
|
||||||
|
await input.press('ArrowDown');
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
assert.equal(await input.getAttribute('aria-expanded'), 'false');
|
||||||
|
assert.equal(await input.evaluate(element => element === document.activeElement), true);
|
||||||
|
assert.equal(await page.evaluate(() => window.requests.length), 1, 'Возврат фокуса запустил лишний поиск');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('возврат стрелкой вверх сохраняет поисковый текст и результаты', async t => {
|
||||||
|
const { page, input } = await fixture(t);
|
||||||
|
await input.fill('Запрос');
|
||||||
|
await page.waitForFunction(() => window.requests.length === 2);
|
||||||
|
await page.evaluate(() => window.requests[1].resolve([{ id: 2, name: 'Результат' }]));
|
||||||
|
await page.getByRole('option').waitFor();
|
||||||
|
await input.press('ArrowDown');
|
||||||
|
await page.keyboard.press('ArrowUp');
|
||||||
|
assert.equal(await input.inputValue(), 'Запрос');
|
||||||
|
assert.equal(await page.evaluate(() => window.requests.length), 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ввод после Escape открывает поиск, а уход фокуса отменяет отложенный запрос', async t => {
|
||||||
|
const { page, input } = await fixture(t);
|
||||||
|
await input.press('Escape');
|
||||||
|
await input.fill('Новый запрос');
|
||||||
|
assert.equal(await input.getAttribute('aria-expanded'), 'true');
|
||||||
|
await page.locator('#outside').focus();
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
assert.equal(await input.getAttribute('aria-expanded'), 'false');
|
||||||
|
assert.equal(await page.evaluate(() => window.requests.length), 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('выбор варианта сохраняет значение и возвращает фокус без нового поиска', async t => {
|
||||||
|
const { page, input } = await fixture(t);
|
||||||
|
await page.evaluate(() => window.requests[0].resolve([{ id: 2, name: 'Группа' }]));
|
||||||
|
await page.getByRole('option').click();
|
||||||
|
assert.deepEqual(await page.evaluate(() => window.selected), { id: 2, name: 'Группа' });
|
||||||
|
assert.equal(await input.getAttribute('aria-expanded'), 'false');
|
||||||
|
assert.equal(await input.evaluate(element => element === document.activeElement), true);
|
||||||
|
assert.equal(await page.evaluate(() => window.requests.length), 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('обязательный селект показывает ошибку, получает фокус и разрешает отправку после выбора', async t => {
|
||||||
|
const { page } = await fixture(t);
|
||||||
|
const errors = [];
|
||||||
|
page.on('console', message => { if (message.type() === 'error') errors.push(message.text()); });
|
||||||
|
const select = page.getByRole('combobox', { name: 'Обязательный выбор', exact: true });
|
||||||
|
await page.getByRole('button', { name: 'Отправить', exact: true }).click();
|
||||||
|
assert.equal(await page.evaluate(() => window.submitted), false);
|
||||||
|
await page.waitForFunction(() => document.activeElement?.getAttribute('aria-label') === 'Обязательный выбор');
|
||||||
|
assert.equal(await select.evaluate(element => element === document.activeElement), true);
|
||||||
|
assert.equal(await select.getAttribute('aria-invalid'), 'true');
|
||||||
|
await page.getByRole('alert').filter({ hasText: 'Выберите значение' }).waitFor();
|
||||||
|
await select.click();
|
||||||
|
await page.getByRole('option', { name: 'Правильный вариант', exact: true }).click();
|
||||||
|
assert.equal(await select.getAttribute('aria-invalid'), null);
|
||||||
|
await page.getByRole('button', { name: 'Отправить', exact: true }).click();
|
||||||
|
assert.equal(await page.evaluate(() => window.submitted), true);
|
||||||
|
assert.deepEqual(errors, [], 'Браузер не смог сфокусировать обязательный селект');
|
||||||
|
});
|
||||||
254
frontend/tests/browser/ui-regressions.test.mjs
Normal file
254
frontend/tests/browser/ui-regressions.test.mjs
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { after, before, test } from 'node:test';
|
||||||
|
import { chromium } from 'playwright';
|
||||||
|
|
||||||
|
const baseURL = process.env.FRONTEND_TEST_BASE_URL || 'https://localhost';
|
||||||
|
let browser;
|
||||||
|
let context;
|
||||||
|
let page;
|
||||||
|
const errors = [];
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
browser = await chromium.launch({
|
||||||
|
executablePath: process.env.CHROMIUM_PATH || '/usr/bin/chromium',
|
||||||
|
headless: true
|
||||||
|
});
|
||||||
|
context = await browser.newContext({
|
||||||
|
baseURL,
|
||||||
|
ignoreHTTPSErrors: true,
|
||||||
|
viewport: { width: 1440, height: 1000 }
|
||||||
|
});
|
||||||
|
page = await context.newPage();
|
||||||
|
page.on('pageerror', error => errors.push(error.message));
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await browser?.close();
|
||||||
|
assert.deepEqual(errors, [], 'Во время проверки возникли ошибки JavaScript');
|
||||||
|
});
|
||||||
|
|
||||||
|
async function assertStableHover(locator) {
|
||||||
|
if (!await locator.isVisible() || !await locator.isEnabled()) return;
|
||||||
|
await locator.scrollIntoViewIfNeeded();
|
||||||
|
await page.mouse.move(0, 0);
|
||||||
|
await page.waitForTimeout(250);
|
||||||
|
const before = await locator.boundingBox();
|
||||||
|
await locator.evaluate(element => {
|
||||||
|
window.hoverLeaves = 0;
|
||||||
|
element.addEventListener('mouseleave', () => { window.hoverLeaves += 1; }, { once: true });
|
||||||
|
});
|
||||||
|
// Курсор внутри нижней кромки: старый translateY(-1px) выводил кнопку из-под него.
|
||||||
|
await page.mouse.move(before.x + before.width / 2, before.y + before.height - 0.2);
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
const after = await locator.boundingBox();
|
||||||
|
assert(Math.abs(before.x - after.x) < 0.01, 'Элемент сместился по горизонтали при наведении');
|
||||||
|
assert(Math.abs(before.y - after.y) < 0.01, 'Кнопка сместилась при наведении');
|
||||||
|
assert.equal(await page.evaluate(() => window.hoverLeaves), 0, 'Кнопка теряет наведение у края');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertSelectsSynchronized() {
|
||||||
|
await page.waitForTimeout(150);
|
||||||
|
const values = await page.locator('.react-custom-select').evaluateAll(roots => roots.map(root => {
|
||||||
|
const select = root.querySelector('select');
|
||||||
|
return {
|
||||||
|
id: select.id,
|
||||||
|
text: select.options[select.selectedIndex]?.text || '—',
|
||||||
|
triggerText: root.querySelector('.custom-select-text').textContent,
|
||||||
|
disabled: select.disabled,
|
||||||
|
triggerDisabled: root.querySelector('.custom-select-trigger').disabled
|
||||||
|
};
|
||||||
|
}));
|
||||||
|
for (const value of values) {
|
||||||
|
assert.equal(value.triggerText, value.text, `Устаревшая подпись ${value.id}`);
|
||||||
|
assert.equal(value.triggerDisabled, value.disabled, `Устаревшее состояние disabled ${value.id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function choose(id, name) {
|
||||||
|
const root = page.locator('.react-custom-select').filter({ has: page.locator(id) });
|
||||||
|
const value = await root.locator('select').evaluate((select, text) => (
|
||||||
|
Array.from(select.options).find(option => option.text === text)?.value
|
||||||
|
), name);
|
||||||
|
await root.locator('.custom-select-trigger').click();
|
||||||
|
await root.getByRole('option', { name, exact: true }).click();
|
||||||
|
await page.waitForFunction(({ id, value }) => document.querySelector(id)?.value === value, { id, value });
|
||||||
|
}
|
||||||
|
|
||||||
|
test('кнопки входа стабильны при наведении в обеих темах', async () => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.locator('#btn-submit').waitFor();
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
for (const theme of ['light', 'dark']) {
|
||||||
|
await page.evaluate(value => { document.documentElement.dataset.theme = value; }, theme);
|
||||||
|
for (const selector of ['#btn-submit', '.toggle-password', '.theme-toggle']) {
|
||||||
|
await assertStableHover(page.locator(selector));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.deepEqual(await page.locator('#btn-submit').evaluate(element => ({
|
||||||
|
position: getComputedStyle(element).position,
|
||||||
|
overflow: getComputedStyle(element).overflow
|
||||||
|
})), { position: 'relative', overflow: 'hidden' }, 'Волна нажатия должна оставаться внутри кнопки');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('вкладки админки сохраняют стабильные кнопки и актуальные селекты', async () => {
|
||||||
|
await page.locator('#username').fill(process.env.FRONTEND_TEST_LOGIN || 'admin');
|
||||||
|
await page.locator('#password').fill(process.env.FRONTEND_TEST_PASSWORD || 'admin');
|
||||||
|
await page.locator('#btn-submit').click();
|
||||||
|
await page.waitForURL('**/admin/**');
|
||||||
|
await page.locator('.nav-item').first().waitFor();
|
||||||
|
assert.equal(await page.locator('.menu-toggle').isVisible(), false, 'Десктопная кнопка мобильного меню скрыта');
|
||||||
|
const tabs = await page.locator('.nav-item').evaluateAll(items => items.map(item => item.dataset.tab));
|
||||||
|
assert(tabs.length > 0, 'Не появились вкладки админки');
|
||||||
|
for (const theme of ['light', 'dark']) {
|
||||||
|
await page.evaluate(value => { document.documentElement.dataset.theme = value; }, theme);
|
||||||
|
await assertStableHover(page.locator('.sidebar .nav-item').first());
|
||||||
|
for (const tab of tabs) {
|
||||||
|
await page.locator(`.nav-item[data-tab="${tab}"]`).click();
|
||||||
|
await page.locator('.tab-loading').waitFor({ state: 'hidden' });
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
await assertSelectsSynchronized();
|
||||||
|
await assertStableHover(page.locator('.theme-toggle'));
|
||||||
|
const button = page.locator('#app-content .btn:visible').first();
|
||||||
|
if (await button.count()) await assertStableHover(button);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await page.locator('.nav-item[data-tab="users"]').click();
|
||||||
|
await page.locator('#new-role').waitFor({ state: 'attached' });
|
||||||
|
await choose('#new-role', 'Преподаватель');
|
||||||
|
await assertSelectsSynchronized();
|
||||||
|
await choose('#new-role', 'Студент');
|
||||||
|
await assertSelectsSynchronized();
|
||||||
|
await choose('#users-role-filter', 'Преподаватель');
|
||||||
|
assert.equal(await page.locator('#users-role-filter').inputValue(), 'TEACHER');
|
||||||
|
await choose('#users-role-filter', 'Все роли');
|
||||||
|
assert.equal(await page.locator('#users-role-filter').inputValue(), '');
|
||||||
|
await assertSelectsSynchronized();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('мобильное меню админки и настроек открывается и закрывается', async () => {
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
for (const path of ['/admin/', '/admin/settings/']) {
|
||||||
|
await page.goto(path);
|
||||||
|
const toggle = page.getByRole('button', { name: 'Открыть меню', exact: true });
|
||||||
|
await toggle.click();
|
||||||
|
assert.equal(await page.locator('.sidebar').evaluate(element => element.classList.contains('open')), true);
|
||||||
|
const destination = path === '/admin/'
|
||||||
|
? page.locator('.nav-item[data-tab="groups"]')
|
||||||
|
: page.locator('.sidebar .nav-item').first();
|
||||||
|
await destination.click();
|
||||||
|
await page.locator('.tab-loading').waitFor({ state: 'hidden' });
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
assert.equal(await page.locator('.sidebar').evaluate(element => element.classList.contains('open')), false);
|
||||||
|
await toggle.click();
|
||||||
|
await page.locator('.sidebar-overlay').click({ position: { x: 350, y: 300 } });
|
||||||
|
assert.equal(await page.locator('.sidebar').evaluate(element => element.classList.contains('open')), false);
|
||||||
|
await toggle.click();
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
assert.equal(await page.locator('.sidebar').evaluate(element => element.classList.contains('open')), false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('кнопки календаря пожеланий преподавателя не смещаются при наведении', async () => {
|
||||||
|
await page.setViewportSize({ width: 1440, height: 1000 });
|
||||||
|
await page.goto('/');
|
||||||
|
await page.locator('#username').fill(process.env.FRONTEND_TEST_TEACHER_LOGIN || 'Тестовый преподаватель');
|
||||||
|
await page.locator('#password').fill(process.env.FRONTEND_TEST_TEACHER_PASSWORD || '1234567890');
|
||||||
|
await page.locator('#btn-submit').click();
|
||||||
|
await page.waitForURL('**/teacher/**');
|
||||||
|
await page.locator('.workspace-tab').filter({ hasText: 'Пожелания' }).click();
|
||||||
|
await page.locator('.preference-cell').first().waitFor();
|
||||||
|
for (const theme of ['light', 'dark']) {
|
||||||
|
await page.evaluate(value => { document.documentElement.dataset.theme = value; }, theme);
|
||||||
|
await assertStableHover(page.locator('.preference-cell:enabled').first());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('карточки дашборда не перерисовываются при наведении на боковое меню', async () => {
|
||||||
|
await page.setViewportSize({ width: 1440, height: 1000 });
|
||||||
|
await page.goto('/');
|
||||||
|
await page.locator('#username').fill(process.env.FRONTEND_TEST_LOGIN || 'admin');
|
||||||
|
await page.locator('#password').fill(process.env.FRONTEND_TEST_PASSWORD || 'admin');
|
||||||
|
await page.locator('#btn-submit').click();
|
||||||
|
await page.waitForURL('**/admin/**');
|
||||||
|
await page.locator('.nav-item[data-tab="dashboard"]').click();
|
||||||
|
await page.locator('.dashboard-metric-card').first().waitFor();
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
await page.locator('[aria-busy="true"]').waitFor({ state: 'hidden' });
|
||||||
|
|
||||||
|
for (const theme of ['dark', 'light']) {
|
||||||
|
await page.evaluate(value => { document.documentElement.dataset.theme = value; }, theme);
|
||||||
|
await page.mouse.move(0, 0);
|
||||||
|
await page.waitForTimeout(600);
|
||||||
|
const surfaces = await page.locator('.card, .sidebar').evaluateAll(elements => elements.map(element => {
|
||||||
|
const style = getComputedStyle(element);
|
||||||
|
return {
|
||||||
|
filter: style.backdropFilter,
|
||||||
|
transform: style.transform,
|
||||||
|
color: style.backgroundColor
|
||||||
|
};
|
||||||
|
}));
|
||||||
|
for (const surface of surfaces) {
|
||||||
|
assert.equal(surface.filter, 'none', 'На постоянной поверхности остался фоновой фильтр');
|
||||||
|
assert.equal(surface.transform, 'none', 'На карточке осталась трансформация после появления');
|
||||||
|
assert.match(surface.color, /^rgb\(/, 'Фон постоянной поверхности должен быть непрозрачным');
|
||||||
|
}
|
||||||
|
for (const selector of ['.dashboard-metrics-grid', '.dashboard-monitor-card', '.red-zone-card']) {
|
||||||
|
const card = page.locator(selector);
|
||||||
|
await page.mouse.move(0, 0);
|
||||||
|
const before = await card.screenshot();
|
||||||
|
await page.locator('.nav-item[data-tab="schedule"]').hover();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
const after = await card.screenshot();
|
||||||
|
assert(before.equals(after), `${theme}: изображение ${selector} изменилось при наведении на меню`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('быстрые изменения фильтра и сортировки сохраняют оба параметра запроса', async () => {
|
||||||
|
await page.locator('.nav-item[data-tab="users"]').click();
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
const requests = [];
|
||||||
|
let releaseFirst;
|
||||||
|
const firstBlocked = new Promise(resolve => { releaseFirst = resolve; });
|
||||||
|
await page.route('**/api/users/page?**', async route => {
|
||||||
|
requests.push(new URL(route.request().url()));
|
||||||
|
if (requests.length === 1) await firstBlocked;
|
||||||
|
await route.fulfill({ json: { items: [], totalItems: 0, totalPages: 0, page: 0, size: 25 } });
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await choose('#users-role-filter', 'Преподаватель');
|
||||||
|
const sortedResponse = page.waitForResponse(response => response.url().includes('/api/users/page?') && response.url().includes('sort=username'));
|
||||||
|
await choose('#users-sort', 'По логину');
|
||||||
|
await sortedResponse;
|
||||||
|
assert.equal(requests.at(-1).searchParams.get('role'), 'TEACHER', 'Сортировка отменила выбранную роль');
|
||||||
|
assert.equal(requests.at(-1).searchParams.get('sort'), 'username');
|
||||||
|
releaseFirst();
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
assert.equal(await page.locator('#users-role-filter').inputValue(), 'TEACHER', 'Старый ответ отменил роль');
|
||||||
|
assert.equal(await page.locator('#users-sort').inputValue(), 'username,asc');
|
||||||
|
} finally {
|
||||||
|
releaseFirst();
|
||||||
|
await page.unroute('**/api/users/page?**');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('сброс фильтров групп отменяет отложенный поиск', async () => {
|
||||||
|
await page.locator('.nav-item[data-tab="groups"]').click();
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
const queries = [];
|
||||||
|
await page.route('**/api/groups/page?**', async route => {
|
||||||
|
queries.push(new URL(route.request().url()).searchParams.get('query'));
|
||||||
|
await route.fulfill({ json: { items: [], totalItems: 0, totalPages: 0, page: 0, size: 25 } });
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await page.locator('#groups-search').fill('Отменённый поиск');
|
||||||
|
await page.getByRole('button', { name: 'Сбросить фильтры', exact: true }).click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
assert.equal(await page.locator('#groups-search').inputValue(), '');
|
||||||
|
assert(queries.length > 0, 'Сброс не обновил список');
|
||||||
|
assert(queries.every(query => query === ''), 'После сброса запустился старый поиск');
|
||||||
|
} finally {
|
||||||
|
await page.unroute('**/api/groups/page?**');
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -3,11 +3,26 @@ import { readFile } from 'node:fs/promises';
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
absenceSemesterLabel,
|
||||||
decisionOptions,
|
decisionOptions,
|
||||||
parseDecisionOption,
|
parseDecisionOption,
|
||||||
teacherAbsenceTeacherOptionsUrl
|
teacherAbsenceTeacherOptionsUrl
|
||||||
} from '../react/admin/teacher-absences-service.js';
|
} from '../react/admin/teacher-absences-service.js';
|
||||||
|
|
||||||
|
test('подпись семестра на русском принимает регистр API расписания и пожеланий', () => {
|
||||||
|
for (const [semesterType, expected] of [
|
||||||
|
['autumn', 'осенний'], ['AUTUMN', 'осенний'],
|
||||||
|
['spring', 'весенний'], ['SPRING', 'весенний']
|
||||||
|
]) {
|
||||||
|
assert.equal(absenceSemesterLabel({
|
||||||
|
semesterType,
|
||||||
|
academicYearTitle: '2026–2027',
|
||||||
|
startDate: '2026-09-01',
|
||||||
|
endDate: '2027-01-31'
|
||||||
|
}), `2026–2027 · ${expected} · 01.09.2026–31.01.2027`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('значение мастера преобразуется в payload точечного изменения', () => {
|
test('значение мастера преобразуется в payload точечного изменения', () => {
|
||||||
assert.deepEqual(parseDecisionOption('REPLACE_TEACHER|17'), {
|
assert.deepEqual(parseDecisionOption('REPLACE_TEACHER|17'), {
|
||||||
resolution: 'REPLACE_TEACHER',
|
resolution: 'REPLACE_TEACHER',
|
||||||
|
|||||||
@@ -138,16 +138,22 @@ input[type="time"],
|
|||||||
background-color var(--ui-transition),
|
background-color var(--ui-transition),
|
||||||
border-color var(--ui-transition),
|
border-color var(--ui-transition),
|
||||||
color var(--ui-transition),
|
color var(--ui-transition),
|
||||||
box-shadow var(--ui-transition),
|
box-shadow var(--ui-transition);
|
||||||
transform var(--ui-transition);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Смещение меняет область наведения и вызывает мерцание у края кнопки. */
|
||||||
.btn:hover:not(:disabled):not([aria-disabled="true"]) {
|
.btn:hover:not(:disabled):not([aria-disabled="true"]) {
|
||||||
transform: translateY(-1px);
|
transform: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn:active:not(:disabled):not([aria-disabled="true"]) {
|
.btn:active:not(:disabled):not([aria-disabled="true"]) {
|
||||||
transform: translateY(0);
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Волна нажатия остаётся внутри кнопки и не перекрывает соседние поля. */
|
||||||
|
.btn-submit {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn:focus-visible {
|
.btn:focus-visible {
|
||||||
|
|||||||
Reference in New Issue
Block a user