1 Commits

Author SHA1 Message Date
dipatrik10
97bf3cc50a feat: завершить миграцию фронтенда на React (этапы 1-5)
Админка, настройки, логин, кабинеты преподавателя и студента
полностью переведены на React 19 (модель strangler, docs/REACT_MIGRATION.md).

- 15 вкладок админки: lazy-загрузка, общий error boundary, сервисный слой
- Удалены legacy-views и переходный слой LegacyTabView (45 файлов)
- Сборка esbuild (frontend/scripts/build-react.mjs) с бюджетами gzip на чанки
- CSP-хэши, кэширование: chunk'и immutable, CSS/HTML no-cache (react-cache.conf)
- Фиксы: lazy-импорт именованных экспортов (краш после авторизации),
  растяжение #admin-root/#settings-root на всю ширину,
  возврат subjects/lessonTypes из справочников (краш просмотра расписаний),
  отказ от заведомо широкого поиска расписания без выбранной цели,
  центрирование карточки логина
- Тесты: 237/237 (npm run check), регрессионные на lazy-вкладки и ширину
- Документация: REACT_MIGRATION.md, REACT_MIGRATION_RESULT.md, FRONTEND.md
2026-09-28 23:15:04 +03:00
167 changed files with 31283 additions and 19033 deletions

View File

@@ -4,7 +4,7 @@
Проект представляет собой систему управления университетским расписанием.
- **Backend**: Java 17, Spring Boot 3.5.16 (Мультитенантная архитектура: отдельная БД для каждого клиента)
- **Frontend**: Vanilla JavaScript + HTML/CSS (без фреймворков)
- **Frontend**: поэтапный переход с Vanilla JavaScript на React 19 + HTML/CSS
- **Database**: PostgreSQL (множество БД, управляются через Flyway)
- **Локальный URL**: localhost:80
- **Продакшн URL**: https://magistr.zuev.company
@@ -25,6 +25,7 @@ magistr/
│ ├── utils/ # Утилиты
│ └── src/main/resources/db/migration/ # Flyway SQL миграции (версионирование схемы БД)
├── frontend/ # Статические файлы
│ ├── react/ # React entrypoint и компоненты уже перенесённых страниц
│ ├── admin/ # Интерфейс администратора
│ │ └── settings/ # Страница настроек (отдельный SPA)
│ ├── department/ # Redirect в кабинет кафедры внутри admin SPA

View File

@@ -4,12 +4,16 @@
| Параметр | Значение |
|----------|----------|
| **Фреймворк** | Нет (Vanilla JavaScript) |
| **Фреймворк** | Переходный режим: React 19 для страницы входа, кабинетов студента и преподавателя, страницы настроек и общего слоя `react/shared`, Vanilla JavaScript для остальных разделов |
| **Модульная система** | ES6 Modules (`import`/`export`) |
| **Стили** | CSS (модульный подход) |
| **Шрифт** | Системный стек без внешних font-CDN |
| **Веб-сервер** | Apache httpd на Alpine со строгим CSP |
Пошаговый план и актуальная точка продолжения находятся в
[`REACT_MIGRATION.md`](REACT_MIGRATION.md). Миграция выполняется по целым страницам и
разделам без изменения действующих URL и REST-контрактов.
---
## Структура файлов
@@ -23,10 +27,90 @@ frontend/
├── schedule-overview.js # Общий расчёт и рендер «Сегодня / Следующая пара»
├── schedule-overview.css # Общие карточки ближайших занятий и поискового выбора группы
├── security.conf # CSP и защитные HTTP-заголовки Apache
├── react-cache.conf # Cache-Control: immutable для chunk'ов, no-cache для entrypoint, CSS и HTML
├── telemetry/
│ └── otel-entry.js # Исходная точка сборки OpenTelemetry
├── scripts/
│ ├── build-react.mjs # Production-сборка React entrypoint и проверка gzip-бюджета
│ ├── build-react-tests.mjs # Сборка компонентных проверок для node:test
│ └── build-vendor.mjs # Сборка `/vendor/otel.js` через esbuild
├── react/
│ ├── login/
│ │ ├── LoginApp.jsx # React-компонент страницы входа
│ │ ├── login-service.js # Валидация и контракт POST /api/auth/login
│ │ └── main.jsx # React entrypoint для `/`
│ ├── student/ # Кабинет студента на React
│ │ ├── StudentApp.jsx # Сессия, выбор группы, неделя и состояния
│ │ ├── WeekGrid.jsx # Недельная сетка дней и занятий
│ │ ├── student-service.js # Чипы, статусы и контракты API кабинета студента
│ │ ├── main.jsx # React entrypoint для `/student/`
│ │ └── testing/ # render-student.jsx для DOM-проверок
│ ├── teacher/ # Кабинет преподавателя на React
│ │ ├── TeacherApp.jsx # Сессия, вкладки, неделя и состояния
│ │ ├── TeacherWeekGrid.jsx # Матрица слотов и мобильный список дней
│ │ ├── AbsencePanel.jsx # Заявки об отсутствии
│ │ ├── PreferencesTab.jsx # Семестровый календарь доступности
│ │ ├── RequestsTab.jsx # Журнал заявок на изменение пар
│ │ ├── RequestDialog.jsx # Диалог переноса/аудитории/отмены
│ │ ├── teacher-service.js # Слоты, матрица недели, метки и контракты API
│ │ ├── main.jsx # React entrypoint для `/teacher/`
│ │ └── testing/ # render-teacher.jsx для DOM-проверок
│ ├── settings/ # Страница настроек на React
│ │ ├── SettingsApp.jsx # Сессия, боковая панель, вкладки и тема
│ │ ├── GeneralTab.jsx # Заглушка общих настроек
│ │ ├── TimeSlotsTab.jsx # Сетки времени и CRUD временных слотов
│ │ ├── EduFormsTab.jsx # Формы обучения
│ │ ├── DatabaseTab.jsx # Статус подключения и тенанты
│ │ ├── settings-service.js # Метки сеток, валидация и контракты API
│ │ ├── main.jsx # React entrypoint для `/admin/settings/`
│ │ └── testing/ # render-settings.jsx для DOM-проверок
│ ├── admin/ # Оболочка основной админ-панели на React
│ │ ├── AdminApp.jsx # Сессия, sidebar с секциями, topbar, hash-router
│ │ ├── react-tabs.js # Реестр всех вкладок админ-панели (lazy + Suspense)
│ │ ├── admin-service.js # Маршруты, секции навигации, склонения, контракты API
│ │ ├── classrooms-service.js # Статусы, оборудование и контракты API аудиторий
│ │ ├── subjects-service.js # Состояние реестра, URL-параметры и контракты API дисциплин
│ │ ├── dashboard-service.js # Метрики, слоты, свободные аудитории и конфликты Red Zone
│ │ ├── users-service.js # Роли, состояние реестра и контракты API пользователей
│ │ ├── groups-service.js # Подгруппы, календарные графики и контракты API групп
│ │ ├── university-structure-service.js # Фильтрация списков и контракты API кафедр/специальностей/профилей
│ │ ├── teacher-requests-service.js # Состояние реестра, валидация одобрения и контракты API заявок
│ │ ├── department-workspace-service.js # Нагрузка, payload-валидация и контракты API кабинета кафедры
│ │ ├── auditorium-workload-service.js # Дедупликация занятий, чётность недель и контракты API загруженности
│ │ ├── academic-calendar-service.js # Учебные годы, сетка графика, диалог ячейки и контракты API календаря
│ │ ├── schedule-view-service.js # Разделы расписания, семестровая матрица и контракты API просмотра
│ │ ├── schedule-service.js # Слоты правил, payload-валидация, визуальная матрица и контракты API конструктора
│ │ ├── schedule-override-service.js # Источник правки, календарь семестра и контракты API разовых правок
│ │ ├── schedule-versions-service.js # Группировка версий, подписи журнала и контракты API контура публикаций
│ │ ├── schedule-quality-service.js # Диапазоны оценки, фильтры проблем и контракт API анализа качества
│ │ ├── teacher-absences-service.js # Решения мастера замены, подписи статусов и контракты API отсутствий
│ │ ├── tabs/ # Вкладки админ-панели (волны 1–6, все вкладки на React)
│ │ │ ├── DashboardTab.jsx # Метрики, мониторинг пар, Red Zone
│ │ │ ├── ClassroomsTab.jsx # Аудиторный фонд с мультиселектом оборудования
│ │ │ ├── SubjectsTab.jsx # Реестр дисциплин с пагинацией и преподавателями
│ │ │ ├── UsersTab.jsx # Реестр пользователей с фильтром роли и архивированием
│ │ │ ├── GroupsTab.jsx # Реестр групп, подгруппы и календарные графики
│ │ │ ├── UniversityStructureTab.jsx # Кафедры, специальности и профили обучения
│ │ │ ├── TeacherRequestsTab.jsx # Реестр заявок с редактируемыми строками и отклонением через PromptDialog
│ │ │ ├── DepartmentWorkspaceTab.jsx # Кабинет кафедры: дисциплины, преподаватели, нагрузка, заявки
│ │ │ ├── AuditoriumWorkloadTab.jsx # Загруженность: сводная матрица и двухнедельная сводка сущности
│ │ │ ├── AcademicCalendarTab.jsx # Календарные графики: годы, сетки, дисциплины (MaskedDateInput)
│ │ │ ├── ScheduleViewTab.jsx # Просмотр расписаний: фильтры, разделы, матрица и панель правок
│ │ │ ├── ScheduleTab.jsx # Конструктор правил: каскад версий, форма правила и визуальная матрица
│ │ │ ├── ScheduleVersionsTab.jsx # Контур публикаций: черновики, diff, архив, журнал и модальные операции
│ │ │ ├── ScheduleQualityTab.jsx # Анализ качества: скорборд, метрики, карта проблем и панель правила
│ │ │ └── TeacherAbsencesTab.jsx # Отсутствия: реестр, мастер замены, пожелания и заявки на изменение
│ │ ├── main.jsx # React entrypoint для `/admin/`
│ │ └── testing/ # render-admin.jsx для DOM-проверок
│ └── shared/ # Общий React-слой для следующих переносимых страниц
│ ├── api-client.js # requestJson/apiClient поверх auth-транспорта
│ ├── auth-client.js # Восстановление сессии и logout с redirect
│ ├── dates.js # Дата-математика (недели, ISO, форматирование)
│ ├── session-policy.js # Фазы loading/ready/unauthorized/error и русские тексты
│ ├── use-authorized-session.js # Хук проверки роли при монтировании страницы
│ ├── runtime-entry.jsx # Единая точка экспорта общего runtime-chunk
│ ├── ui/ # AppErrorBoundary, AsyncState, FormAlert, AsyncCombobox, ScheduleOverview, ConfirmDialog, Pagination
│ └── testing/ # render-shared.jsx для DOM-проверок через renderToStaticMarkup
├── tests/
│ ├── academic-calendar-grid.test.mjs # Неполная первая неделя календарного графика
│ ├── academic-calendar-title.test.mjs # Составное название календарного графика
@@ -34,6 +118,13 @@ frontend/
│ ├── schedule-overview.test.mjs # Текущее занятие, список на сегодня и следующая пара
│ ├── admin-ui-regressions.test.mjs # Заголовки, меню, действия слотов и компоновка вкладок
│ ├── auth-session.test.mjs # Login/refresh/reload/logout и single-flight refresh
│ ├── react-login.test.mjs # Валидация и API-контракт React-формы входа
│ ├── react-shared.test.mjs # Общая политика сессии, API-клиент и DOM-проверки состояний
│ ├── react-student.test.mjs # Дата-математика, чипы, контракты API и DOM-проверки кабинета студента
│ ├── react-teacher.test.mjs # Слоты, матрица недели, метки, payload заявок и DOM-проверки кабинета преподавателя
│ ├── react-settings.test.mjs # Вкладки по ролям, метки сеток, валидация и контракты API страницы настроек
│ ├── react-admin.test.mjs # Навигация по ролям, hash-маршруты, склонения и DOM-проверки оболочки админки
│ ├── react-admin-tabs.test.mjs # Сервисы и DOM-проверки вкладок волн 1–5 (дашборд, аудитории, дисциплины, пользователи, группы, структура вуза, заявки, кабинет кафедры, загруженность, календарные графики, вкладки расписания)
│ ├── dashboard-conflicts.test.mjs # Регрессии дат и состояний проверки конфликтов
│ ├── schedule-overrides.test.mjs # Действия, роли, недельный выбор и подбор времени разовой правки
│ ├── schedule-quality.test.mjs # Метрики качества, фильтры проблем и доступность вкладки
@@ -43,13 +134,12 @@ frontend/
│ ├── teacher-preferences.test.mjs # Календарь пожеланий, заявки и подсказки конструктора
│ └── security-policy.test.mjs # Web Storage, XSS, пароли, язык, CSP и Dockerfile
├── index.html # 🔐 Страница авторизации (общая)
├── script.js # Логика авторизации
├── style.css # Стили страницы авторизации
├── theme-toggle.js # Переключение светлой/тёмной темы
├── Dockerfile # Multi-stage: npm bundle → Apache httpd
│
├── admin/ # 👨‍💼 Интерфейс администратора
│ ├── index.html # SPA-оболочка с sidebar
│ ├── index.html # Монтирует React-оболочку `/react/admin.js`
│ ├── css/
│ │ ├── main.css # CSS-переменные, цвета, типографика
│ │ ├── layout.css # Раскладка (sidebar, topbar, content)
@@ -61,81 +151,37 @@ frontend/
│ │ ├── schedule-quality.css # Диагностический экран качества расписания
│ │ └── schedule-versions.css # Контур публикации, diff и журнал версий
│ ├── js/
│ │ ├── main.js # Инициализация, маршрутизация, навигация
│ │ ├── role-capabilities.js # Единая матрица вкладок admin/settings по ролям
│ │ ├── api.js # HTTP-обёртка (fetch + Authorization)
│ │ ├── dashboard-conflicts.js # Чистые функции дат, загрузки и состояний Red Zone
│ │ ├── date-input.js # Маска ДД.ММ.ГГГГ и преобразование дат в ISO
│ │ ├── async-combobox.js # Асинхронный поиск больших справочников
│ │ ├── dialog.js # Доступные подтверждения, сообщения и ввод причины
│ │ ├── dirty-state.js # Защита несохранённых изменений
│ │ ├── pagination.js # Общая панель серверной пагинации
│ │ ├── url-state.js # Чтение и запись фильтров в query string
│ │ ├── view-state.js # Единые loading/empty/error-состояния таблиц
│ │ ├── academic-period.js # Каскад учебного года и семестра в профильных вкладках
│ │ ├── utils.js # Утилиты
│ │ ├── schedule-period.js # Выбор ближайшего учебного периода для расписаний и нагрузки
│ │ └── views/ # Модули представлений
│ │ ├── dashboard.js # Дашборд
│ │ ├── users.js # Управление пользователями
│ │ ├── teacher-requests.js # Заявки кафедр на преподавателей
│ │ ├── groups.js # Управление группами
│ │ ├── classrooms.js # Управление аудиториями
│ │ ├── subjects.js # Управление дисциплинами
│ │ ├── university-structure.js # Кафедры, специальности и профили обучения
│ │ ├── department-workspace.js # Кабинет кафедры в общей панели
│ │ ├── schedule-view.js # Просмотр расписаний и запуск разовой правки из карточки
│ │ ├── schedule-override-panel.js # Боковая панель и реестр разовых изменений
│ │ ├── teacher-absences.js # Отсутствия, согласование пожеланий и заявок на изменение
│ │ ├── schedule-quality.js # Оценка, фильтры и переход к редактированию правил
│ │ ├── schedule-versions.js # Черновики, публикация, восстановление, diff и аудит
│ │ ├── schedule.js # Конструктор правил и подсказки пожеланий преподавателей
│ │ ├── academic-calendar-grid.js # Расчёт ISO-недели дневной сетки
│ │ ├── academic-calendar-title.js # Название из кода, профиля, формы и года
│ │ ├── academic-calendar.js # Календарные учебные графики
│ │ └── auditorium-workload.js # Загруженность аудиторий, преподавателей и кафедр
│ ├── views/ # HTML-шаблоны представлений
│ │ ├── dashboard.html
│ │ ├── users.html
│ │ ├── teacher-requests.html
│ │ ├── groups.html
│ │ ├── classrooms.html
│ │ ├── subjects.html
│ │ ├── university-structure.html
│ │ ├── department-workspace.html
│ │ ├── schedule-view.html
│ │ ├── teacher-absences.html
│ │ ├── schedule-quality.html
│ │ ├── schedule-versions.html
│ │ ├── schedule.html
│ │ ├── academic-calendar.html
│ │ └── auditorium-workload.html
│ │ └── views/ # Общие чистые хелперы (используются React-сервисами)
│ │ ├── academic-calendar-grid.js # Расчёт ISO-недели дневной сетки (общий с React-сервисом)
│ │ └── academic-calendar-title.js # Название из кода, профиля, формы и года (общий с React-сервисом)
│ │
│ └── settings/ # ⚙️ Страница настроек (отдельный SPA)
│ ├── index.html # Оболочка с собственной sidebar
│ ├── css/
│ │ ├── main.css # CSS-переменные, базовые стили
│ │ └── layout.css # Sidebar, topbar, content
│ ├── js/
│ │ ├── main.js # Навигация по вкладкам настроек
│ │ └── views/
│ │ └── time-slots.js # Настройка временных слотов
│ └── views/
│ ├── general.html # Общие настройки (заглушка)
│ └── time-slots.html # Базовая, субботняя и ручные сетки времени
│ └── settings/ # ⚙️ Страница настроек (отдельный SPA на React)
│ ├── index.html # Оболочка с корневым элементом settings-root
│ └── css/
│ ├── main.css # CSS-переменные, базовые стили
│ └── layout.css # Sidebar, topbar, content
│
├── teacher/ # 👩‍🏫 Интерфейс преподавателя
│ ├── index.html # Расписание, календарь доступности и журнал заявок
│ ├── app.js # Пожелания, переносы и недельный просмотр через общий auth-session
├── teacher/ # 👩‍🏫 Интерфейс преподавателя (React)
│ ├── index.html # Оболочка с корневым элементом teacher-root
│ └── style.css # Адаптивный кабинет в стиле диспетчерского журнала
├── department/ # 🏛 Кабинет кафедры
│ └── index.html # Redirect в `/admin/#department-workspace`
├── edu-office/ # 🗓 Кабинет учебного отдела
│ └── index.html # Redirect в `/admin/#schedule-view`
│
└── student/ # 🎓 Интерфейс студента
├── index.html # CSP-совместимая HTML-оболочка
├── app.js # Недельный просмотр и общий auth-session
└── student/ # 🎓 Интерфейс студента (React)
├── index.html # Оболочка с корневым элементом student-root
└── style.css # Стили кабинета без inline-блока
```
@@ -143,24 +189,36 @@ frontend/
## Система маршрутизации (Admin SPA)
Админ-панель работает как **Single Page Application** без фреймворка.
Оболочка админ-панели работает на **React** (`react/admin/AdminApp.jsx`, entrypoint
`/react/admin.js`): sidebar с секциями, topbar с заголовком вкладки и счётчиком заявок,
тема, сворачивание панели, настройки с выходом и hash-router.
Навигация реализована через `data-tab` атрибуты на элементах sidebar:
Навигация реализована через hash-адреса вкладок (`#users`, `#schedule` и т. д.):
```html
<a href="#" class="nav-item" data-tab="users">Пользователи</a>
<a href="#" class="nav-item" data-tab="groups">Группы</a>
<a href="#" class="nav-item" data-tab="schedule">Расписание занятий</a>
<a href="#" class="nav-item" data-tab="academic-calendar">Календарный график</a>
```
- при старте активная вкладка читается из `location.hash` (неразрешённые роли
значения заменяются вкладкой по умолчанию);
- слушатель `hashchange` синхронизирует вкладку с адресной строкой (работают
кнопки «назад/вперёд» и ссылки из других кабинетов);
- переключение идёт через `requestTabSwitch` с guard'ом несохранённых изменений
из общего модуля `admin/js/dirty-state.js`.
При клике на пункт меню `main.js`:
1. Загружает HTML-шаблон из `views/{tab}.html` через `fetch()`
2. Вставляет его в `#app-content`
3. Подключает соответствующий JS-модуль из `js/views/{tab}.js`
4. Обновляет заголовок страницы (`#page-title`)
Содержимое вкладки выбирается по реестру `react/admin/react-tabs.js`:
`main.js` и отдельный settings SPA получают разрешённые вкладки из единого
- **все 15 вкладок админ-панели** (волна 1: `dashboard`, `classrooms`, `subjects`;
волна 2: `users`, `groups`, `university-structure`; волна 3:
`teacher-requests`, `department-workspace`; волна 4: `auditorium-workload`,
`academic-calendar`; волна 5: `schedule-view`, `schedule-versions`,
`schedule-quality`, `teacher-absences`; волна 6: `schedule`)
рендерятся React-компонентами из `react/admin/tabs/`, каждый загружается
отдельным lazy-chunk'ом (`React.lazy` + `Suspense`, fallback — индикатор
`tab-loading`);
- переходный слой `LegacyTabView` + `legacy-views.js` и каталоги
`admin/views/*.html` / `admin/js/views/{tab}.js` удалены после волны 6 —
HTML-фрагменты больше не загружаются через `fetch`, в `admin/js/views/`
остались только общие чистые хелперы календарных графиков.
React-оболочка админки (`react/admin/admin-service.js`) и React-оболочка настроек
(`react/settings/SettingsApp.jsx`) получают разрешённые вкладки из единого
`admin/js/role-capabilities.js`; локальные дубли `ROLE_NAVIGATION`/`ROLE_TABS` удалены.
| Роль | Доступные вкладки |
@@ -172,10 +230,11 @@ frontend/
Пути `/department/` и `/edu-office/` оставлены как входные redirect-страницы в общую панель. Отдельные кабинеты не дублируют UI админ-панели.
Пункты меню, недоступные роли, не отображаются в sidebar: `main.js` выставляет `hidden`, а `layout.css` явно скрывает такие элементы, чтобы базовый `display: flex` у `.nav-item` не возвращал их на экран.
Верхние панели основной админки и settings SPA не содержат неработающей кнопки
бургер-меню. В светлой теме пункты и заголовки sidebar получают контрастные тёмные цвета
для всех ролей, включая `DEPARTMENT`.
Пункты меню, недоступные роли, не попадают в sidebar: React-оболочка фильтрует
секции навигации по матрице ролей до рендера. Верхние панели основной админки и
settings SPA не содержат неработающей кнопки бургер-меню. В светлой теме пункты и
заголовки sidebar получают контрастные тёмные цвета для всех ролей, включая
`DEPARTMENT`.
### Разделы админ-панели
@@ -267,9 +326,9 @@ frontend/
восстанавливают поиск, фильтры, сортировку, номер и размер страницы после reload или
возврата по ссылке. Просмотр расписания сохраняет вид сущности, выбранную сущность, дату,
учебный год, семестр и дополнительные фильтры; конечные кабинеты сохраняют неделю, вкладку и группу.
- `pagination.js` работает с общим ответом `PageResponse`; размер страницы — 25, 50 или
100. `view-state.js` унифицирует загрузку со spinner, пустой результат и ошибку с кнопкой
повторного запроса.
- Общий React-компонент `react/shared/ui/Pagination.jsx` работает с общим ответом
`PageResponse`; размер страницы — 25, 50 или 100. `view-state.js` унифицирует
загрузку со spinner, пустой результат и ошибку с кнопкой повторного запроса.
- `AsyncCombobox` загружает варианты с сервера, сохраняет выбранный ID, поддерживает
клавиатуру и роли `combobox/listbox`. Он используется для групп, преподавателей,
аудиторий и совместимых календарных графиков; предварительная загрузка вариантов не
@@ -288,13 +347,26 @@ frontend/
### Страница настроек (`/admin/settings/`)
Настройки — это **отдельный SPA** со своей боковой панелью и вкладками, не связанными с основной админ-панелью.
Страница переведена на React (`react/settings/`, entrypoint `dist/react/settings.js`) и использует
общий слой `react/shared`: `useAuthorizedSession(SETTINGS_ROLES)`, `apiClient`, `AsyncState`,
`AppErrorBoundary` и общий `ConfirmDialog`/`PromptDialog` (классы `.project-dialog*`,
закрытие по Escape и клику по фону; `PromptDialog` — реплика legacy `promptAction`
с обязательным текстовым полем и ошибкой внутри диалога). Настройки — **отдельный SPA** со своей боковой панелью и вкладками, не связанными с
основной админ-панелью; как и в legacy-версии, hash-навигации нет — активная вкладка живёт
в состоянии React.
- Доступ: через dropdown «Настройки» в footer боковой панели админки для `ADMIN` и `EDUCATION_OFFICE`
- Кнопка «Назад в панель» для возврата в `/admin/`
- Доступ: через dropdown «Настройки» в footer боковой панели админки для `ADMIN` и `EDUCATION_OFFICE`;
список и вкладка по умолчанию фильтруются единой матрицей `admin/js/role-capabilities.js`
- Кнопка «Назад в панель» для возврата в `/admin/`, кнопка выхода через общий `logoutAndRedirect`
- Сворачивание боковой панели сохраняется в `localStorage.sidebar-collapsed`; тема управляется
в React (кнопка в topbar), `theme-toggle.js` на странице больше не подключается
- Текущие вкладки:
- **Общие настройки** — заглушка (только `ADMIN`)
- **Временные слоты** — выбор сетки через выпадающий список, CRUD слотов выбранной сетки, добавление ручных сеток через модальное окно и удаление выбранной ручной сетки. Поле длительности доступно только для чтения и пересчитывается при изменении времени начала или окончания; в API отправляются только границы, а окончательное значение вычисляет backend. Базовая сетка применяется по умолчанию, субботняя — автоматически по субботам; ручное применение выполняется в сетке календарного графика.
- **Формы обучения** — создание и удаление форм обучения
- **База данных** — статус текущего подключения, реестр тенантов, проверка и добавление нового
подключения (пароль помечен `autocomplete="new-password"`); inline-стили legacy заменены
классами `.db-status-grid`/`.form-group-wide`/`.form-row-actions`
---
@@ -339,11 +411,15 @@ SHA-256 style-хэшей с CSP. Там же закреплены безопас
Команды выполняются из каталога `frontend/`:
```bash
npm run build # собрать локальный dist/vendor/otel.js
npm run build # собрать dist/vendor/otel.js и dist/react/login.js
npm test # frontend unit/static tests
npm run check # синтаксис auth/UI-модулей + все frontend-тесты
npm run check # React-сборка, синтаксис auth/UI-модулей + все frontend-тесты
```
React-сборка выполняется существующим esbuild без отдельного dev-сервера. Для страницы
входа установлен лимит 75 000 байт gzip; превышение завершает сборку ошибкой. При появлении
нескольких React entrypoint общий runtime будет вынесен в отдельный кэшируемый chunk.
---
## Аутентификация (Frontend)
@@ -351,7 +427,7 @@ npm run check # синтаксис auth/UI-модулей + все frontend-т
### Страница входа (`/index.html`)
1. Пользователь вводит логин/пароль
2. `script.js` отправляет `POST /api/auth/login`
2. `react/login/LoginApp.jsx` через `login-service.js` отправляет `POST /api/auth/login`
3. При успехе кладёт access JWT и профиль пользователя только в память текущей страницы.
4. Refresh-токен сохраняется браузером как `HttpOnly` cookie и недоступен JavaScript.
5. После перехода новый документ восстанавливает память через `POST /api/auth/refresh`.
@@ -409,25 +485,31 @@ if (!session || !AUTHORIZED_ROLES.includes(session.role)) {
### Преподаватель (`/teacher/`)
Страница объединяет недельную сетку занятий, семестровый календарь доступности и журнал
заявок на изменение пар. ID преподавателя берётся из восстановленного в памяти профиля
сессии.
Страница переведена на React (`react/teacher/`, entrypoint `dist/react/teacher.js`) и использует
общий слой `react/shared`: `useAuthorizedSession(['TEACHER'])`, `apiClient`, `AsyncState`,
`AppErrorBoundary`, дата-математику `dates.js` и общий `ScheduleOverview` с ролью `teacher`.
ID преподавателя берётся из восстановленного в памяти профиля сессии.
Кабинет объединяет недельную сетку занятий, семестровый календарь доступности и журнал
заявок на изменение пар в трёх вкладках; активная вкладка и дата недели сохраняются
в query-параметрах `teacherTab`/`teacherDate` через общий `url-state.js`.
Основные элементы:
- общий блок «Сегодня» и карточка «Следующая пара»; отдельный запрос покрывает ближайшие
14 дней, поэтому следующая пара находится и за границей текущей недели;
- навигация по неделям: предыдущая, текущая, следующая;
- выбор даты через `input[type="date"]`;
- навигация по неделям: предыдущая, текущая, следующая; выбор даты через `input[type="date"]`;
- запрос `GET /api/schedule?teacherId={userId}&startDate={YYYY-MM-DD}&endDate={YYYY-MM-DD}`;
- матрица «слоты × дни недели» на десктопе и дневной список на мобильных (переключение
через `matchMedia` с живым откликом на смену ширины);
- отображение дисциплины, времени, типа занятия, лабораторных подгрупп, аудитории и всех групп правила;
- вкладка «Мои пожелания» с выбором семестра, режимом отметки строгих, предпочтительных и
нежелательных интервалов, полностью недоступных дат, пар подряд и расписания без окон;
- цветовые статусы `PENDING`, `APPROVED`, `REJECTED`, `CANCELLED`; ожидающее пожелание можно
отозвать кликом по отмеченной ячейке или кнопкой в списке дат;
- кнопка «Заявка на изменение» в карточке фактического занятия и модальное окно переноса,
- кнопка «Запросить изменение» в карточке занятия из правила и модальное окно переноса,
смены аудитории или отмены. Недоступные кандидаты отфильтрованы после проверки backend;
- вкладка журнала заявок с причиной, решением, ссылкой на применённый override и историей
статусов; ожидающую заявку можно отозвать;
- вкладка журнала заявок с причиной, решением и историей статусов; ожидающую заявку можно
отозвать; счётчик ожидающих заявок выводится прямо на вкладке навигации;
- форма собственной заявки на отсутствие через `POST /api/teacher-absences`;
- список статусов заявок и отмена ещё не подтверждённой записи через
`DELETE /api/teacher-absences/{id}`.
@@ -436,6 +518,11 @@ if (!session || !AUTHORIZED_ROLES.includes(session.role)) {
### Студент (`/student/`)
Страница переведена на React (`react/student/`, entrypoint `dist/react/student.js`) и использует
общий слой `react/shared`: `useAuthorizedSession(['STUDENT'])`, `apiClient`, `AsyncState`,
`AppErrorBoundary` и React-версия поискового списка `AsyncCombobox`. Модель обзора «Сегодня /
Следующая пара» переиспользует чистые функции `schedule-overview.js`.
В текущей модели студент не связан с конкретной группой, поэтому страница использует
асинхронный поиск группы через `/api/groups/options`.
@@ -443,9 +530,10 @@ if (!session || !AUTHORIZED_ROLES.includes(session.role)) {
- поисковый combobox группы с подсказками по специальности, профилю и курсу; выбор хранится
в `localStorage.studentGroupId` и `studentGroup` query string;
- общий блок «Сегодня» и карточка «Следующая пара», рассчитанные по ближайшим 14 дням;
- недельная сетка по дням;
- недельная сетка по дням с навигацией «Предыдущая/Следующая неделя», «Сегодня» и выбором даты;
- запрос `GET /api/schedule?groupId={groupId}&startDate={YYYY-MM-DD}&endDate={YYYY-MM-DD}`;
- отображение дисциплины, времени, преподавателя, аудитории, формата, типа занятия и лабораторных подгрупп.
- отображение дисциплины, времени, преподавателя, аудитории, формата, типа занятия и лабораторных подгрупп;
- URL-параметры `studentGroup` и `studentDate` сохраняются через общий `url-state.js`.
---

View File

@@ -95,10 +95,11 @@ Backend собирается через multi-stage сборку Maven:
```dockerfile
FROM node:22-alpine3.23@sha256:8516dce... AS frontend-assets
RUN npm ci
RUN npm run build:vendor
RUN npm run build
FROM httpd:alpine3.23@sha256:4a15e9c...
RUN apk upgrade --no-cache
COPY --from=frontend-assets /build/dist/react/ /usr/local/apache2/htdocs/react/
COPY --from=frontend-assets /build/dist/vendor/ /usr/local/apache2/htdocs/vendor/
COPY security.conf /usr/local/apache2/conf/extra/magistr-security.conf
COPY proxy.conf /usr/local/apache2/conf/extra/magistr-proxy.conf
@@ -106,7 +107,7 @@ COPY proxy.conf /usr/local/apache2/conf/extra/magistr-proxy.conf
Все базовые образы backend/frontend и локальный PostgreSQL зафиксированы одновременно точным
tag и manifest digest. Первый этап frontend собирает зафиксированный
OpenTelemetry bundle; второй раздаёт только runtime-
OpenTelemetry bundle и production-bundle React-страницы входа; второй раздаёт только runtime-
файлы, без `node_modules`, тестов и build-исходников. В корень образа также копируются
`ui-foundation.css`, `schedule-overview.css` и `schedule-overview.js`: они необходимы
для общего шрифта, компонентов и обзора занятий в личных кабинетах. Apache подключает `mod_headers`,

675
docs/REACT_MIGRATION.md Normal file
View File

@@ -0,0 +1,675 @@
# План перехода frontend на React
## Зачем выполняется миграция
Цель перехода — снизить сложность развития интерфейса, сделать состояние страниц явным,
переиспользовать компоненты и тестировать пользовательские сценарии изолированно. Сам React
не гарантирует меньший размер страницы: вес, время загрузки и объём запросов контролируются
отдельными бюджетами.
Миграция выполняется постепенно по модели «strangler»: React и существующие ES-модули
временно работают рядом, а переключение происходит по целым страницам или независимым
разделам. Большой одномоментный rewrite не планируется.
## Обязательные ограничения
- Сохраняются текущие URL, REST API и серверная ролевая модель.
- Access JWT остаётся только в памяти, refresh-токен — в `HttpOnly` cookie.
- Нельзя ослаблять CSP: удалённые и inline-скрипты по-прежнему запрещены.
- UI, ошибки и пользовательские сообщения остаются на русском языке.
- Общие CSS-токены и светлая/тёмная темы сохраняются до отдельного решения о редизайне.
- Каждый перенесённый срез должен иметь тесты, production-сборку и измеримый бюджет bundle.
- Vanilla-код удаляется только после переключения соответствующего маршрута на React.
## Целевая схема
```text
frontend/
├── react/
│ ├── shared/ # Общие React-компоненты, auth/api и состояния загрузки
│ ├── login/ # Страница входа
│ ├── student/ # Кабинет студента
│ ├── teacher/ # Кабинет преподавателя
│ └── admin/ # Shell, маршруты и feature-модули админ-панели
├── dist/react/ # Скомпилированные production-bundle, не хранятся в Git
├── admin/, student/, ... # Legacy-срезы до завершения их миграции
└── scripts/build-react.mjs # Сборка React через esbuild
```
На переходном этапе Apache продолжает раздавать статические файлы и проксировать `/api`.
React-bundle собираются в существующем Node-этапе Docker-образа. После появления нескольких
React entrypoint сборка должна выделить общий runtime в кэшируемый chunk, а функциональные
разделы — загружать лениво.
## Этапы
| Этап | Содержание | Критерий завершения | Статус |
|------|------------|---------------------|--------|
| 0. Инвентаризация | Карта entrypoint, маршрутов, общих модулей и рисков | Выбран независимый первый срез; зафиксированы ограничения | Завершён |
| 1. Основа + вход | React/React DOM, esbuild entrypoint, Docker, перенос `/`, тест API-контракта и бюджет bundle | `/` работает на React без изменения auth-контракта; `npm run check` проходит | Завершён |
| 2. Общий React-слой | `react/shared`: auth/api adapters, состояния loading/empty/error, Error Boundary; общий runtime chunk; DOM-тесты | Следующие страницы не дублируют инфраструктурный код | Завершён |
| 3. Автономные кабинеты | Сначала `/student/`, затем `/teacher/`; компоненты расписания и навигации | Оба URL полностью работают на React, legacy `app.js` удалены | Завершён: `/student/` и `/teacher/` перенесены |
| 4. Настройки | React-shell `/admin/settings/`, затем вкладки `general`, `time-slots`, `edu-forms`, `database` | Роли и hash-навигация совпадают с текущим SPA | Завершён: `/admin/settings/` перенесён |
| 5. Основная админ-панель | Shell и hash-router, затем перенос feature-модулей волнами | Все роли и вкладки работают на React, HTML-фрагменты больше не загружаются через `fetch` | Завершён: shell, hash-router и все 15 вкладок (волны 1–6) перенесены, переходный `LegacyTabView`/`legacy-views.js` удалён |
| 6. Очистка и оптимизация | Удаление legacy-loader, дедупликация CSS, lazy loading, cache/compression, финальные performance budgets | Нет исполняемого legacy UI-кода; контрольные метрики не хуже baseline | Не начат |
### Очерёдность админских feature-модулей
1. Простые справочники: `classrooms`, `subjects`, `dashboard`.
2. Реестры и формы: `users`, `groups`, `university-structure`.
3. Ролевые рабочие места: `teacher-requests`, `department-workspace`.
4. Календарь и нагрузка: `academic-calendar`, `auditorium-workload`.
5. Контур публикации: `schedule-view`, `schedule-versions`, `schedule-quality`,
`teacher-absences`.
6. Последними — `schedule` и `schedule-override-panel`, потому что у них больше всего
связей, локального состояния и бизнес-ограничений.
## Стратегия проверки
- На каждом этапе: `npm run build`, `npm run check` и smoke-проверка Docker-образа.
- Для чистой логики сохраняются быстрые тесты на `node:test`.
- На этапе 2 добавляются DOM/component-тесты для форм, ошибок, клавиатурной навигации и ролей.
- Перед удалением legacy-среза сравниваются URL, payload запросов, редиректы и доступность.
- Для React entrypoint действует gzip-бюджет. Первый срез `/` ограничен 75 000 байт gzip;
следующие feature-chunk получают отдельные лимиты после выделения общего runtime.
- Для тяжёлых разделов измеряются число запросов, transferred bytes и время до готовности
интерфейса; миграция не считается оптимизацией без улучшения или сохранения этих метрик.
## Риски и меры
| Риск | Мера |
|------|------|
| Рост bundle из-за React runtime | Общий кэшируемый chunk, lazy loading, лимит сборки |
| Дублирование auth/api логики | Завершить этап 2 до переноса следующей страницы |
| Рассинхрон ролей admin/settings | Использовать текущую `role-capabilities.js` как единый контракт до её переноса |
| Поломка CSP | Проверять `.jsx` теми же security-тестами, что `.js` и `.html` |
| Два источника истины в одном экране | Переключать целый маршрут/вкладку, не смешивать управление одним DOM-деревом |
| Незаметная деградация производительности | Фиксировать raw/gzip размер и браузерные метрики на каждом этапе |
## Точка остановки
**Дата:** 28 сентября 2026 года
**Остановились после этапа 5, волны 1 — вкладки `dashboard`, `classrooms`, `subjects`
основной админ-панели переведены на React-компоненты с lazy-загрузкой.**
Выполнено на этапе 2 (общий слой):
- `react/shared`: `apiClient`/`requestJson`, `auth-client` + `session-policy`,
`useAuthorizedSession`, `AppErrorBoundary`, `AsyncState`, `FormAlert`;
- общий кэшируемый runtime-chunk `dist/react/shared/chunks/` (esbuild `splitting`),
Apache отдаёт chunk с `immutable`, entrypoint — `no-cache`;
- DOM-проверки через `renderToStaticMarkup`, бюджеты сборки.
Выполнено на этапе 3 (кабинет студента):
- `react/student/`: `StudentApp` (сессия через `useAuthorizedSession(['STUDENT'])`,
выбор группы, навигация по неделям, состояния loading/empty/error с повтором),
`WeekGrid`, `ScheduleOverview`, `student-service` (дата-математика, чипы, контракты API);
- React-версия поискового списка `AsyncCombobox` вынесена в `react/shared/ui/` с той же
семантикой ARIA, debounce 320 мс и клавиатурной навигацией, что и vanilla-версия;
- модель обзора «Сегодня / Следующая пара» переиспользует чистые функции
`schedule-overview.js` (нужные помощники экспортированы аддитивно);
- контракты сохранены: `GET /api/groups/options`, `GET /api/schedule`,
`localStorage.studentGroupId`, query-параметры `studentGroup`/`studentDate`,
telemetry-события `student_*`, logout через общий `logoutAndRedirect`;
- `student/index.html` монтирует `/react/student.js`, legacy `student/app.js` удалён;
- бюджеты: entrypoint студента 5 819 байт gzip (лимит 20 000), холодная загрузка
78 309 байт gzip (лимит 90 000), общие chunk 72 490 байт gzip (лимит 75 000);
- `npm run check`: 124 теста пройдено (13 новых для кабинета студента).
Выполнено на этапе 3 (кабинет преподавателя):
- `react/teacher/`: `TeacherApp` (сессия через `useAuthorizedSession(['TEACHER'])`,
вкладки расписания/пожеланий/заявок, навигация по неделям), `TeacherWeekGrid`
(матрица слотов на десктопе и дневной список на мобильных через `matchMedia`),
`AbsencePanel`, `PreferencesTab`, `RequestsTab`, `RequestDialog`,
`teacher-service` (слоты, матрица недели, метки статусов, payload заявок,
контракты API);
- дата-математика выделена в `react/shared/dates.js` (единый источник для
студента и преподавателя, `student-service` реэкспортирует),
`ScheduleOverview` перенесён в `react/shared/ui/` и переиспользуется обоими
кабинетами с ролью `student`/`teacher`;
- контракты сохранены: `GET /api/schedule?teacherId=`, `GET|POST|DELETE
/api/teacher-preferences`, `GET|POST|DELETE /api/teacher-change-requests`,
`GET /api/teacher-change-requests/candidates`, `GET|POST|DELETE
/api/teacher-absences`, query-параметры `teacherTab`/`teacherDate`,
telemetry-события `teacher_*`;
- `teacher/index.html` монтирует `/react/teacher.js`, legacy `teacher/app.js` удалён;
- бюджеты: entrypoint преподавателя 10 095 байт gzip (лимит 20 000), холодная
загрузка 84 590 байт gzip (лимит 90 000), общие chunk 74 495 байт gzip
(лимит 75 000);
- `npm run check`: 146 тестов пройдено (22 новых для кабинета преподавателя).
Выполнено на этапе 4 (страница настроек):
- `react/settings/`: `SettingsApp` (сессия через `useAuthorizedSession(SETTINGS_ROLES)`,
боковая панель с фильтрацией вкладок по `role-capabilities.js`, сворачивание панели
с сохранением в `localStorage.sidebar-collapsed`, тема в React вместо
`theme-toggle.js`), вкладки `GeneralTab`, `TimeSlotsTab`, `EduFormsTab`, `DatabaseTab`,
`settings-service` (метки сеток времени, валидация слота/тенанта, контракты API);
- общий `ConfirmDialog` в `react/shared/ui/` переиспользует классы `.project-dialog*`
и закрывается по Escape/клику по фону;
- роли и навигация совпадают с legacy-SPA: у настроек не было hash-навигации,
активная вкладка живёт в состоянии React, как раньше — в памяти страницы;
ADMIN видит все четыре вкладки (по умолчанию `general`), EDUCATION_OFFICE —
`time-slots` и `edu-forms` (по умолчанию `time-slots`);
- inline-стили legacy (`database.html`, `database.js`) заменены классами
`.db-status-grid`, `.form-group-wide`, `.form-row-actions` в `admin/settings/css/main.css`,
CSP-хэши `style-src-attr` в `security.conf` пересобраны по фактическим атрибутам;
- контракты сохранены: `GET|POST|DELETE /api/admin/time-slots/scopes`,
`GET|POST|PUT|DELETE /api/admin/time-slots`, `GET|POST|DELETE /api/education-forms`,
`GET /api/database/status`, `GET|POST|DELETE /api/database/tenants`,
`POST /api/database/test`;
- `admin/settings/index.html` монтирует `/react/settings.js`, legacy
`admin/settings/js/` и `admin/settings/views/` удалены;
- бюджеты: entrypoint настроек 9 172 байта gzip (лимит 20 000), холодная загрузка
83 667 байт gzip (лимит 90 000), общие chunk 74 495 байт gzip (лимит 75 000);
- `npm run check`: 159 тестов пройдено (13 новых для страницы настроек).
Выполнено на этапе 5 (срез 1 — React-shell основной админ-панели):
- `react/admin/`: `AdminApp` (сессия через `useAuthorizedSession(ADMIN_APP_ROLES)`) и
`AdminWorkspace` (sidebar с секциями и фильтрацией по `role-capabilities.js`,
topbar с заголовком вкладки и счётчиком заявок, тема в React вместо
`theme-toggle.js`, сворачивание панели `localStorage.sidebar-collapsed`,
настройки с выходом);
- hash-router в React: чтение `location.hash` при старте, слушатель `hashchange`,
переключение через `requestTabSwitch` с guard'ом `confirmAbandonDirtyChanges`
(единый модуль `admin/js/dirty-state.js` для React и legacy-вкладок);
- счётчик ожидающих заявок (только ADMIN): `GET /api/teacher-requests/page?status=PENDING&page=0&size=1`,
обновление по событию `teacherRequestsChanged`, склонение
«заявка/заявки/заявок», бейдж в навигации и заголовке;
- переходный слой `LegacyTabView` + `legacy-views.js` (удалён после волны 6):
ещё не перенесённые вкладки загружались как lazy-chunkи (fetch HTML-фрагмента
+ `init*()` legacy-модуля), каждая вьюха целиком владела своим DOM-поддеревом —
React и vanilla не смешивались в одном дереве; после переноса вкладки её
загрузчик и legacy-файл удалялись;
- глобальные утилиты legacy (`applyRippleEffect`, `closeAllDropdownsOnOutsideClick`,
`initAllCustomDropdowns`, `startDropdownAutoObserver`) вызываются оболочкой один раз;
- `admin/index.html` монтирует `/react/admin.js`, legacy `admin/js/main.js` удалён;
- бюджеты: entrypoint админ-панели 5 399 байт gzip (лимит 20 000), холодная загрузка
79 977 байт gzip (лимит 90 000), общие chunk 74 578 байт gzip (лимит 75 000),
переходные lazy-вкладки 109 512 байт gzip (лимит 150 000, загружаются по одной);
- `npm run check`: 170 тестов пройдено (10 новых для оболочки админки).
Выполнено на этапе 5 (волна 1 — `dashboard`, `classrooms`, `subjects`):
- `react/admin/react-tabs.js` — реестр перенесённых вкладок: каждая вкладка
загружается отдельным lazy-chunk через `React.lazy` + `Suspense`
(fallback — `tab-loading`); вкладки без записи в реестре едут через
`LegacyTabView`;
- `react/admin/tabs/DashboardTab.jsx`: метрики, мониторинг текущей пары
(обновление каждые 30 с), свободные аудитории, быстрый переход, Red Zone;
чистая логика выявления конфликтов (накладки преподавателей/аудиторий,
превышение вместимости) перенесена в `dashboard-service.js`
(`detectScheduleConflicts` возвращает сегменты с выделением вместо HTML-строк),
презентации состояния — из общего `dashboard-conflicts.js`;
- `react/admin/tabs/ClassroomsTab.jsx`: форма создания с React-мультиселектом
оборудования (замена `initMultiSelect`), таблица с переключением статуса,
модалки редактирования аудитории и справочника оборудования;
- `react/admin/tabs/SubjectsTab.jsx`: реестр с поиском (debounce 320 мс),
фильтром кафедры, сортировкой, пагинацией (общий `react/shared/ui/Pagination.jsx`),
сохранением состояния в URL (`subjectQuery`/`subjectDepartment`/`subjectPage`/
`subjectSize`/`subjectSort`), модалка управления преподавателями с
`AsyncCombobox`; контракты `GET /api/subjects/page`, `GET|POST|DELETE
/api/teacher-subjects` сохранены;
- legacy-файлы вкладок удалены (`admin/js/views/{dashboard,classrooms,subjects}.js`,
`admin/views/{dashboard,classrooms,subjects}.html`), загрузчики убраны из
`legacy-views.js`, у маршрутов перенесённых вкладок в `admin-service.js`
больше нет `viewFile`;
- inline-стили вкладок заменены классами в `admin/css/components.css`
(дашборд: метрики/Red Zone/свободные аудитории; общие: `card-toolbar`,
`form-row--*`, `modal-content--medium`, `th-actions`, `badge-list` и др.);
CSP-хэши `style-src-attr` в `security.conf` пересчитаны (50 → 22);
- классификация chunk'ов в `build-react.mjs` переведена на достижимость от
entrypoint'ов: chunk, достижимый только из `dist/react/admin.js`, — lazy-вкладка
(в общий runtime-бюджет не входит), даже если внутри есть модуль `react/shared`
(например, `Pagination` внутри chunk'а `SubjectsTab`);
- бюджеты: entrypoint админ-панели 5 530 байт gzip (лимит 20 000), холодная
загрузка 83 877 байт gzip (лимит 90 000), общие chunk 78 347 байт gzip
(лимит поднят 75 000 → 85 000: `ConfirmDialog` и `AsyncCombobox` стали общими
chunk'ами — их теперь используют и React-вкладки админки; лимит холодной
загрузки входа поднят 80 000 → 85 000 по той же причине), lazy-вкладки
112 753 байта gzip в 26 chunk'ах (лимит 150 000, загружаются по одной);
- `npm run check`: 182 теста пройдено (12 новых: `react-admin-tabs.test.mjs`).
Выполнено на этапе 5 (волна 2 — `users`, `groups`, `university-structure`):
- `react/admin/tabs/UsersTab.jsx`: форма создания пользователя (пароль —
`type="password"` + `autoComplete="new-password"`), реестр с поиском (debounce
320 мс), фильтром роли, сортировкой и пагинацией, состояние в URL
(`userQuery`/`userRole`/`userPage`/`userSize`/`userSort`), архивирование через
`ConfirmDialog` и восстановление; контракты `GET /api/users/page`,
`POST /api/users`, `DELETE /api/users/{id}`, `POST /api/users/{id}/restore`
сохранены; чистая логика — `users-service.js`;
- `react/admin/tabs/GroupsTab.jsx`: форма создания с каскадом
специальность → профиль, реестр с мультиселектом форм обучения
(React-фильтр с чекбоксами «Все» + формы, как `EquipmentMultiSelect`),
поиском, сортировкой, сбросом фильтров и пагинацией, состояние в URL
(включая массив `groupEducationForm`); модалки редактирования группы,
подгрупп (режимы 0/2/3, подсказанные вместимости, валидация суммы,
legacy-порядок синхронизации: лишние удаляются, затем обновления —
сначала уменьшающие вместимость, затем создания) и календарных графиков
(учебный год + `AsyncCombobox` совместимых графиков, список назначений с
дисциплинами по семестрам); контракты `GET /api/groups/page`,
`POST|PUT|DELETE /api/groups...`, `/subgroups`, `/calendar-assignments`,
`GET /api/admin/academic-calendars/options` сохранены; чистая логика —
`groups-service.js` (заголовки графиков — общий
`admin/js/views/academic-calendar-title.js`);
- `react/admin/tabs/UniversityStructureTab.jsx`: внутренние разделы
кафедр/специальностей/профилей, формы создания и редактирования
(модалки кафедр и специальностей, форма профиля на вкладке и в модалке
специальности), клиентская фильтрация и пагинация списков как в legacy
`listPage` (поиск с debounce 250 мс, `toLocaleLowerCase('ru')`); контракты
`GET|POST|PUT|DELETE /api/departments`, `/api/specialties...`,
`/api/specialties/profiles` сохранены; чистая логика —
`university-structure-service.js`;
- legacy-файлы вкладок удалены (`admin/js/views/{users,groups,
university-structure}.js`, `admin/views/{users,groups,
university-structure}.html`), загрузчики убраны из `legacy-views.js`,
у маршрутов в `admin-service.js` больше нет `viewFile`;
- inline-стили заменены классами в `admin/css/components.css`
(`modal-content--wide/--xwide/--full`, `profile-form-*`,
`form-actions-row`); CSP-хэши `style-src-attr` пересчитаны (22 → 6);
- бюджеты: entrypoint админ-панели 5 508 байт gzip (лимит 20 000), холодная
загрузка 83 855 байт gzip (лимит 90 000), общие chunk 78 347 байт gzip
(лимит 85 000), lazy-вкладки 118 009 байт gzip в 26 chunk'ах (лимит
150 000, загружаются по одной) — лимиты не поднимались;
- `npm run check`: 195 тестов пройдено (13 новых: контракты, валидация
подгрупп, фильтрация списков, DOM-рендеры вкладок волны 2).
Выполнено на этапе 5 (волна 3 — `teacher-requests`, `department-workspace`):
- `react/admin/tabs/TeacherRequestsTab.jsx`: реестр заявок с поиском (debounce
320 мс), фильтром статуса (по умолчанию `PENDING`), пагинацией и состоянием
в URL (`requestQuery`/`requestStatus`/`requestPage`/`requestSize`);
редактируемые строки (кафедра, логин, ФИО, должность, пароль —
`type="password"` + `autoComplete="new-password"`), одобрение с валидацией
legacy (все поля + пароль от 8 символов), отклонение через новый общий
`PromptDialog` (реплика `promptAction`: обязательная причина, ошибка внутри
диалога); событие `teacherRequestsChanged` для счётчика sidebar
диспетчируется как в legacy (count актуален только для нефильтрованного
списка `PENDING`); контракты `GET /api/teacher-requests/page`,
`POST /api/teacher-requests/{id}/approve|reject` сохранены; чистая логика —
`teacher-requests-service.js`;
- `react/shared/ui/ConfirmDialog.jsx`: добавлен `PromptDialog` — реплика
legacy `promptAction` из `admin/js/dialog.js` (разметка `project-dialog*`,
обязательное поле с `requiredMessage`, Escape/клик по фону закрывают);
- `react/admin/tabs/DepartmentWorkspaceTab.jsx`: карточка управления
(кафедра — скрыта для роли `DEPARTMENT`, период, обновление), загрузка
дисциплин строками (добавить/удалить, валидация legacy), привязка
преподавателя (доступные = все минус привязанные), заявка на преподавателя,
сводка «Преподаватели и нагрузка» (сопоставление по `id:` и нормализованному
имени, непривязанная нагрузка — «Есть занятия в расписании кафедры», как в
legacy), таблицы заявок и дисциплин кафедры, комментарии к дисциплинам
(карточка открывается по кнопке «Открыть»); период по умолчанию — из
справочника семестров (`defaultSchedulePeriod`) с fallback «сегодня + 7
дней»; сессия — `getSession()` из `admin/js/api.js`, как в legacy; контракты
`/api/department/*`, `/api/workload/teachers`, `/api/schedule/semesters`,
`/api/users/teachers` сохранены; чистая логика —
`department-workspace-service.js`;
- legacy-файлы вкладок удалены (`admin/js/views/{teacher-requests,
department-workspace}.js`, `admin/views/{teacher-requests,
department-workspace}.html`), загрузчики убраны из `legacy-views.js`,
у маршрутов в `admin-service.js` больше нет `viewFile`;
- inline-стиль `style="margin:0"` из legacy HTML заменён классом
`schedule-section-label--flush` в `admin/css/components.css`; CSP-хэши
`style-src-attr` пересчитаны (6 → 5);
- бюджеты: entrypoint админ-панели 4 371 байт gzip (лимит 20 000), холодная
загрузка 83 268 байт gzip (лимит 90 000), общие chunk 78 897 байт gzip
(лимит 85 000; `PromptDialog` добавил ~550 байт gzip), lazy-вкладки
121 368 байт gzip в 27 chunk'ах (лимит 150 000, загружаются по одной) —
лимиты не поднимались;
- `npm run check`: 205 тестов пройдено (10 новых: состояние и контракты
заявок, payload-валидация кабинета кафедры, сопоставление нагрузки,
DOM-рендеры вкладок волны 3).
Выполнено на этапе 5 (волна 4, срез 1 — `auditorium-workload`):
- `react/admin/tabs/AuditoriumWorkloadTab.jsx`: переключатель «Что смотреть»
(аудитории/преподаватели/кафедры) и селект «Отображение» (все/конкретная
сущность), сводная таблица занятости на дату (ось — сущности, колонки —
эффективные слоты, карточки занятий, «Свободно»), фильтры аудиторий
(корпус/мин. вместимость/оборудование — React-мультиселекты, применяются
к уже загруженным данным без перезапроса), двухнедельная сводка по
выбранной сущности (чёт/нечёт недели в ячейках одного дня, сводка
«Занято: N из M», предупреждения об ошибках загрузки и датах вне
семестра); сводный текст «Дата: …, Занятых слотов: …» воспроизводит
legacy `renderSummary` (включая «выбран один объект» и счётчик ошибок
групп); при сбое части групп/сеток показываются доступные данные с
предупреждением (legacy `allSettled`-стратегия);
- `react/admin/auditorium-workload-service.js`: контракты
`GET /api/classrooms`, `/api/equipments`, `/api/groups`,
`/api/admin/calendar/years` (сбой молча даёт `[]`), `/api/users/teachers`,
`/api/departments`, `/api/admin/time-slots/effective?date=`,
`/api/schedule?groupId=&startDate=&endDate=`; чистая логика: дедупликация
занятий (`uniqueLessons` с мерджем групп/подгрупп), группировка по
сущность×слот и дата×слот, сопоставление преподаватель→кафедра
(`departmentId`, fallback по названию), двухнедельный период с
понедельника, номера учебных недель и чётность (паритет занятия
перекрывает расчёт по семестру, вне семестра — fallback по индексу
даты), строки слотов сводной недели, состояния ячеек
(busy/free/missing/empty) и сравнение наборов занятий подписью;
- legacy-файлы вкладки удалены (`admin/js/views/auditorium-workload.js`,
`admin/views/auditorium-workload.html`), загрузчик убран из
`legacy-views.js`, у маршрута в `admin-service.js` больше нет `viewFile`;
- inline-стили legacy HTML (отступы заголовка/фильтров, ширина поля
вместимости, дата) заменены классами `workload-card-header`,
`workload-filter-row` (дополнен), `workload-min-capacity`,
`workload-date-field` в `admin/css/auditorium-workload.css`;
CSP-хэши `style-src-attr` пересчитаны (5 → 1: в корпусе остался один
inline-стиль в `schedule-view.js`);
- бюджеты: entrypoint админ-панели 4 372 байта gzip (лимит 20 000),
холодная загрузка 83 266 байт gzip (лимит 90 000), общие chunk
78 894 байта gzip (лимит 85 000), lazy-вкладки 122 252 байта gzip
в 27 chunk'ах (лимит 150 000, загружаются по одной) — лимиты
не поднимались;
- `npm run check`: 212 тестов пройдено (7 новых: период и чётность недель,
дедупликация/группировка занятий, фильтры и опции, контракты API,
сводки и счётчики, DOM-рендер вкладки).
**Следующее действие при продолжении:** этап 5, волна 5 — контур публикации
(`schedule-view`, `schedule-versions`, `schedule-quality`, `teacher-absences`)
по той же схеме: выделить чистую логику, переиспользовать `react/shared`,
заменить загрузчик в `legacy-views.js` на запись в `react-tabs.js`, удалить
legacy-файл вкладки. Последними (волна 6) — `schedule` +
`schedule-override-panel`.
Выполнено на этапе 5 (волна 4, срез 2 — `academic-calendar`):
- `react/admin/tabs/AcademicCalendarTab.jsx`: три внутренних раздела
(Графики/Сетки/Дисциплины) с подтверждением ухода при несохранённых
изменениях; раздел «Графики»: таблица учебных годов (редактирование года и
отдельного семестра, удаление через `ConfirmDialog`), форма календарного
графика (год, специальность → профиль, форма обучения, число курсов),
реестр графиков (поиск с debounce 300 мс, фильтр по году, пагинация,
кнопки «Изменить»/«Сетка»/«Дисциплины»/«Удалить»), конструктор учебного
года (ввод года начала автоматически считает год окончания и даты обоих
семестров, 4 поля дат с маской); раздел «Сетки»: выбор графика через
`AsyncCombobox`, загрузка/сохранение сетки, заполнение диапазона
(курс/даты/код активности), чипы итогов с маркером выделения, блоки
курсов с таблицами семестров, диалог ячейки (одно/множественное
выделение, код активности на всё выделение, информационная временная
сетка, «Убрать ручную сетку»), drag-выделение ячеек и подсказка через
портал в `document.body` с позиционированием `el.animate` (реплика
legacy, без inline-стилей); раздел «Дисциплины»: выбор графика,
семестр/дисциплина, привязки по семестрам чипами с удалением,
добавление/сохранение с dirty-индикатором «•»;
- `react/admin/academic-calendar-service.js`: контракты
`GET /api/admin/academic-calendars/page|options`, `GET|PUT
/api/admin/academic-calendars/{id}/grid|subjects`, `POST|PUT|DELETE
/api/admin/calendar/years`, `DELETE /api/admin/academic-calendars/{id}`,
`/api/specialties/{id}/profiles`, `/api/admin/time-slots/*`; чистая
логика: расчёт семестров учебного года и валидация пересечений,
построение сетки по датам учебного года, блоки семестров, итоги по кодам
активностей, тексты диалога/подсказки, расчёт позиции подсказки;
заголовки графиков — общий `admin/js/views/academic-calendar-title.js`
(как в `groups-service`);
- `MaskedDateInput` внутри вкладки — React-реплика `bindDateInputMask`
из `admin/js/date-input.js` (форматирование при вводе, нормализация и
`aria-invalid` при потере фокуса), переиспользует чистые функции
`formatDateInputValue`/`dateInputValueToIso`/`isoDateToInputValue`;
- мёртвый код legacy не переносился: «Применить» в диалоге ячейки только
закрывает окно, выбор временной сетки информационный (задокументировано
в FRONTEND.md), «Убрать ручную сетку» реально удаляет назначение;
- legacy-файлы вкладки удалены (`admin/js/views/academic-calendar.js`,
`admin/views/academic-calendar.html`; общие хелперы
`academic-calendar-title.js`/`academic-calendar-grid.js` сохранены —
используются React-сервисом), загрузчик убран из `legacy-views.js`,
у маршрута в `admin-service.js` больше нет `viewFile`;
- inline-стилей в legacy HTML вкладки не было — CSP-хэши `style-src-attr`
не изменились (1 хэш, inline-стиль в `schedule-view.js`);
- бюджеты: entrypoint админ-панели 4 375 байт gzip (лимит 20 000),
холодная загрузка 83 848 байт gzip (лимит 90 000), общие chunk
79 473 байта gzip (лимит 85 000), lazy-вкладки 124 206 байт gzip
в 26 chunk'ах (лимит 150 000, загружаются по одной) — лимиты
не поднимались;
- `npm run check`: 217 тестов пройдено (5 новых: контракты API и эндпоинты,
ввод года и чтение дат, подписи и сортировки, построение сетки и тексты
диалога/подсказки, DOM-рендер вкладки).
**Следующее действие при продолжении:** этап 5, волна 5 — контур публикации
(`schedule-view`, `schedule-versions`, `schedule-quality`, `teacher-absences`)
по той же схеме: выделить чистую логику, переиспользовать `react/shared`,
заменить загрузчик в `legacy-views.js` на запись в `react-tabs.js`, удалить
legacy-файл вкладки. Последними (волна 6) — `schedule` +
`schedule-override-panel`.
Выполнено на этапе 5 (волна 5, срез 1 — `schedule-view`):
- `react/admin/tabs/ScheduleViewTab.jsx`: карточка параметров (цель просмотра —
для `DEPARTMENT` заблокирована на «Кафедра», `AsyncCombobox` группы/
преподавателя/аудитории, обычный select кафедры, скрытое поле даты,
дополнительные фильтры с каскадом «учебный год → семестр», дисциплина, тип
занятия, чётность), карточка результатов (счётчик, чипы разделов, кнопки
предыдущего/следующего расписания, кнопка реестра правок) и одна активная
таблица (семестровая матрица, совмещённая двухнедельная таблица выбранной
сущности, мобильный блок одного дня с переключателем дней); состояние таблиц
выводится производно из `sections/activeSectionIndex/range/isMobile`,
пустое состояние с подсказкой содержит собственную кнопку «Показать
расписание»;
- `schedule-override-panel.js` (634 строки) сохранён как общий хелпер волны 6:
React-вкладка рендерит drawer как статичный JSX (все `schedule-override-*`
id сохранены) и вызывает `createScheduleOverridePanel` в эффекте после
загрузки справочников; колбэки панели (`loadData`, `getRange`) читают только
`latestRef`/`rangeRef`, поэтому захваченные панелью замыкания не протухают;
- `react/admin/schedule-view-service.js`: контракты `GET /api/departments`,
`/api/subjects`, `/api/lesson-types`, `/api/schedule/semesters`,
`/api/schedule/search`, `/api/edu-office/schedule/overrides`,
`/api/groups/options`, `/api/users/teachers/options`,
`/api/classrooms/options`; чистая логика: разделы по цели просмотра
(выбранная группа собирает все занятия в один раздел — фильтрует сервер),
семестровая матрица с чётностью и границами недель, совмещённые двухнедельные
блоки, подписи недель из академических часов, URL-состояние фильтров
(кафедра не сохраняет цель — она зафиксирована ролью);
- прелоад трёх комбобоксов на монтировании воспроизводит legacy
`combo.preload('')`: справочники сущностей наполняются заранее, подпись
восстановленного из URL значения обновляется после загрузки опций;
- legacy-файлы вкладки удалены (`admin/js/views/schedule-view.js`,
`admin/views/schedule-view.html`), загрузчик убран из `legacy-views.js`,
у маршрута в `admin-service.js` больше нет `viewFile`;
- inline-стиль legacy (flex в заголовке карточки занятия) оказался избыточным —
правило уже есть в классе `.schedule-view-lesson-title`
(`auditorium-workload.css`); CSP-хэши `style-src-attr` стали пустыми
(1 → 0), директива `unsafe-hashes` сохранена;
- мёртвый код legacy не переносился: кнопок периода `prev-period`/`today`/
`next-period` в разметке не было (в legacy они были null);
- бюджеты: entrypoint админ-панели 4 383 байта gzip (лимит 20 000),
холодная загрузка 83 856 байт gzip (лимит 90 000), общие chunk
79 473 байта gzip (лимит 85 000), lazy-вкладки 126 170 байт gzip
в 26 chunk'ах (лимит 150 000, загружаются по одной) — лимиты
не поднимались;
- `npm run check`: 224 теста пройдено (7 новых: контракты API просмотра,
разделы по цели, двухнедельная сетка и матрица семестра, подписи недель,
URL-состояние, пополнение справочников, DOM-рендер вкладки).
**Следующее действие при продолжении:** этап 5, волна 5, срезы 2–4 —
`schedule-versions`, `schedule-quality`, `teacher-absences` по той же схеме:
выделить чистую логику, переиспользовать `react/shared`, заменить загрузчик
в `legacy-views.js` на запись в `react-tabs.js`, удалить legacy-файл вкладки.
Последними (волна 6) — `schedule` + `schedule-override-panel`.
Выполнено на этапе 5 (волна 5, срез 2 — `schedule-versions`):
- `react/admin/tabs/ScheduleVersionsTab.jsx`: hero с каскадом «учебный год →
семестр» (восстановление из `localStorage`-ключей `magistr.schedule.*`),
статусная строка с `data-tone` (`loading`/`success`/`error`), карточка
опубликованной версии (LIVE/ПУСТО), панель черновиков со счётчиком, панель
сравнения (Δ / загрузка / ошибка / сводка «Что изменится после публикации»
с карточками правил), архив публикаций, журнал действий и модальное окно
операций (создание черновика, публикация со сводкой валидации, восстановление)
с закрытием по Escape/фону и блокировкой отправки при непройденной проверке;
- переходы «Редактировать»/«Качество» передают контекст через `localStorage`
(`magistr.schedule.openVersionId`, `magistr.quality.*`) и переключают вкладку
через `location.hash` — hash-router `AdminApp` проводит переход через
guard несохранённых изменений, как клик по пункту меню в legacy;
- `react/admin/schedule-versions-service.js`: контракты `GET
/api/schedule/semesters`, `/api/edu-office/schedule/versions?semesterId=`,
`.../history?semesterId=`, `.../{id}/diff`, `.../{id}/validate`, `POST
/api/edu-office/schedule/versions` (черновик), `POST .../{id}/publish|restore`
(причина); чистая логика: группировка версий (published/drafts/archive),
подписи действий журнала и типов изменений, основа черновика, сводка
валидации перед публикацией, формат даты-времени; localStorage-ключи
экспортированы константами для волны 6;
- до первой загрузки панели пусты, как в legacy HTML (пустые блоки «Нет
активных черновиков» и т. п. появляются только после успешной загрузки);
мёртвый CSS `version-modal-summary[data-tone]` не поддержан (в legacy
атрибут никогда не ставился);
- legacy-файлы вкладки удалены (`admin/js/views/schedule-versions.js`,
`admin/views/schedule-versions.html`), загрузчик убран из
`legacy-views.js`, у маршрута в `admin-service.js` больше нет `viewFile`;
- inline-стилей в legacy HTML вкладки не было — CSP-хэши `style-src-attr`
остались пустыми;
- бюджеты: entrypoint админ-панели 4 378 байт gzip (лимит 20 000),
холодная загрузка 83 851 байт gzip (лимит 90 000), общие chunk
79 473 байта gzip (лимит 85 000), lazy-вкладки 127 193 байта gzip
в 26 chunk'ах (лимит 150 000, загружаются по одной) — лимиты
не поднимались;
- `npm run check`: 227 тестов пройдено (3 новых: контракты API контура
публикаций, группировка версий и подписи, DOM-рендер вкладки).
**Следующее действие при продолжении:** этап 5, волна 5, срезы 3–4 —
`schedule-quality`, `teacher-absences` по той же схеме: выделить чистую
логику, переиспользовать `react/shared`, заменить загрузчик в
`legacy-views.js` на запись в `react-tabs.js`, удалить legacy-файл вкладки.
Последними (волна 6) — `schedule` + `schedule-override-panel`.
Выполнено на этапе 5 (волна 5, срез 3 — `schedule-quality`):
- `react/admin/tabs/ScheduleQualityTab.jsx`: каскад «учебный год → семестр →
версия» (восстановление из `localStorage`-ключей `magistr.quality.*`,
версия по умолчанию — сохранённая, иначе опубликованная, иначе первая),
статусная строка с `data-tone` (включая `warning`), кнопка анализа с
состоянием «Анализируем…», скорборд с SVG-кольцом (`stroke-dasharray`
по клампу счёта, `data-band`), метрики, карта проблем с фильтрами
важности/категории и сортировкой по штрафу, панель выбранного правила
с переходом в конструктор;
- блок результатов скрывается на время анализа и остаётся скрытым при
ошибке (как `hidden` в legacy), пустые блоки фильтра — только после
успешного анализа;
- переход «Открыть правило в конструкторе» передаёт контекст через
`localStorage` (`magistr.schedule.semesterId`, `openVersionId`,
`openRuleId`) и переключает вкладку через `location.hash`;
- `react/admin/schedule-quality-service.js`: контракт
`GET /api/edu-office/schedule/quality?semesterId=&versionId=`; чистая
логика: диапазоны оценки, фильтрация и сортировка проблем по категориям
(окна/нагрузка/аудитории/пожелания), подписи статусов версий и
важности, формат метрик и дат, склонение «занятие/занятия/занятий»,
сводка «Показаны первые N из M правил»; семестры и версии переиспользуют
контракты `schedule-versions-service.js`;
- legacy-файлы вкладки удалены (`admin/js/views/schedule-quality.js`,
`admin/views/schedule-quality.html`), загрузчик убран из
`legacy-views.js`, у маршрута в `admin-service.js` больше нет `viewFile`;
- бюджеты: entrypoint админ-панели 4 357 байт gzip (лимит 20 000),
холодная загрузка 83 830 байт gzip (лимит 90 000), общие chunk
79 473 байта gzip (лимит 85 000), lazy-вкладки 130 276 байт gzip
в 26 chunk'ах (лимит 150 000, загружаются по одной) — лимиты
не поднимались;
- `npm run check`: 233 теста пройдено (3 новых: контракт API анализа,
подписи и диапазоны, DOM-рендер вкладки).
Выполнено на этапе 5 (волна 5, срез 4 — `teacher-absences`):
- `react/admin/tabs/TeacherAbsencesTab.jsx`: сводка статистики, форма
регистрации отсутствия (React-`AsyncCombobox` поиска преподавателя —
для роли DEPARTMENT подставляет `departmentId` из сессии, даты по
умолчанию сегодня), реестр инцидентов с фильтром статуса и действиями
(подтвердить/отклонить/отменить/мастер), мастер замены (overlay с
блокировкой прокрутки `absence-wizard-open` на body, решения по
каждому занятию, журнал решений, «Применить выбранные» с проверкой
«Выберите хотя бы одно решение»), согласование пожеланий на семестр
(фильтры семестра/статуса, счётчик ожидающих, комментарий обязателен
при отклонении) и заявки на изменение (применение доступно только
ADMIN/EDUCATION_OFFICE, история решения);
- модальные сообщения об ошибках действий воспроизведены локальным
диалогом с классами `project-dialog` (разметка и поведение `alertAction`
из `dialog.js`: закрытие по Escape/фону, кнопка «Понятно»);
- до инициализации списки согласования пусты, реестр показывает
«Загрузка...» — как в legacy HTML; мастер замены рендерится только
при открытии;
- `react/admin/teacher-absences-service.js`: контракты
`GET/POST/DELETE /api/teacher-absences` (+ `/{id}/review`, `/{id}/wizard`,
`/{id}/resolve`), `GET /api/teacher-preferences/meta`, `GET/POST
/api/teacher-preferences`, `GET/POST /api/teacher-change-requests`,
`GET /api/users/teachers/options`; чистая логика: варианты решений
мастера и разбор `REPLACE_TEACHER|id` / `MOVE_TIME|date|slot` /
`CHANGE_CLASSROOM|id`, подписи статусов и решений, склонение
«решение/решения/решений», сводка статистики, цель заявки на изменение;
- legacy-файлы вкладки удалены (`admin/js/views/teacher-absences.js`,
`admin/views/teacher-absences.html`), загрузчик убран из
`legacy-views.js`, у маршрута в `admin-service.js` больше нет `viewFile`;
в `legacy-views.js` остался единственный загрузчик — `schedule` (волна 6);
- бюджеты: lazy-вкладки 130 276 байт gzip в 26 chunk'ах (лимит 150 000),
остальные без изменений — лимиты не поднимались;
- `npm run check`: 233 теста пройдено (3 новых: контракты API отсутствий,
подписи и сводки, DOM-рендер вкладки).
Выполнено на этапе 5 (волна 6, финальная — `schedule` + `schedule-override-panel`):
- `react/admin/tabs/ScheduleTab.jsx`: конструктор правил — каскад
учебный год → семестр → версия (контекст-бар с eyebrow/заголовком/заметкой,
выбор версии по localStorage-ключам `magistr.schedule.openVersionId`/
`openRuleId` из `schedule-versions-service.js`, модалка создания черновика
с копией правил), форма правила (дисциплина, семестр синхронизирован с
версией, часы/недели начала по типам занятий, мультиселект групп с поиском
и кэшем выбранных, слоты с подгруппами только для лабораторных, подсказки
согласованных пожеланий преподавателя), реестр правил с поиском, пагинацией
и удалением, модалка конфликта (причины, редактируемые слоты конфликтующего
правила, повтор с сохранением payload), визуальная матрица (drawer с
фильтром года/семестра/групп, ячейки по чётности, контекстное меню пары —
позиционирование через Web Animations API, действия «открыть правило»/
«изменить день и пару»/«удалить»), dirty-guard формы через общий
`dirty-state.js`;
- `react/admin/schedule-service.js`: чистая логика и контракты API —
слоты правил и payload-валидация (часы по типам, подгруппы только
лабораторных и по одной на группу), payload конфликтующего правила,
подписи правил/черновиков, группировка опций версий, визуальная матрица
(ячейки по чётности, активные недели, дедупликация занятий), позиционирование
контекстного меню, `GET /api/admin/schedule-rules/page|/{id}`,
`POST|PUT|DELETE /api/admin/schedule-rules`, `GET /api/groups/options`,
`GET /api/groups/subgroups`, `GET /api/teacher-preferences?status=APPROVED`;
- боковая панель разовых правок `schedule-override-panel.js` абсорбирована
в `ScheduleViewTab.jsx`: drawer переписан на React-состояние
(`forwardRef` + `useImperativeHandle`: `openLesson`/`openRegistry`),
реестром правок за период владеет вкладка (кнопка «Изменения за период»
со счётчиком), вкладка «Редактирование» — сравнение «было/станет»,
недельный выбор дат, календарь семестра, время по эффективным слотам,
преподаватель/аудитория через React-`AsyncCombobox` с пополнением
справочников, отмена занятия и возврат по правилу; чистая логика —
`react/admin/schedule-override-service.js` (контракты
`GET /api/edu-office/schedule/overrides/availability`,
`GET /api/admin/time-slots/effective`, `POST|PUT|DELETE
/api/edu-office/schedule/overrides`);
- переходный слой удалён: `react/admin/LegacyTabView.jsx`,
`react/admin/legacy-views.js`, `admin/views/schedule.html`,
`admin/js/views/schedule.js`, `admin/js/views/schedule-override-panel.js`,
а также оставшиеся без пользователей `admin/js/async-combobox.js`
и `admin/js/pagination.js` (React-аналоги — `react/shared/ui/AsyncCombobox.jsx`
и `Pagination.jsx`); все 15 вкладок админ-панели — React, HTML-фрагменты
больше не загружаются через `fetch`;
- бюджеты: entrypoint админ-панели 5 640 байт gzip (лимит 20 000), холодная
загрузка 84 857 байт gzip (лимит 90 000), общие chunk 79 217 байт gzip
(лимит 85 000), lazy-вкладки 128 571 байт gzip в 25 chunk'ах (лимит 150 000,
загружаются по одной) — лимиты не поднимались;
- `npm run check`: 237 тестов пройдено (обновлены импорты тестов с legacy-модулей
на React-сервисы, добавлены payload-валидация, DOM-рендер конструктора
и контроль разрешения lazy-вкладок; проверки `LegacyTabView`/`legacy-views`
заменены на контроль отсутствия legacy-модулей).
Починено при первом браузерном smoke-тесте после волны 6: реестр `react-tabs.js`
использовал голый `import()` внутри `React.lazy`, а компоненты вкладок экспортируются
именованными экспортами — без `default` React падал при открытии любой вкладки
(React error #306, «Интерфейс временно недоступен» после авторизации). Исправлено
явным отображением `.then(m => ({ default: m.XTab }))`; добавлен регрессионный тест,
проверяющий разрешение всех 15 lazy-вкладок.
Этап 5 закрыт: основная админ-панель полностью работает на React.
Команды для возобновления:
```bash
cd frontend
npm ci
npm run build
npm run check
```

View File

@@ -0,0 +1,101 @@
# Итоги миграции frontend на React
Дата завершения: 28.09.2026. Полный журнал процесса — в [`REACT_MIGRATION.md`](REACT_MIGRATION.md),
архитектура итогового решения — в [`FRONTEND.md`](FRONTEND.md).
---
## Что сделано
По модели strangler (этапы 1–5, все изменения не закоммичены — коммит по решению владельца):
| Этап | Содержимое | Статус |
|------|-----------|--------|
| 1. Точка входа и вход в систему | `index.html`, страница логина | Завершён |
| 2. Интерфейс студента | SPA `student/` | Завершён |
| 3. Интерфейс преподавателя | SPA `teacher/` (пожелания, заявки) | Завершён |
| 4. Страница настроек | Отдельный SPA `admin/settings/` (4 вкладки) | Завершён |
| 5. Основная админ-панель | Shell + hash-router + **15 вкладок** в 6 волнах | Завершён |
Волны этапа 5: `dashboard`, `classrooms`, `subjects` → `users`, `groups`, `university-structure` →
`teacher-requests`, `department-workspace` → `auditorium-workload`, `academic-calendar` →
`schedule-view`, `schedule-versions`, `schedule-quality`, `teacher-absences` → `schedule`
(конструктор правил + абсорбция панели разовых правок).
**Удалено 45 legacy-файлов** (~19 000 строк vanilla-кода): все `admin/js/views/*.js`
и `admin/views/*.html`, `admin/js/main.js`, переходный слой `LegacyTabView.jsx` + `legacy-views.js`,
дублирующие утилиты `async-combobox.js` и `pagination.js` (заменены общими React-компонентами
`AsyncCombobox.jsx` и `Pagination.jsx`).
**Добавлено 75 React-файлов** (~23 200 строк): оболочки четырёх SPA, 15 вкладок админки,
19 сервисов с чистой логикой, общая дизайн-система (`react/shared/ui/`), render-хелперы для тестов.
---
## Насколько стало лучше
### Скорость загрузки и переключения вкладок
| Метрика (gzip) | До (legacy) | После (React) |
|----------------|-------------|---------------|
| Переключение вкладки админки | 2 запроса: `fetch` HTML-фрагмента + lazy JS | **1 запрос**: один lazy-chunk |
| Средний размер вкладки | HTML + JS отдельно | ~5,1 КБ (128 571 байт / 25 chunk'ов) |
| Общие chunk'и (кэшируются между страницами) | — | 79 217 байт (лимит 85 000) |
| Холодная загрузка админки | — | 84 857 байт (лимит 90 000) |
- HTML-фрагменты больше **не загружаются через `fetch`** — вкладка рендерится сразу из
одного кэшируемого JS-chunk'а, исчезли двойные сетевые задержки и парсинг HTML на каждое
переключение.
- Runtime, дизайн-система и утилиты вынесены в общие chunk'и: повторные визиты и переходы
между страницами (логин → студент → админка) переиспользуют кэш браузера.
- Каждая вкладка загружается отдельно (`React.lazy` + `Suspense`) — пользователь качает
только то, что открывает.
### Качество и надёжность
- **237 тестов** проходят (`npm run check`), против ~170 на момент начала этапа 5:
контракты API, payload-валидация, DOM-рендер каждой вкладки, бюджеты bundle, CSP,
разрешение всех lazy-вкладок (регрессия на React error #306 поймана и закрыта тестом).
- Логика вынесена в **чистые сервисы** (`*-service.js`) — тестируются в Node без браузера
и DOM-заглушек.
- Единая дизайн-система вместо копипасты виджетов: кастомные селекты, `AsyncCombobox`,
`ConfirmDialog`, `Pagination`, `MaskedDateInput` — одинаковое поведение и a11y везде.
- Устранён класс ошибок ручного DOM: React-state вместо `innerHTML`/`classList`,
модалки — условный рендер вместо `hidden`-флагов, позиционирование меню — Web Animations API.
### Безопасность (не ослаблена)
- CSP-хэши: `[]` — ни одного элемента вне политики.
- JWT по-прежнему только в памяти, без inline-скриптов и inline-стилей
(проверяется тестами `security-policy.test.mjs`).
- Сообщения об ошибках не вставляют текст исключений через `innerHTML`.
### Сопровождаемость
- Один стек вместо двух: не нужно поддерживать параллельно vanilla-модули и React.
- −19 000 строк legacy-кода, +4 965 строк тестов.
- URL/REST-контракты и русский UI сохранены — для пользователей миграция незаметна.
---
## Бюджеты bundle (итог, лимиты не поднимались с волны 1)
| Entry point | Размер gzip | Холодная загрузка | Лимиты |
|-------------|-------------|-------------------|--------|
| Логин | 2 493 | 81 710 | 10 000 / 85 000 |
| Студент | 3 431 | 82 648 | 20 000 / 90 000 |
| Преподаватель | 10 145 | 89 362 | 20 000 / 90 000 |
| Настройки | 8 466 | 87 683 | 20 000 / 90 000 |
| Админ-панель | 5 640 | 84 857 | 20 000 / 90 000 |
| Lazy-вкладки админки | 128 571 (25 chunk'ов, по одной за раз) | — | 150 000 |
---
## Проверка
```bash
cd frontend
npm ci
npm run build
npm run check # 237/237 тестов, бюджеты, CSP
```

View File

@@ -14,7 +14,7 @@
| Компонент | Технология |
|-----------|-----------|
| **Backend** | Java 17, Spring Boot 3.5.16 |
| **Frontend** | Vanilla JavaScript (ES6 Modules) + HTML/CSS |
| **Frontend** | React 19 + переходные Vanilla JavaScript ES-модули + HTML/CSS |
| **База данных** | PostgreSQL (через Flyway миграции) |
| **Контейнеризация** | Docker, Docker Compose |
| **Продакшн** | Kubernetes, Caddy (реверс-прокси) |

View File

@@ -3,9 +3,12 @@ FROM node:22-alpine3.23@sha256:8516dce0483394d5708d4b2ee6cacb79fb1d617ea4e2787c2
WORKDIR /build
COPY package.json package-lock.json ./
RUN npm ci
COPY auth-session.js telemetry.js schedule-overview.js ./
COPY admin/js/ ./admin/js/
COPY react/ ./react/
COPY telemetry/ ./telemetry/
COPY scripts/build-vendor.mjs ./scripts/build-vendor.mjs
RUN npm run build:vendor
COPY scripts/build-react.mjs scripts/build-vendor.mjs ./scripts/
RUN npm run build
FROM httpd:alpine3.23@sha256:4a15e9c73f25334bc03cfb3c692c9adfc103bb46ca89cee1f0b9a5fcbc7b21f6
@@ -17,15 +20,17 @@ COPY department/ /usr/local/apache2/htdocs/department/
COPY edu-office/ /usr/local/apache2/htdocs/edu-office/
COPY student/ /usr/local/apache2/htdocs/student/
COPY teacher/ /usr/local/apache2/htdocs/teacher/
COPY index.html script.js style.css theme-toggle.js auth-session.js telemetry.js /usr/local/apache2/htdocs/
COPY index.html style.css theme-toggle.js auth-session.js telemetry.js /usr/local/apache2/htdocs/
COPY ui-foundation.css schedule-overview.css schedule-overview.js /usr/local/apache2/htdocs/
COPY --from=frontend-assets /build/dist/react/ /usr/local/apache2/htdocs/react/
COPY --from=frontend-assets /build/dist/vendor/ /usr/local/apache2/htdocs/vendor/
COPY security.conf /usr/local/apache2/conf/extra/magistr-security.conf
COPY react-cache.conf /usr/local/apache2/conf/extra/magistr-react-cache.conf
COPY proxy.conf /usr/local/apache2/conf/extra/magistr-proxy.conf
# Сервер раздаёт same-origin ресурсы и проксирует API во внутреннюю сеть Compose.
RUN sed -i 's/^#LoadModule headers_module/LoadModule headers_module/' /usr/local/apache2/conf/httpd.conf \
&& sed -i 's/^#LoadModule proxy_module/LoadModule proxy_module/' /usr/local/apache2/conf/httpd.conf \
&& sed -i 's/^#LoadModule proxy_http_module/LoadModule proxy_http_module/' /usr/local/apache2/conf/httpd.conf \
&& printf '\nInclude conf/extra/magistr-security.conf\nInclude conf/extra/magistr-proxy.conf\n' >> /usr/local/apache2/conf/httpd.conf \
&& printf '\nInclude conf/extra/magistr-security.conf\nInclude conf/extra/magistr-react-cache.conf\nInclude conf/extra/magistr-proxy.conf\n' >> /usr/local/apache2/conf/httpd.conf \
&& chown -R www-data:www-data /usr/local/apache2/htdocs/

View File

@@ -11,6 +11,22 @@
.workload-filter-row {
flex-wrap: wrap;
align-items: flex-end;
gap: 1.5rem;
margin-bottom: 2rem;
}
/* Заголовок карточки загруженности (вместо inline-стиля legacy-вьюхи) */
.workload-card-header {
margin-bottom: 1.5rem;
}
.workload-min-capacity {
width: 100px;
}
.workload-date-field {
max-width: 200px;
}
.workload-display-control {

View File

@@ -4186,3 +4186,251 @@ tbody tr:hover {
.project-dialog-field { display: grid; gap: 0.4rem; margin-top: 1rem; }
.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; }
/* ===== Êëàññû ýòàïà 5 (âîëíà 1): çàìåíà inline-ñòèëåé ïåðåíåñ¸ííûõ âêëàäîê ===== */
/* Çàãîëîâîê êàðòî÷êè ñ äåéñòâèåì ñïðàâà (àóäèòîðèè) */
.card-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
flex-wrap: wrap;
gap: 0.5rem;
}
.card-toolbar h2 {
margin: 0;
}
/* Ñòðîêè è ãðóïïû ôîðì */
.form-row--spacing {
margin-top: 1rem;
}
.form-row--bottom {
align-items: flex-end;
}
.form-group--wide {
flex: 2;
}
.form-group--flush {
margin-bottom: 0;
}
.form-group--submit {
display: flex;
align-items: flex-end;
}
.btn-block {
width: 100%;
}
.btn-inline {
height: fit-content;
margin-bottom: 0;
}
/* ß÷åéêè òàáëèö */
.th-actions {
text-align: right;
}
.table-cell-actions {
text-align: right;
white-space: nowrap;
}
.badge-list {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
}
.badge--tight {
margin: 0.1rem;
}
.badge--lesson-type {
font-size: 0.75rem;
}
/* Ìîäàëüíûå îêíà ïåðåíåñ¸ííûõ âêëàäîê */
.modal-content--medium {
max-width: 600px;
width: 90%;
}
.modal-header-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
gap: 1rem;
}
.modal-subtitle {
color: var(--text-secondary);
font-size: 0.9rem;
margin-top: 0.25rem;
}
.modal-subcard {
margin-bottom: 1.5rem;
background: var(--bg-secondary);
}
.modal-subcard h3 {
margin-top: 0;
}
/* Ñîñòîÿíèå çàãðóçêè lazy-âêëàäêè */
.tab-loading {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 2rem;
color: var(--text-secondary);
}
/* Äàøáîðä: ìåòðèêè è ìîíèòîðèíã */
.dashboard-metric-label {
font-size: 0.85rem;
color: var(--text-secondary);
font-weight: 500;
}
.dashboard-metric-value {
margin: 0.25rem 0 0;
font-size: 1.5rem;
font-weight: 700;
}
.dashboard-monitor-table table {
width: 100%;
border-collapse: collapse;
}
.dashboard-monitor-table th,
.dashboard-monitor-table td {
text-align: left;
padding: 0.75rem;
}
.dashboard-state-row {
padding: 1.5rem;
text-align: center;
color: var(--text-secondary);
}
.dashboard-state-row--wide {
padding: 2rem;
}
.dashboard-free-hint {
color: var(--text-secondary);
font-size: 0.9rem;
}
.dashboard-free-busy {
color: var(--error);
font-size: 0.9rem;
font-weight: 500;
}
.dashboard-free-badge {
padding: 0.4rem 0.6rem;
font-size: 0.85rem;
background: rgba(16, 185, 129, 0.1);
color: #10b981;
border: 1px solid rgba(16, 185, 129, 0.2);
}
/* Äàøáîðä: öåíòð ïðåäóïðåæäåíèé (Red Zone) */
.red-zone-card {
border-top: 4px solid var(--error, #ef4444);
}
.red-zone-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
gap: 1rem;
flex-wrap: wrap;
}
.red-zone-title {
margin: 0;
display: flex;
align-items: center;
gap: 0.5rem;
}
.red-zone-subtitle {
color: var(--text-secondary);
font-size: 0.85rem;
margin-top: 0.25rem;
}
.red-zone-conflicts {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-top: 1rem;
}
.dashboard-check-loading {
text-align: center;
padding: 2rem;
color: var(--text-secondary);
}
/* ===== Ýòàï 5 (âîëíà 2): êëàññû inline-ñòèëåé ïåðåíåñ¸ííûõ âêëàäîê ===== */
/* Øèðîêèå ìîäàëüíûå îêíà (ïîäãðóïïû, ïðîôèëè, êàëåíäàðíûå ãðàôèêè) */
.modal-content--wide {
max-width: 700px;
width: 90%;
}
.modal-content--xwide {
max-width: 800px;
width: 90%;
}
.modal-content--full {
max-width: 960px;
width: 90%;
}
/* Ôîðìà ïðîôèëÿ íà âêëàäêå ñòðóêòóðû âóçà */
.profile-form-spec {
flex: 1;
min-width: 200px;
}
.profile-form-name {
flex: 1.5;
min-width: 250px;
}
.profile-form-description {
flex: 2;
min-width: 250px;
}
/* Ðÿä êíîïîê ôîðìû, âûðîâíåííûõ ïî íèæíåé êðîìêå */
.form-actions-row {
display: flex;
gap: 0.5rem;
align-items: flex-end;
}
/* Âîëíà 3 (ýòàï 5): çàìåíà inline-ñòèëÿ èç legacy department-workspace.html */
.schedule-section-label--flush {
margin: 0;
}

View File

@@ -265,6 +265,20 @@
margin: 0.25rem 0.5rem;
}
/* ===== Корневой контейнер ===== */
/* body — flex-контейнер, поэтому без явного растяжения корневой
div сжимается до max-content контента: страницы с узким контентом
(карточки, формы) не занимают всю ширину монитора, а дашборд
«растягивался» только после загрузки данных. Растягиваем корень
ровно на ширину окна (flex-basis 0), а не под контент: intrinsic-замер
max-content раздувает вложенные flex-фильтры (например, панель
загруженности аудиторий) до ~1 млн px горизонтального скролла. */
#admin-root {
flex: 1 1 0%;
min-width: 0;
}
/* ===== Main ===== */
.main {
flex: 1;

View File

@@ -19,243 +19,9 @@
</head>
<body>
<!-- Sidebar -->
<aside class="sidebar">
<div class="sidebar-header">
<div class="logo">
<svg width="32" height="32" viewBox="0 0 40 40" fill="none">
<rect width="40" height="40" rx="12" fill="url(#lg)" />
<path d="M12 20L18 26L28 14" stroke="#fff" stroke-width="3" stroke-linecap="round"
stroke-linejoin="round" />
<defs>
<linearGradient id="lg" x1="0" y1="0" x2="40" y2="40">
<stop stop-color="#6366f1" />
<stop offset="1" stop-color="#8b5cf6" />
</linearGradient>
</defs>
</svg>
<span>Magistr</span>
</div>
<button class="btn btn-icon-sm btn-ghost sidebar-close-btn" id="sidebar-close-btn" aria-label="Скрыть панель">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="15 18 9 12 15 6" />
</svg>
</button>
</div>
<nav class="sidebar-nav">
<!-- Главная -->
<a href="#" class="nav-item" data-tab="dashboard">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="7" height="9" />
<rect x="14" y="3" width="7" height="5" />
<rect x="14" y="12" width="7" height="9" />
<rect x="3" y="16" width="7" height="5" />
</svg>
<span>Дашборд</span>
</a>
<div id="admin-root"></div>
<a href="#" class="nav-item" data-tab="teacher-requests">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" />
<rect x="8" y="2" width="8" height="4" rx="1" />
<path d="M8 11h8" />
<path d="M8 16h5" />
</svg>
<span>Заявки</span>
<span class="nav-badge" id="teacher-requests-nav-badge" hidden>0</span>
</a>
<a href="#" class="nav-item" data-tab="department-workspace">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M3 21h18" />
<path d="M5 21V7l7-4 7 4v14" />
<path d="M9 21v-6h6v6" />
<path d="M9 9h.01" />
<path d="M15 9h.01" />
</svg>
<span>Кабинет кафедры</span>
</a>
<!-- Раздел: Расписание -->
<div class="sidebar-section-divider"></div>
<div class="sidebar-section-title">Расписание</div>
<a href="#" class="nav-item" data-tab="schedule">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
<line x1="16" y1="2" x2="16" y2="6"></line>
<line x1="8" y1="2" x2="8" y2="6"></line>
<line x1="3" y1="10" x2="21" y2="10"></line>
</svg>
<span>Конструктор правил</span>
</a>
<a href="#" class="nav-item" data-tab="schedule-versions">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 7h12a4 4 0 0 1 4 4v7" />
<path d="M16 3l4 4-4 4" />
<path d="M20 17H8a4 4 0 0 1-4-4V6" />
<path d="M8 21l-4-4 4-4" />
</svg>
<span>Версии расписания</span>
</a>
<a href="#" class="nav-item" data-tab="schedule-quality">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 19V9" />
<path d="M10 19V5" />
<path d="M16 19v-7" />
<path d="M22 19V3" />
<path d="M2 19h20" />
</svg>
<span>Качество расписания</span>
</a>
<a href="#" class="nav-item" data-tab="academic-calendar">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 3h18v18H3z"></path>
<path d="M3 9h18"></path>
<path d="M9 3v18"></path>
<path d="M15 3v18"></path>
<path d="M3 15h18"></path>
</svg>
<span>Календарный график</span>
</a>
<a href="#" class="nav-item" data-tab="auditorium-workload">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<line x1="3" y1="9" x2="21" y2="9"></line>
<line x1="9" y1="21" x2="9" y2="9"></line>
</svg>
<span>Загруженность</span>
</a>
<a href="#" class="nav-item" data-tab="schedule-view">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z" />
<circle cx="12" cy="12" r="3" />
</svg>
<span>Просмотр расписаний</span>
</a>
<a href="#" class="nav-item" data-tab="teacher-absences">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M12 3a4 4 0 1 0 0 8 4 4 0 0 0 0-8z" />
<path d="M5 21v-2a7 7 0 0 1 14 0v2" />
<path d="M18 8l3 3" />
<path d="M21 8l-3 3" />
</svg>
<span>Запросы преподавателей</span>
</a>
<!-- Раздел: Субъекты и ресурсы -->
<div class="sidebar-section-divider"></div>
<div class="sidebar-section-title">Ресурсы</div>
<a href="#" class="nav-item" data-tab="classrooms">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M3 3h18v18H3zM9 3v18M15 3v18M3 9h18M3 15h18" />
</svg>
<span>Аудиторный фонд</span>
</a>
<a href="#" class="nav-item" data-tab="groups">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" />
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" />
</svg>
<span>Группы обучения</span>
</a>
<a href="#" class="nav-item" data-tab="university-structure">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"></path>
<rect x="8" y="2" width="8" height="4" rx="1" ry="1"></rect>
</svg>
<span>Структура вуза</span>
</a>
<a href="#" class="nav-item" data-tab="subjects">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M12 20h9" />
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" />
</svg>
<span>Дисциплины</span>
</a>
<!-- Раздел: Система -->
<div class="sidebar-section-divider"></div>
<div class="sidebar-section-title">Система</div>
<a href="#" class="nav-item" data-tab="users">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
<span>Пользователи</span>
</a>
</nav>
<div class="sidebar-footer">
<div class="settings-dropdown" id="settings-dropdown">
<button class="btn-settings" id="btn-settings">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
<span>Настройки</span>
<svg class="settings-chevron" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
<div class="settings-menu" id="settings-menu">
<a href="/admin/settings/" class="settings-menu-item">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
Общие настройки
</a>
<div class="settings-menu-divider"></div>
<button class="btn btn-md btn-danger-subtle btn-logout settings-menu-item settings-menu-item--danger" id="btn-logout">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<polyline points="16 17 21 12 16 7" />
<line x1="21" y1="12" x2="9" y2="12" />
</svg>
Выйти
</button>
</div>
</div>
</div>
</aside>
<!-- Sidebar overlay (mobile) -->
<div class="sidebar-overlay" id="sidebar-overlay"></div>
<!-- Main -->
<main class="main">
<header class="topbar">
<div class="topbar-title-group">
<h1 id="page-title">Загрузка...</h1>
<button type="button" class="topbar-request-badge" id="teacher-requests-title-badge" hidden>0 заявок</button>
</div>
</header>
<!-- Dynamic Content Injected Here -->
<section class="content" id="app-content">
<!-- Content loaded via main.js -->
</section>
</main>
<script src="/theme-toggle.js"></script>
<script type="module" src="js/main.js"></script>
<script type="module" src="/react/admin.js"></script>
</body>
</html>

View File

@@ -1,193 +0,0 @@
import { debounce } from './url-state.js';
export class AsyncCombobox {
constructor(select, {
load,
optionValue = item => item.id,
optionLabel = item => item.name,
optionDescription = () => '',
placeholder = 'Начните вводить название',
emptyLabel = 'Ничего не найдено',
allowEmpty = true
}) {
if (!select || typeof load !== 'function') throw new Error('Для поискового списка нужны select и функция загрузки');
this.select = select;
this.load = load;
this.optionValue = optionValue;
this.optionLabel = optionLabel;
this.optionDescription = optionDescription;
this.emptyLabel = emptyLabel;
this.allowEmpty = allowEmpty;
this.requestNumber = 0;
this.items = [];
select.dataset.nativeSelect = 'true';
select.hidden = true;
this.root = document.createElement('div');
this.root.className = 'async-combobox';
this.root.innerHTML = `
<div class="async-combobox-control">
<input type="search" role="combobox" aria-autocomplete="list" aria-expanded="false"
autocomplete="off" placeholder="${escapeText(placeholder)}">
<button type="button" class="async-combobox-clear" aria-label="Очистить выбор" ${allowEmpty ? '' : 'hidden'}>×</button>
</div>
<div class="async-combobox-menu" role="listbox" hidden></div>
`;
select.insertAdjacentElement('afterend', this.root);
this.input = this.root.querySelector('input');
this.menu = this.root.querySelector('[role="listbox"]');
this.clearButton = this.root.querySelector('.async-combobox-clear');
this.input.setAttribute('aria-label', select.getAttribute('aria-label') || placeholder);
this.labels = select.id
? [...document.querySelectorAll('label[for]')].filter(label => label.htmlFor === select.id)
: [];
if (select.id) {
this.input.id = `${select.id}-search`;
this.labels.forEach(label => { label.htmlFor = this.input.id; });
}
const listId = `${select.id || 'async-combobox'}-listbox`;
this.menu.id = listId;
this.input.setAttribute('aria-controls', listId);
this.input.addEventListener('focus', () => this.search(this.input.value));
this.input.addEventListener('input', debounce(() => this.search(this.input.value), 320));
this.input.addEventListener('keydown', event => this.handleKeydown(event));
this.clearButton.addEventListener('click', () => this.clear());
this.menu.addEventListener('mousedown', event => event.preventDefault());
this.menu.addEventListener('keydown', event => this.handleMenuKeydown(event));
this.handleOutsideClick = event => {
if (!this.root.contains(event.target)) this.close();
};
document.addEventListener('click', this.handleOutsideClick);
this.syncFromSelect();
}
async search(query = '') {
return this.loadMatches(query, true);
}
async preload(query = '') {
return this.loadMatches(query, false);
}
async loadMatches(query, revealMenu) {
const requestNumber = ++this.requestNumber;
if (revealMenu) this.open();
this.menu.innerHTML = '<div class="async-combobox-state"><span class="state-spinner" aria-hidden="true"></span>Поиск...</div>';
try {
const items = await this.load(query.trim(), this.select.value || '');
if (requestNumber !== this.requestNumber) return;
this.items = Array.isArray(items) ? items : [];
this.renderItems();
} catch (error) {
if (requestNumber !== this.requestNumber) return;
this.menu.innerHTML = `<div class="async-combobox-state is-error">${escapeText(error.message || 'Не удалось выполнить поиск')}</div>`;
}
}
renderItems() {
if (!this.items.length) {
this.menu.innerHTML = `<div class="async-combobox-state">${escapeText(this.emptyLabel)}</div>`;
return;
}
this.menu.innerHTML = this.items.map((item, index) => {
const value = String(this.optionValue(item));
const description = this.optionDescription(item);
return `<button type="button" role="option" class="async-combobox-option" data-index="${index}"
aria-selected="${String(this.select.value) === value}">
<strong>${escapeText(this.optionLabel(item))}</strong>
${description ? `<small>${escapeText(description)}</small>` : ''}
</button>`;
}).join('');
this.menu.querySelectorAll('[data-index]').forEach(button => {
button.addEventListener('click', () => this.choose(this.items[Number(button.dataset.index)]));
});
}
choose(item) {
const value = String(this.optionValue(item));
const label = this.optionLabel(item);
this.select.innerHTML = `<option value="${escapeText(value)}" selected>${escapeText(label)}</option>`;
this.select.value = value;
this.input.value = label;
this.input.dataset.selectedValue = value;
this.select.dispatchEvent(new Event('change', { bubbles: true }));
this.close();
}
clear() {
this.select.innerHTML = '<option value="" selected></option>';
this.select.value = '';
this.input.value = '';
delete this.input.dataset.selectedValue;
this.select.dispatchEvent(new Event('change', { bubbles: true }));
this.input.focus();
this.search('');
}
syncFromSelect() {
const selected = this.select.selectedOptions?.[0];
this.input.value = selected?.value ? selected.textContent.trim() : '';
if (selected?.value) this.input.dataset.selectedValue = selected.value;
}
open() {
this.menu.hidden = false;
this.input.setAttribute('aria-expanded', 'true');
this.root.classList.add('is-open');
}
close() {
this.menu.hidden = true;
this.input.setAttribute('aria-expanded', 'false');
this.root.classList.remove('is-open');
this.syncFromSelect();
}
handleKeydown(event) {
const options = [...this.menu.querySelectorAll('[role="option"]')];
const current = options.indexOf(document.activeElement);
if (event.key === 'Escape') {
this.close();
} else if (event.key === 'ArrowDown' && options.length) {
event.preventDefault();
options[Math.min(current + 1, options.length - 1)].focus();
} else if (event.key === 'ArrowUp' && options.length) {
event.preventDefault();
options[Math.max(current - 1, 0)].focus();
}
}
handleMenuKeydown(event) {
const options = [...this.menu.querySelectorAll('[role="option"]')];
const current = options.indexOf(document.activeElement);
if (event.key === 'Escape') {
event.preventDefault();
this.close();
this.input.focus();
} else if (event.key === 'ArrowDown' && current >= 0) {
event.preventDefault();
options[Math.min(current + 1, options.length - 1)]?.focus();
} else if (event.key === 'ArrowUp' && current >= 0) {
event.preventDefault();
(current === 0 ? this.input : options[current - 1])?.focus();
}
}
destroy() {
document.removeEventListener('click', this.handleOutsideClick);
this.requestNumber += 1;
this.root.remove();
this.select.hidden = false;
this.labels.forEach(label => { label.htmlFor = this.select.id; });
}
}
function escapeText(value) {
return String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}

View File

@@ -1,331 +0,0 @@
import { api, initializeSession, logout } from './api.js';
import { applyRippleEffect, closeAllDropdownsOnOutsideClick, renderStatusMessage } from './utils.js';
import { startDropdownAutoObserver, initAllCustomDropdowns } from './dropdown.js';
import { ADMIN_APP_ROLES, SETTINGS_ROLES, capabilitiesForRole } from './role-capabilities.js';
import { initializeTelemetry } from '../../telemetry.js';
import { confirmAbandonDirtyChanges } from './dirty-state.js';
import { trackUxEvent } from '../../telemetry.js';
async function bootstrap() {
const session = await initializeSession();
if (!session || !ADMIN_APP_ROLES.includes(session.role)) {
window.location.replace('/');
return;
}
const currentRole = session.role;
void initializeTelemetry();
// Global initialization for Custom Selects
document.addEventListener('DOMContentLoaded', () => {
initAllCustomDropdowns(document.body);
startDropdownAutoObserver();
});
// Configuration
const ROUTES = {
dashboard: {
title: 'Дашборд',
file: '/admin/views/dashboard.html',
init: async () => {
const module = await import('./views/dashboard.js');
await module.initDashboard();
}
},
users: {
title: 'Управление пользователями',
file: '/admin/views/users.html',
init: async () => {
const module = await import('./views/users.js');
await module.initUsers();
}
},
'teacher-requests': {
title: 'Заявки',
file: '/admin/views/teacher-requests.html',
init: async () => {
const module = await import('./views/teacher-requests.js');
await module.initTeacherRequests();
}
},
groups: {
title: 'Управление группами',
file: '/admin/views/groups.html',
init: async () => {
const module = await import('./views/groups.js');
await module.initGroups();
}
},
classrooms: {
title: 'Аудиторный фонд',
file: '/admin/views/classrooms.html',
init: async () => {
const module = await import('./views/classrooms.js');
await module.initClassrooms();
}
},
subjects: {
title: 'Дисциплины',
file: '/admin/views/subjects.html',
init: async () => {
const module = await import('./views/subjects.js');
await module.initSubjects();
}
},
'university-structure': {
title: 'Структура вуза',
file: '/admin/views/university-structure.html',
init: async () => {
const module = await import('./views/university-structure.js');
await module.initUniversityStructure();
}
},
'department-workspace': {
title: 'Кабинет кафедры',
file: '/admin/views/department-workspace.html',
init: async () => {
const module = await import('./views/department-workspace.js');
await module.initDepartmentWorkspace();
}
},
'schedule-view': {
title: 'Просмотр расписаний',
file: '/admin/views/schedule-view.html',
init: async () => {
const module = await import('./views/schedule-view.js');
await module.initScheduleView();
}
},
schedule: {
title: 'Конструктор правил',
file: '/admin/views/schedule.html',
init: async () => {
const module = await import('./views/schedule.js');
await module.initSchedule();
}
},
'schedule-versions': {
title: 'Версии расписания',
file: '/admin/views/schedule-versions.html',
init: async () => {
const module = await import('./views/schedule-versions.js');
await module.initScheduleVersions();
}
},
'schedule-quality': {
title: 'Анализ качества расписания',
file: '/admin/views/schedule-quality.html',
init: async () => {
const module = await import('./views/schedule-quality.js');
await module.initScheduleQuality();
}
},
'academic-calendar': {
title: 'Календарный учебный график',
file: '/admin/views/academic-calendar.html',
init: async () => {
const module = await import('./views/academic-calendar.js');
await module.initAcademicCalendar();
}
},
'auditorium-workload': {
title: 'Загруженность',
file: '/admin/views/auditorium-workload.html',
init: async () => {
const module = await import('./views/auditorium-workload.js');
await module.initAuditoriumWorkload();
}
},
'teacher-absences': {
title: 'Запросы преподавателей',
file: '/admin/views/teacher-absences.html',
init: async () => {
const module = await import('./views/teacher-absences.js');
await module.initTeacherAbsences();
}
},
};
let currentTab = null;
// DOM Elements
const appContent = document.getElementById('app-content');
const pageTitle = document.getElementById('page-title');
const navItems = document.querySelectorAll('.nav-item[data-tab]');
const sidebar = document.querySelector('.sidebar');
const sidebarOverlay = document.getElementById('sidebar-overlay');
const sidebarCloseBtn = document.getElementById('sidebar-close-btn');
const btnLogout = document.getElementById('btn-logout');
const main = document.querySelector('.main');
const teacherRequestsNavBadge = document.getElementById('teacher-requests-nav-badge');
const teacherRequestsTitleBadge = document.getElementById('teacher-requests-title-badge');
const roleCapabilities = capabilitiesForRole(currentRole);
const allowedTabs = new Set(roleCapabilities.adminTabs);
// Setup Global Effects
applyRippleEffect();
closeAllDropdownsOnOutsideClick();
configureRoleInterface();
refreshTeacherRequestBadge();
// Init sidebar state from localStorage on load
if (window.innerWidth > 768 && localStorage.getItem('sidebar-collapsed') === 'true') {
sidebar.classList.add('collapsed');
main.classList.add('sidebar-collapsed');
}
// Sidebar Close (X button)
sidebarCloseBtn?.addEventListener('click', () => {
if (window.innerWidth <= 768) {
sidebar.classList.remove('open');
sidebarOverlay.classList.remove('open');
} else {
sidebar.classList.toggle('collapsed');
main.classList.toggle('sidebar-collapsed');
localStorage.setItem('sidebar-collapsed', sidebar.classList.contains('collapsed'));
}
});
sidebarOverlay.addEventListener('click', () => {
sidebar.classList.remove('open');
sidebarOverlay.classList.remove('open');
});
// Settings Dropdown
const settingsDropdown = document.getElementById('settings-dropdown');
const btnSettings = document.getElementById('btn-settings');
btnSettings.addEventListener('click', (e) => {
e.stopPropagation();
settingsDropdown.classList.toggle('open');
});
document.addEventListener('click', (e) => {
if (!settingsDropdown.contains(e.target)) {
settingsDropdown.classList.remove('open');
}
});
// Logout
btnLogout.addEventListener('click', async () => {
await logout();
window.location.href = '/';
});
teacherRequestsTitleBadge?.addEventListener('click', () => {
switchTab('teacher-requests');
});
// Navigation
navItems.forEach(item => {
item.addEventListener('click', (e) => {
e.preventDefault();
const tab = item.dataset.tab;
switchTab(tab);
});
});
async function switchTab(tab) {
if (currentTab === tab || !ROUTES[tab] || !allowedTabs.has(tab)) return;
if (currentTab && !(await confirmAbandonDirtyChanges())) return;
// UI Update
navItems.forEach(n => n.classList.remove('active'));
document.querySelector(`.nav-item[data-tab="${tab}"]`)?.classList.add('active');
pageTitle.textContent = ROUTES[tab].title;
// Load template
try {
appContent.innerHTML = '<div class="loading-row">Загрузка...</div>';
const response = await fetch(ROUTES[tab].file);
if (!response.ok) throw new Error('Не удалось загрузить вкладку');
const html = await response.text();
appContent.innerHTML = html;
// Initialize logic for the tab
if (ROUTES[tab].init) {
await ROUTES[tab].init();
}
currentTab = tab;
trackUxEvent('раздел_открыт', { tab, role: currentRole });
if (window.location.hash !== `#${tab}`) {
window.location.hash = tab;
}
} catch (e) {
renderStatusMessage(appContent, 'Не удалось загрузить выбранную вкладку');
console.error('Не удалось загрузить вкладку');
}
// Close mobile menu if open
sidebar.classList.remove('open');
sidebarOverlay.classList.remove('open');
}
function configureRoleInterface() {
navItems.forEach(item => {
item.hidden = !allowedTabs.has(item.dataset.tab);
});
const settingsLink = document.querySelector('.settings-menu a[href="/admin/settings/"]');
if (settingsLink) {
settingsLink.hidden = !SETTINGS_ROLES.includes(currentRole);
}
const firstAllowedTitle = {
ADMIN: 'Magistr',
EDUCATION_OFFICE: 'Учебный отдел',
DEPARTMENT: 'Кафедра',
SCHEDULE_VIEWER: 'Расписания'
}[currentRole] || 'Magistr';
document.querySelector('.logo span').textContent = firstAllowedTitle;
}
async function refreshTeacherRequestBadge(countOverride = null) {
if (currentRole !== 'ADMIN') return;
try {
const count = countOverride === null
? Number((await api.get('/api/teacher-requests/page?status=PENDING&page=0&size=1')).totalItems || 0)
: countOverride;
updateTeacherRequestBadge(teacherRequestsNavBadge, count, count > 99 ? '99+' : String(count));
updateTeacherRequestBadge(teacherRequestsTitleBadge, count, requestCountLabel(count));
} catch (error) {
if (teacherRequestsNavBadge) teacherRequestsNavBadge.hidden = true;
if (teacherRequestsTitleBadge) teacherRequestsTitleBadge.hidden = true;
console.warn('Не удалось загрузить счётчик заявок');
}
}
function updateTeacherRequestBadge(element, count, label) {
if (!element) return;
const normalizedCount = Number(count) || 0;
element.textContent = label;
element.hidden = normalizedCount <= 0;
element.setAttribute('aria-hidden', normalizedCount <= 0 ? 'true' : 'false');
element.setAttribute('aria-label', normalizedCount > 0 ? `Заявок в очереди: ${normalizedCount}` : 'Заявок в очереди нет');
}
function requestCountLabel(count) {
const mod10 = count % 10;
const mod100 = count % 100;
if (mod10 === 1 && mod100 !== 11) return `${count} заявка`;
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return `${count} заявки`;
return `${count} заявок`;
}
document.addEventListener('teacherRequestsChanged', (event) => {
refreshTeacherRequestBadge(Number.isFinite(event.detail?.count) ? event.detail.count : null);
});
const hashTab = window.location.hash.replace('#', '');
switchTab(allowedTabs.has(hashTab) ? hashTab : roleCapabilities.defaultAdminTab);
window.addEventListener('hashchange', () => {
const hashTab = window.location.hash.replace('#', '');
if (allowedTabs.has(hashTab) && hashTab !== currentTab) {
switchTab(hashTab);
}
});
}
await bootstrap();

View File

@@ -1,34 +0,0 @@
import { escapeHtml } from './utils.js';
export function renderPagination(container, pageData, { onPage, onSize, label = 'записей' } = {}) {
if (!container) return;
const totalItems = Number(pageData?.totalItems || 0);
const page = Number(pageData?.page || 0);
const size = Number(pageData?.size || 25);
const totalPages = Math.max(Number(pageData?.totalPages || 0), totalItems ? 1 : 0);
const start = totalItems ? page * size + 1 : 0;
const end = Math.min((page + 1) * size, totalItems);
container.innerHTML = `
<div class="pagination-summary" aria-live="polite">
Показано ${start}–${end} из ${totalItems} ${escapeHtml(label)}
</div>
<div class="pagination-actions">
<label class="pagination-size">
<span>На странице</span>
<select data-pagination-size data-native-select="true">
${[25, 50, 100].map(value => `<option value="${value}" ${value === size ? 'selected' : ''}>${value}</option>`).join('')}
</select>
</label>
<button type="button" class="btn btn-sm btn-secondary" data-pagination-page="${page - 1}" ${page <= 0 ? 'disabled' : ''}>Назад</button>
<span class="pagination-page">${totalPages ? page + 1 : 0} / ${totalPages}</span>
<button type="button" class="btn btn-sm btn-secondary" data-pagination-page="${page + 1}" ${page >= totalPages - 1 ? 'disabled' : ''}>Далее</button>
</div>
`;
container.querySelectorAll('[data-pagination-page]').forEach(button => {
button.addEventListener('click', () => onPage?.(Number(button.dataset.paginationPage)));
});
container.querySelector('[data-pagination-size]')?.addEventListener('change', event => {
onSize?.(Number(event.target.value));
});
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,314 +0,0 @@
import { api } from '../api.js';
import { escapeHtml, showAlert, hideAlert, initMultiSelect, updateSelectText } from '../utils.js';
import { alertAction, confirmAction } from '../dialog.js';
async function fetchEquipments() {
try {
return await api.get('/api/equipments');
} catch (e) {
console.error('Не удалось загрузить список оборудования');
return [];
}
}
function renderEquipmentCheckboxes(equipments, containerId, selectTextId, selectedIds = []) {
const container = document.getElementById(containerId);
if (!container) return;
if (!equipments || !equipments.length) {
container.innerHTML = '<p class="text-muted"><small>Оборудование отсутствует</small></p>';
updateSelectText(containerId, selectTextId);
return;
}
container.innerHTML = equipments.map(eq => `
<label class="checkbox-item">
<input type="checkbox" value="${eq.id}" ${selectedIds.includes(eq.id) ? 'checked' : ''}>
<span class="checkmark"></span>
<span>${escapeHtml(eq.name)}</span>
</label>
`).join('');
updateSelectText(containerId, selectTextId);
}
export async function initClassrooms() {
const classroomsTbody = document.getElementById('classrooms-tbody');
const createClassroomForm = document.getElementById('create-classroom-form');
const equipmentCheckboxes = document.getElementById('equipment-checkboxes');
const editEquipmentCheckboxes = document.getElementById('edit-equipment-checkboxes');
const modalEditClassroom = document.getElementById('modal-edit-classroom');
const modalEditClassroomClose = document.getElementById('modal-edit-classroom-close');
const editClassroomForm = document.getElementById('edit-classroom-form');
let allEquipments = [];
let editingClassroomData = null;
initMultiSelect('equipment-select-box', 'equipment-dropdown-menu', 'equipment-select-text', 'equipment-checkboxes');
initMultiSelect('edit-equipment-select-box', 'edit-equipment-dropdown-menu', 'edit-equipment-select-text', 'edit-equipment-checkboxes');
async function loadInitialData() {
try {
allEquipments = await fetchEquipments();
renderEquipmentCheckboxes(allEquipments, 'equipment-checkboxes', 'equipment-select-text');
await loadClassrooms();
} catch (e) {
classroomsTbody.innerHTML = '<tr><td colspan="6" class="loading-row">Ошибка загрузки данных</td></tr>';
}
}
async function loadClassrooms() {
try {
const classrooms = await api.get('/api/classrooms?includeArchived=true');
renderClassrooms(classrooms);
} catch (e) {
classroomsTbody.innerHTML = '<tr><td colspan="6" class="loading-row">Ошибка загрузки</td></tr>';
}
}
function renderClassrooms(classrooms) {
if (!classrooms || !classrooms.length) {
classroomsTbody.innerHTML = '<tr><td colspan="6" class="loading-row">Нет аудиторий</td></tr>';
return;
}
classroomsTbody.innerHTML = classrooms.map(c => {
const equipHtml = c.equipments && c.equipments.length
? c.equipments.map(eq => escapeHtml(eq.name)).join(', ')
: '—';
return `
<tr>
<td>${c.id}</td>
<td><strong>${escapeHtml(c.name)}</strong></td>
<td>${c.capacity} чел.</td>
<td><small>${equipHtml}</small></td>
<td>
<div class="status-cell">
<span class="badge ${c.isAvailable ? 'badge-available' : 'badge-unavailable'}">
${c.status === 'ARCHIVED' ? 'Архив' : (c.isAvailable ? 'Доступна' : 'Не доступна')}
</span>
<button class="btn btn-icon-sm btn-secondary btn-icon-toggle" data-id="${c.id}" data-current-status="${c.isAvailable}" title="Сменить статус" aria-label="Сменить статус">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"></path></svg>
</button>
</div>
</td>
<td class="table-actions-cell">
<div class="table-actions">
<button class="btn btn-sm btn-secondary classroom-edit-action" data-id="${c.id}">Изменить</button>
${c.status === 'ARCHIVED'
? `<button class="btn btn-sm btn-secondary btn-restore-classroom" data-id="${c.id}">Восстановить</button>`
: `<button class="btn btn-sm btn-danger-subtle btn-archive-classroom" data-id="${c.id}">Архивировать</button>`}
</div>
</td>
</tr>`;
}).join('');
}
createClassroomForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideAlert('create-classroom-alert');
const name = document.getElementById('new-classroom-name').value.trim();
const capacity = parseInt(document.getElementById('new-classroom-capacity').value, 10);
const checkedBoxes = Array.from(equipmentCheckboxes.querySelectorAll('input:checked'));
const equipmentIds = checkedBoxes.map(chk => parseInt(chk.value, 10));
if (!name || isNaN(capacity)) { showAlert('create-classroom-alert', 'Заполните обязательные поля', 'error'); return; }
try {
const data = await api.post('/api/classrooms', { name, capacity, equipmentIds, isAvailable: true });
showAlert('create-classroom-alert', `Аудитория "${escapeHtml(data.name)}" добавлена`, 'success');
createClassroomForm.reset();
updateSelectText('equipment-checkboxes', 'equipment-select-text');
loadClassrooms();
} catch (e) {
showAlert('create-classroom-alert', e.message || 'Ошибка создания', 'error');
}
});
classroomsTbody.addEventListener('click', async (e) => {
const btnDelete = e.target.closest('.btn-archive-classroom');
const btnToggleStatus = e.target.closest('.btn-icon-toggle');
const btnEdit = e.target.closest('.classroom-edit-action');
if (btnDelete) {
if (!await confirmAction({
title: 'Вывод аудитории из эксплуатации',
message: 'Историческое расписание сохранится, но аудитория перестанет участвовать в планировании.',
confirmLabel: 'Вывести из эксплуатации',
danger: true
})) return;
try {
await api.delete('/api/classrooms/' + btnDelete.dataset.id);
loadClassrooms();
} catch (err) { await alertAction({ title: 'Ошибка архивирования', message: err.message || 'Не удалось архивировать аудиторию' }); }
}
const btnRestore = e.target.closest('.btn-restore-classroom');
if (btnRestore) {
try {
await api.post('/api/classrooms/' + btnRestore.dataset.id + '/restore', {});
loadClassrooms();
} catch (err) { await alertAction({ title: 'Ошибка восстановления', message: err.message || 'Не удалось восстановить аудиторию' }); }
}
if (btnToggleStatus) {
const id = btnToggleStatus.dataset.id;
const currentStatus = btnToggleStatus.dataset.currentStatus === 'true';
try {
await api.put('/api/classrooms/' + id, { isAvailable: !currentStatus });
loadClassrooms();
} catch (err) { await alertAction({ title: 'Ошибка изменения статуса', message: err.message || 'Не удалось изменить доступность аудитории' }); }
}
if (btnEdit) {
openEditClassroomModal(btnEdit.dataset.id);
}
});
async function openEditClassroomModal(id) {
try {
// Can optimize by using already loaded classrooms, but fetch is safer to get fresh data
const classrooms = await api.get('/api/classrooms?includeArchived=true');
editingClassroomData = classrooms.find(c => c.id == id);
if (!editingClassroomData) return;
document.getElementById('edit-classroom-id').value = editingClassroomData.id;
document.getElementById('edit-classroom-name').value = editingClassroomData.name;
document.getElementById('edit-classroom-capacity').value = editingClassroomData.capacity;
const existingEquipIds = editingClassroomData.equipments.map(e => e.id);
renderEquipmentCheckboxes(allEquipments, 'edit-equipment-checkboxes', 'edit-equipment-select-text', existingEquipIds);
hideAlert('edit-classroom-alert');
modalEditClassroom.classList.add('open');
} catch (e) {
await alertAction({ title: 'Ошибка загрузки', message: e.message || 'Не удалось загрузить данные аудитории' });
}
}
modalEditClassroomClose.addEventListener('click', () => {
modalEditClassroom.classList.remove('open');
});
modalEditClassroom.addEventListener('click', (e) => {
if (e.target === modalEditClassroom) {
modalEditClassroom.classList.remove('open');
}
});
editClassroomForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideAlert('edit-classroom-alert');
const id = document.getElementById('edit-classroom-id').value;
const name = document.getElementById('edit-classroom-name').value.trim();
const capacity = parseInt(document.getElementById('edit-classroom-capacity').value, 10);
const checkedBoxes = Array.from(editEquipmentCheckboxes.querySelectorAll('input:checked'));
const equipmentIds = checkedBoxes.map(chk => parseInt(chk.value, 10));
if (!name || isNaN(capacity)) { showAlert('edit-classroom-alert', 'Заполните обязательные поля', 'error'); return; }
try {
const data = await api.put('/api/classrooms/' + id, {
name,
capacity,
equipmentIds,
isAvailable: editingClassroomData.isAvailable
});
modalEditClassroom.classList.remove('open');
// We show alert on the main create form area or we could use toast
showAlert('create-classroom-alert', `Аудитория "${escapeHtml(data.name)}" обновлена`, 'success');
loadClassrooms();
} catch (e) {
showAlert('edit-classroom-alert', e.message || 'Ошибка обновления', 'error');
}
});
// Элементы модалки оборудования
const btnManageEquipments = document.getElementById('btn-manage-equipments');
const modalManageEquipments = document.getElementById('modal-manage-equipments');
const modalManageEquipmentsClose = document.getElementById('modal-manage-equipments-close');
const manageEquipmentForm = document.getElementById('manage-equipment-form');
const manageEquipmentNameInput = document.getElementById('manage-equipment-name');
const manageEquipmentTbody = document.getElementById('manage-equipments-tbody');
btnManageEquipments.addEventListener('click', openManageEquipmentsModal);
modalManageEquipmentsClose.addEventListener('click', () => modalManageEquipments.classList.remove('open'));
modalManageEquipments.addEventListener('click', (e) => {
if (e.target === modalManageEquipments) modalManageEquipments.classList.remove('open');
});
async function openManageEquipmentsModal() {
hideAlert('manage-equipment-alert');
manageEquipmentForm.reset();
modalManageEquipments.classList.add('open');
await loadManageEquipments();
}
async function loadManageEquipments() {
manageEquipmentTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Загрузка...</td></tr>';
try {
allEquipments = await fetchEquipments();
renderManageEquipmentsTable();
// Обновим чекбоксы на основной странице
renderEquipmentCheckboxes(allEquipments, 'equipment-checkboxes', 'equipment-select-text');
} catch (e) {
manageEquipmentTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Ошибка загрузки</td></tr>';
}
}
function renderManageEquipmentsTable() {
if (!allEquipments || !allEquipments.length) {
manageEquipmentTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Нет оборудования</td></tr>';
return;
}
manageEquipmentTbody.innerHTML = allEquipments.map(eq => `
<tr>
<td>${eq.id}</td>
<td>${escapeHtml(eq.name)}</td>
<td><button class="btn btn-sm btn-danger btn-delete-equipment" data-id="${eq.id}">Удалить</button></td>
</tr>
`).join('');
}
manageEquipmentForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideAlert('manage-equipment-alert');
const name = manageEquipmentNameInput.value.trim();
if (!name) {
showAlert('manage-equipment-alert', 'Введите название', 'error');
return;
}
try {
const data = await api.post('/api/equipments', { name });
showAlert('manage-equipment-alert', `Оборудование "${escapeHtml(data.name)}" добавлено`, 'success');
manageEquipmentForm.reset();
await loadManageEquipments();
} catch (err) {
showAlert('manage-equipment-alert', err.message || 'Ошибка добавления', 'error');
}
});
manageEquipmentTbody.addEventListener('click', async (e) => {
const btnDelete = e.target.closest('.btn-delete-equipment');
if (!btnDelete) return;
if (!await confirmAction({
title: 'Удаление оборудования',
message: 'Удалить оборудование из каталога?',
confirmLabel: 'Удалить',
danger: true
})) return;
try {
await api.delete('/api/equipments/' + btnDelete.dataset.id);
await loadManageEquipments();
} catch (err) {
await alertAction({ title: 'Ошибка удаления', message: err.message || 'Не удалось удалить оборудование' });
}
});
loadInitialData();
}

View File

@@ -1,376 +0,0 @@
import { api } from '../api.js';
import { escapeHtml } from '../utils.js';
import {
CONFLICT_CHECK_STATE,
conflictCheckPresentation,
currentWeekDateRange,
formatLocalDate,
loadDepartmentSchedules
} from '../dashboard-conflicts.js';
export async function initDashboard() {
// DOM Элементы
const metricGroups = document.getElementById('metric-groups');
const metricStudents = document.getElementById('metric-students');
const metricTeachers = document.getElementById('metric-teachers');
const metricClassrooms = document.getElementById('metric-classrooms');
const currentTimeSlot = document.getElementById('current-time-slot');
const currentLessonsTbody = document.getElementById('current-lessons-tbody');
const freeClassroomsList = document.getElementById('free-classrooms-list');
const conflictsContainer = document.getElementById('conflicts-container');
const btnRecheck = document.getElementById('btn-recheck-conflicts');
let departments = [];
let classrooms = [];
let groups = [];
let teachers = [];
let todayLessons = [];
let timeSlots = [];
let dashboardDataReady = false;
btnRecheck.addEventListener('click', async () => {
btnRecheck.disabled = true;
btnRecheck.setAttribute('aria-busy', 'true');
btnRecheck.textContent = 'Проверка…';
try {
if (dashboardDataReady) {
await checkConflicts();
} else {
await loadData();
}
} finally {
btnRecheck.textContent = 'Перепроверить';
btnRecheck.disabled = false;
btnRecheck.removeAttribute('aria-busy');
}
});
async function loadData() {
dashboardDataReady = false;
try {
// Загружаем базовые справочники
[departments, classrooms, groups, teachers] = await Promise.all([
api.get('/api/departments'),
api.get('/api/classrooms?includeArchived=false'),
api.get('/api/groups?includeArchived=false'),
api.get('/api/users/teachers')
]);
// Выводим метрики
metricGroups.textContent = groups.length;
const totalStudents = groups.reduce((acc, g) => acc + (g.groupSize || 0), 0);
metricStudents.textContent = totalStudents;
metricTeachers.textContent = teachers.length;
const todayStr = formatLocalDate(new Date());
// Загружаем временные слоты и сегодняшнее расписание
const lessonsPromises = departments.map(d =>
api.get(`/api/schedule/search?departmentId=${d.id}&startDate=${todayStr}&endDate=${todayStr}`).catch(() => [])
);
const lessonsArrays = await Promise.all(lessonsPromises);
todayLessons = lessonsArrays.flat();
try {
timeSlots = await api.get(`/api/admin/time-slots/effective?date=${todayStr}`);
} catch (e) {
// Если эндпоинт effective не поддерживается, используем базовые слоты
const baseSlots = await api.get('/api/admin/time-slots').catch(() => []);
// Отфильтруем базовые слоты тенанта (scopeId = DEFAULT или аналогично)
timeSlots = baseSlots.filter(s => s.scopeId === 1 || !s.scopeId);
}
// Сортируем слоты по времени
timeSlots.sort((a, b) => (a.orderNumber || 0) - (b.orderNumber || 0));
// Посчитаем занятость аудиторий сегодня
const occupiedClassroomIds = new Set(todayLessons.map(l => l.classroomId).filter(Boolean));
const activeClassrooms = classrooms.filter(c => c.isAvailable && c.status !== 'ARCHIVED');
const classPercent = activeClassrooms.length
? Math.round((occupiedClassroomIds.size / activeClassrooms.length) * 100)
: 0;
metricClassrooms.textContent = `${classPercent}% (${occupiedClassroomIds.size}/${activeClassrooms.length})`;
updateRealtimeMonitoring();
dashboardDataReady = true;
await checkConflicts();
} catch (e) {
dashboardDataReady = false;
console.error('Не удалось загрузить данные панели', {
errorType: e?.name || 'Error'
});
conflictsContainer.innerHTML = renderCheckStateCard({
tone: 'error',
title: 'Проверка не выполнена',
description: 'Не удалось загрузить данные панели. Нажмите «Перепроверить», чтобы повторить попытку.'
});
}
}
function updateRealtimeMonitoring() {
const now = new Date();
const currentTimeStr = now.toTimeString().split(' ')[0]; // HH:MM:SS
// Находим текущий временной слот
let currentSlot = null;
for (const slot of timeSlots) {
if (currentTimeStr >= slot.startTime && currentTimeStr <= slot.endTime) {
currentSlot = slot;
break;
}
}
if (currentSlot) {
currentTimeSlot.textContent = `Пара №${currentSlot.orderNumber} (${currentSlot.startTime.substring(0, 5)} – ${currentSlot.endTime.substring(0, 5)})`;
renderCurrentLessons(currentSlot.orderNumber);
renderFreeClassrooms(currentSlot.orderNumber);
} else {
currentTimeSlot.textContent = 'Вне сетки пар';
currentLessonsTbody.innerHTML = '<tr><td colspan="4" class="loading-row" style="padding: 1.5rem; text-align: center; color: var(--text-secondary);">В данный момент учебные пары не проводятся</td></tr>';
// Если сейчас нет пары, покажем свободные аудитории на ближайшую следующую пару (или просто все свободные)
renderFreeClassrooms(null);
}
}
function renderCurrentLessons(slotOrder) {
const activeLessons = todayLessons.filter(l => l.timeSlotOrder === slotOrder);
if (!activeLessons.length) {
currentLessonsTbody.innerHTML = '<tr><td colspan="4" class="loading-row" style="padding: 1.5rem; text-align: center; color: var(--text-secondary);">В текущий слот занятий не запланировано</td></tr>';
return;
}
currentLessonsTbody.innerHTML = activeLessons.map(l => `
<tr>
<td style="padding: 0.75rem;"><strong>${escapeHtml(l.classroomName)}</strong></td>
<td style="padding: 0.75rem;">${escapeHtml(l.subjectName)} <span class="badge badge-ef" style="font-size:0.75rem;">${escapeHtml(l.lessonTypeName)}</span></td>
<td style="padding: 0.75rem;">${escapeHtml(l.teacherName)}</td>
<td style="padding: 0.75rem;"><span class="badge badge-available">${escapeHtml(l.groupNames.join(', '))}</span></td>
</tr>
`).join('');
}
function renderFreeClassrooms(slotOrder) {
const activeClassrooms = classrooms.filter(c => c.isAvailable && c.status !== 'ARCHIVED');
let occupiedIds = new Set();
if (slotOrder !== null) {
occupiedIds = new Set(todayLessons.filter(l => l.timeSlotOrder === slotOrder).map(l => l.classroomId));
}
const free = activeClassrooms.filter(c => !occupiedIds.has(c.id));
if (!free.length) {
freeClassroomsList.innerHTML = '<span style="color: var(--error); font-size: 0.9rem; font-weight: 500;">Все аудитории заняты</span>';
return;
}
freeClassroomsList.innerHTML = free.map(c => `
<span class="badge badge-available" style="padding: 0.4rem 0.6rem; font-size: 0.85rem; background: rgba(16, 185, 129, 0.1); color: #10b981; border: 1px solid rgba(16, 185, 129, 0.2);">
${escapeHtml(c.name)} (${c.capacity} м.)
</span>
`).join('');
}
// --- Анализ конфликтов (Red Zone) ---
async function checkConflicts() {
conflictsContainer.setAttribute('aria-busy', 'true');
conflictsContainer.innerHTML = '<div class="loading-row" style="text-align: center; padding: 2rem; color: var(--text-secondary);">Анализ расписания и выявление конфликтов...</div>';
try {
const { startDate, endDate } = currentWeekDateRange(new Date());
const scheduleLoad = await loadDepartmentSchedules(
departments,
department => api.get(
`/api/schedule/search?departmentId=${department.id}`
+ `&startDate=${startDate}&endDate=${endDate}`
)
);
if (scheduleLoad.state === CONFLICT_CHECK_STATE.NOT_RUN) {
renderConflicts([], scheduleLoad, { startDate, endDate });
return;
}
const weekLessons = scheduleLoad.lessons;
const conflicts = [];
// Мапы для быстрого поиска характеристик групп и аудиторий
const groupSizeMap = new Map(groups.map(g => [g.id, g.groupSize || 0]));
const classroomCapacityMap = new Map(classrooms.map(c => [c.id, c.capacity || 0]));
// 1. Проверка накладок преподавателей
// Ключ: date + '_' + timeSlotOrder + '_' + teacherId
const teacherSlots = new Map();
weekLessons.forEach(l => {
if (!l.teacherId) return;
const key = `${l.date}_${l.timeSlotOrder}_${l.teacherId}`;
if (!teacherSlots.has(key)) {
teacherSlots.set(key, []);
}
teacherSlots.get(key).push(l);
});
teacherSlots.forEach((lessons, key) => {
if (lessons.length > 1) {
// Проверим, если все занятия в одной аудитории — это потоковая лекция, а не конфликт
const classroomIds = new Set(lessons.map(l => l.classroomId));
if (classroomIds.size > 1) {
const first = lessons[0];
const listStr = lessons.map(l => `ауд. ${l.classroomName} (группа ${l.groupNames.join(', ')})`).join(', ');
conflicts.push({
type: 'TEACHER_COLLISION',
title: 'Накладка в расписании преподавателя',
description: `Преподаватель <strong>${escapeHtml(first.teacherName)}</strong> назначен одновременно в разные места: ${escapeHtml(listStr)} на <strong>${first.date} (${first.dayName}), пара №${first.timeSlotOrder}</strong>.`,
severity: 'error'
});
}
}
});
// 2. Проверка накладок аудиторий
// Ключ: date + '_' + timeSlotOrder + '_' + classroomId
const classroomSlots = new Map();
weekLessons.forEach(l => {
if (!l.classroomId) return;
const key = `${l.date}_${l.timeSlotOrder}_${l.classroomId}`;
if (!classroomSlots.has(key)) {
classroomSlots.set(key, []);
}
classroomSlots.get(key).push(l);
});
classroomSlots.forEach((lessons, key) => {
if (lessons.length > 1) {
// Проверим, не является ли это потоковой лекцией (один преподаватель и одна дисциплина)
const teacherIds = new Set(lessons.map(l => l.teacherId));
const subjectIds = new Set(lessons.map(l => l.subjectId));
if (teacherIds.size > 1 || subjectIds.size > 1) {
const first = lessons[0];
const details = lessons.map(l => `препод. ${l.teacherName} (предмет: ${l.subjectName}, группа: ${l.groupNames.join(', ')})`).join(' И ');
conflicts.push({
type: 'CLASSROOM_COLLISION',
title: 'Накладка в аудитории',
description: `Аудитория <strong>${escapeHtml(first.classroomName)}</strong> занята одновременно разными занятиями: ${escapeHtml(details)} на <strong>${first.date} (${first.dayName}), пара №${first.timeSlotOrder}</strong>.`,
severity: 'error'
});
}
}
});
// 3. Проверка превышения вместимости аудитории
// Считаем сумму студентов для каждого уникального занятия в аудитории
classroomSlots.forEach((lessons, key) => {
if (!lessons.length) return;
const first = lessons[0];
const capacity = classroomCapacityMap.get(first.classroomId) || 0;
// Суммируем численность всех групп, назначенных на этот слот в этой аудитории
const groupIds = new Set();
lessons.forEach(l => {
if (l.groupIds) {
l.groupIds.forEach(id => groupIds.add(id));
}
});
let totalStudents = 0;
groupIds.forEach(id => {
totalStudents += groupSizeMap.get(id) || 0;
});
if (totalStudents > capacity) {
const groupNamesList = lessons.map(l => l.groupNames.join(', ')).join(', ');
conflicts.push({
type: 'CAPACITY_OVERFLOW',
title: 'Превышение вместимости аудитории',
description: `Вместимость аудитории <strong>${escapeHtml(first.classroomName)}</strong> составляет <strong>${capacity} чел.</strong>, однако на занятие назначено <strong>${totalStudents} человек</strong> (группы: ${escapeHtml(groupNamesList)}) на <strong>${first.date} (${first.dayName}), пара №${first.timeSlotOrder}</strong>.`,
severity: 'warning'
});
}
});
renderConflicts(conflicts, scheduleLoad, { startDate, endDate });
} catch (e) {
console.error('Не удалось выполнить проверку конфликтов', {
errorType: e?.name || 'Error'
});
conflictsContainer.innerHTML = renderCheckStateCard({
tone: 'error',
title: 'Проверка не выполнена',
description: 'Во время анализа произошла ошибка. Повторите проверку позже.'
});
} finally {
conflictsContainer.removeAttribute('aria-busy');
}
}
function renderConflicts(conflicts, scheduleLoad, range) {
const stateCards = [];
const presentation = conflictCheckPresentation(scheduleLoad, conflicts.length, range);
if (scheduleLoad.state === CONFLICT_CHECK_STATE.NOT_RUN) {
conflictsContainer.innerHTML = renderCheckStateCard(presentation);
return;
}
if (presentation) {
stateCards.push(renderCheckStateCard(presentation));
}
if (!conflicts.length) {
conflictsContainer.innerHTML = stateCards.join('');
return;
}
const conflictCards = conflicts.map(c => {
const isError = c.severity === 'error';
const badgeIcon = isError
? `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>`
: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#f59e0b" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>`;
return `
<div class="dashboard-conflict-card dashboard-conflict-card--${isError ? 'error' : 'warning'}">
<div class="dashboard-conflict-card__icon">${badgeIcon}</div>
<div class="dashboard-conflict-card__body">
<h4>${escapeHtml(c.title)}</h4>
<p>${escapeHtml(c.description)}</p>
</div>
</div>
`;
}).join('');
conflictsContainer.innerHTML = stateCards.join('') + conflictCards;
}
function renderCheckStateCard({ tone, title, description, meta = '' }) {
const icon = tone === 'success'
? '<path d="M20 6 9 17l-5-5" />'
: tone === 'warning'
? '<path d="M12 9v4m0 4h.01" /><path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0Z" />'
: '<circle cx="12" cy="12" r="9" /><path d="M12 8v5m0 3h.01" />';
return `
<section class="dashboard-check-state dashboard-check-state--${tone}">
<span class="dashboard-check-state__icon" aria-hidden="true">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round">${icon}</svg>
</span>
<span class="dashboard-check-state__body">
<strong>${escapeHtml(title)}</strong>
<span>${escapeHtml(description)}</span>
${meta ? `<small>${escapeHtml(meta)}</small>` : ''}
</span>
</section>
`;
}
// Загружаем данные при старте
await loadData();
// Запускаем периодическое обновление (каждые 30 секунд для пар в реальном времени)
const realtimeTimer = setInterval(() => {
if (document.getElementById('current-time-slot')) {
updateRealtimeMonitoring();
} else {
clearInterval(realtimeTimer);
}
}, 30000);
}

View File

@@ -1,587 +0,0 @@
import { api, getSession } from '../api.js';
import { escapeHtml, formatLocalDate, showAlert, hideAlert } from '../utils.js';
import { defaultSchedulePeriod } from '../schedule-period.js';
const ROLE_DEPARTMENT = 'DEPARTMENT';
export async function initDepartmentWorkspace() {
const session = getSession();
const role = session?.role;
const userDepartmentId = session?.departmentId;
const departmentField = document.getElementById('department-workspace-department-field');
const departmentSelect = document.getElementById('department-workspace-department');
const startInput = document.getElementById('department-workspace-start');
const endInput = document.getElementById('department-workspace-end');
const refreshButton = document.getElementById('department-workspace-refresh');
const importForm = document.getElementById('department-subject-import-form');
const importRowsContainer = document.getElementById('subject-import-rows');
const importAddButton = document.getElementById('subject-import-add');
const assignTeacherForm = document.getElementById('department-teacher-assign-form');
const assignTeacherSelect = document.getElementById('department-teacher-select');
const assignTeacherComment = document.getElementById('department-teacher-assign-comment');
const teacherRequestForm = document.getElementById('department-teacher-request-form');
const teacherRequestsTbody = document.getElementById('department-teacher-requests-tbody');
const teacherRequestCount = document.getElementById('department-teacher-request-count');
const subjectsTbody = document.getElementById('department-subjects-tbody');
const subjectCount = document.getElementById('department-subject-count');
const commentsCard = document.getElementById('department-comments-card');
const commentsTitle = document.getElementById('department-comments-title');
const commentsList = document.getElementById('department-comment-list');
const commentsForm = document.getElementById('department-comment-form');
const commentsSubjectInput = document.getElementById('department-comment-subject-id');
const commentsTextInput = document.getElementById('department-comment-text');
const commentsCloseButton = document.getElementById('department-comments-close');
let lastTeachers = null;
let lastWorkload = null;
let allTeachers = [];
let teacherRequests = null;
let teachersErrorMessage = '';
let workloadErrorMessage = '';
setDefaultDates(startInput, endInput);
refreshButton?.addEventListener('click', loadWorkspace);
departmentSelect?.addEventListener('change', loadWorkspace);
startInput?.addEventListener('change', handleStartDateChange);
endInput?.addEventListener('change', loadWorkload);
importForm?.addEventListener('submit', importSubjects);
assignTeacherForm?.addEventListener('submit', assignTeacherToDepartment);
teacherRequestForm?.addEventListener('submit', submitTeacherRequest);
importAddButton?.addEventListener('click', () => renderImportRows([...readImportRows(), { code: '', name: '' }]));
importRowsContainer?.addEventListener('click', (event) => {
const removeBtn = event.target.closest('.subject-import-remove');
if (removeBtn) {
const rows = readImportRows();
rows.splice(Number(removeBtn.dataset.index), 1);
renderImportRows(rows.length ? rows : [{ code: '', name: '' }]);
}
});
commentsForm?.addEventListener('submit', saveComment);
commentsCloseButton?.addEventListener('click', () => {
commentsCard.hidden = true;
commentsSubjectInput.value = '';
});
subjectsTbody?.addEventListener('click', event => {
const button = event.target.closest('.department-comments-open');
if (button) {
openComments(button.dataset.subjectId, button.dataset.subjectName);
}
});
try {
await loadDepartments(role, userDepartmentId);
if (role === ROLE_DEPARTMENT) {
departmentField.hidden = true;
departmentSelect.value = userDepartmentId || '';
}
await selectDefaultWorkloadPeriod(startInput, endInput);
await loadAllTeachers();
renderImportRows([{ code: '', name: '' }]);
await loadWorkspace();
} catch (error) {
showAlert('department-workspace-alert', error.message || 'Ошибка загрузки кабинета кафедры', 'error');
}
async function loadWorkspace() {
hideAlert('department-workspace-alert');
lastTeachers = null;
lastWorkload = null;
teachersErrorMessage = '';
workloadErrorMessage = '';
renderTeachers();
teacherRequests = null;
renderTeacherRequests();
await Promise.all([loadSubjects(), loadTeachers(), loadWorkload(), loadTeacherRequests()]);
}
async function loadSubjects() {
const departmentId = selectedDepartmentId();
subjectsTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Загрузка...</td></tr>';
subjectCount.textContent = 'Загрузка...';
try {
const subjects = await api.get(`/api/department/subjects?departmentId=${encodeURIComponent(departmentId || '')}`);
subjectCount.textContent = subjectCountLabel(subjects.length);
if (!subjects.length) {
subjectsTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Дисциплины не найдены</td></tr>';
return;
}
subjectsTbody.innerHTML = subjects.map(subject => `
<tr>
<td>${escapeHtml(subject.code || '-')}</td>
<td>${escapeHtml(subject.name || '-')}</td>
<td>${escapeHtml(subject.description || '-')}</td>
<td>
<button type="button" class="btn btn-sm btn-secondary department-comments-open"
data-subject-id="${escapeHtml(subject.id)}"
data-subject-name="${escapeHtml(subject.name || '')}">
Открыть
</button>
</td>
</tr>
`).join('');
} catch (error) {
subjectsTbody.innerHTML = `<tr><td colspan="4" class="loading-row">${escapeHtml(error.message || 'Ошибка загрузки дисциплин')}</td></tr>`;
subjectCount.textContent = '0 дисциплин';
}
}
function renderImportRows(rows) {
if (!importRowsContainer) return;
importRowsContainer.innerHTML = (rows || [{ code: '', name: '' }]).map((row, index) => `
<div class="subject-import-row" data-index="${index}">
<div class="form-group">
<label>Код</label>
<input type="text" class="subject-import-code" value="${escapeHtml(row.code || '')}" placeholder="DB201">
</div>
<div class="form-group">
<label>Название</label>
<input type="text" class="subject-import-name" value="${escapeHtml(row.name || '')}" placeholder="Базы данных">
</div>
<button type="button" class="btn btn-sm btn-danger subject-import-remove" data-index="${index}">Удалить</button>
</div>
`).join('');
}
function readImportRows() {
if (!importRowsContainer) return [];
return Array.from(importRowsContainer.querySelectorAll('.subject-import-row')).map(row => ({
code: (row.querySelector('.subject-import-code')?.value || '').trim(),
name: (row.querySelector('.subject-import-name')?.value || '').trim()
}));
}
async function importSubjects(event) {
event.preventDefault();
const alertId = 'department-subject-import-alert';
hideAlert(alertId);
const departmentId = selectedDepartmentId();
const rows = readImportRows()
.filter(item => item.name)
.map(item => ({
code: item.code,
name: item.name,
departmentId: Number(departmentId)
}));
if (!departmentId) {
showAlert(alertId, 'Выберите кафедру', 'error');
return;
}
if (!rows.length) {
showAlert(alertId, 'Заполните название хотя бы одной дисциплины', 'error');
return;
}
try {
const result = await api.post('/api/department/subjects/import', rows);
showAlert(alertId, result.message || 'Дисциплины загружены', 'success');
renderImportRows([{ code: '', name: '' }]);
await loadSubjects();
} catch (error) {
showAlert(alertId, error.message || 'Ошибка загрузки дисциплин', 'error');
}
}
async function assignTeacherToDepartment(event) {
event.preventDefault();
const alertId = 'department-teacher-assign-alert';
hideAlert(alertId);
const teacherId = assignTeacherSelect?.value;
const departmentId = selectedDepartmentId();
if (!departmentId) {
showAlert(alertId, 'Выберите кафедру', 'error');
return;
}
if (!teacherId) {
showAlert(alertId, 'Выберите преподавателя', 'error');
return;
}
try {
await api.post(`/api/department/teachers/${encodeURIComponent(teacherId)}/assignments`, {
departmentId: Number(departmentId),
comment: assignTeacherComment?.value.trim() || null
});
showAlert(alertId, 'Преподаватель привязан к кафедре', 'success');
if (assignTeacherComment) assignTeacherComment.value = '';
await Promise.all([loadTeachers(), loadWorkload()]);
} catch (error) {
showAlert(alertId, error.message || 'Ошибка привязки преподавателя', 'error');
}
}
async function submitTeacherRequest(event) {
event.preventDefault();
const alertId = 'department-teacher-request-alert';
hideAlert(alertId);
const departmentId = selectedDepartmentId();
const username = document.getElementById('teacher-request-username')?.value.trim();
const fullName = document.getElementById('teacher-request-fullname')?.value.trim();
const jobTitle = document.getElementById('teacher-request-jobtitle')?.value.trim();
const comment = document.getElementById('teacher-request-comment')?.value.trim();
if (!departmentId) {
showAlert(alertId, 'Выберите кафедру', 'error');
return;
}
if (!username || !fullName) {
showAlert(alertId, 'Заполните логин и ФИО преподавателя', 'error');
return;
}
try {
await api.post('/api/department/teacher-requests', {
departmentId: Number(departmentId),
username,
fullName,
jobTitle,
comment
});
showAlert(alertId, 'Заявка отправлена администратору', 'success');
teacherRequestForm.reset();
await loadTeacherRequests();
} catch (error) {
showAlert(alertId, error.message || 'Ошибка отправки заявки', 'error');
}
}
async function openComments(subjectId, subjectName) {
commentsCard.hidden = false;
commentsTitle.textContent = `Комментарии: ${subjectName || 'дисциплина'}`;
commentsSubjectInput.value = subjectId;
commentsTextInput.value = '';
hideAlert('department-comment-alert');
commentsList.textContent = 'Загрузка...';
try {
const comments = await api.get(`/api/department/subjects/${subjectId}/comments`);
commentsList.innerHTML = comments.length
? comments.map(comment => `
<article class="department-comment-item">
<strong>${escapeHtml(comment.authorName || 'Автор не указан')}</strong>
<span>${escapeHtml(formatDateTime(comment.createdAt))}</span>
<p>${escapeHtml(comment.comment)}</p>
</article>
`).join('')
: '<div class="loading-row">Комментариев пока нет</div>';
} catch (error) {
commentsList.innerHTML = `<div class="loading-row">${escapeHtml(error.message || 'Ошибка загрузки комментариев')}</div>`;
}
}
async function saveComment(event) {
event.preventDefault();
const subjectId = commentsSubjectInput.value;
const comment = commentsTextInput.value.trim();
if (!subjectId) {
showAlert('department-comment-alert', 'Выберите дисциплину', 'error');
return;
}
if (!comment) {
showAlert('department-comment-alert', 'Комментарий не должен быть пустым', 'error');
return;
}
try {
await api.post(`/api/department/subjects/${subjectId}/comments`, { comment });
showAlert('department-comment-alert', 'Комментарий сохранён', 'success');
await openComments(subjectId, commentsTitle.textContent.replace('Комментарии: ', ''));
} catch (error) {
showAlert('department-comment-alert', error.message || 'Ошибка сохранения комментария', 'error');
}
}
async function loadTeachers() {
lastTeachers = null;
teachersErrorMessage = '';
renderTeachers();
renderTeacherSelect();
try {
const teachers = await api.get(`/api/department/teachers?departmentId=${encodeURIComponent(selectedDepartmentId() || '')}`);
lastTeachers = Array.isArray(teachers) ? teachers : [];
renderTeachers();
renderTeacherSelect();
} catch (error) {
lastTeachers = [];
teachersErrorMessage = error.message || 'Ошибка загрузки преподавателей';
renderTeachers();
renderTeacherSelect();
}
}
async function loadAllTeachers() {
try {
const teachers = await api.get('/api/users/teachers');
allTeachers = Array.isArray(teachers) ? teachers : [];
renderTeacherSelect();
} catch (error) {
allTeachers = [];
if (assignTeacherSelect) {
assignTeacherSelect.innerHTML = `<option value="">${escapeHtml(error.message || 'Ошибка загрузки преподавателей')}</option>`;
}
}
}
async function loadTeacherRequests() {
teacherRequests = null;
renderTeacherRequests();
try {
const requests = await api.get(`/api/department/teacher-requests?departmentId=${encodeURIComponent(selectedDepartmentId() || '')}`);
teacherRequests = Array.isArray(requests) ? requests : [];
renderTeacherRequests();
} catch (error) {
teacherRequests = [];
if (teacherRequestsTbody) {
teacherRequestsTbody.innerHTML = `<tr><td colspan="6" class="loading-row">${escapeHtml(error.message || 'Ошибка загрузки заявок')}</td></tr>`;
}
if (teacherRequestCount) teacherRequestCount.textContent = '0 заявок';
}
}
async function loadWorkload() {
if (!startInput.value || !endInput.value) {
lastWorkload = [];
workloadErrorMessage = 'Выберите начало и окончание периода';
renderTeachers();
return;
}
const params = new URLSearchParams({
startDate: startInput.value,
endDate: endInput.value
});
if (selectedDepartmentId()) {
params.set('departmentId', selectedDepartmentId());
}
lastWorkload = null;
workloadErrorMessage = '';
renderTeachers();
try {
const workload = await api.get(`/api/workload/teachers?${params}`);
lastWorkload = Array.isArray(workload) ? workload : [];
renderTeachers();
} catch (error) {
lastWorkload = [];
workloadErrorMessage = error.message || 'Ошибка расчёта нагрузки';
renderTeachers();
}
}
async function handleStartDateChange() {
if (startInput.value && endInput.value && startInput.value > endInput.value) {
endInput.value = '';
}
await loadWorkload();
}
function renderTeachers() {
const container = document.getElementById('department-teachers-list');
if (!container) return;
if (lastTeachers === null || lastWorkload === null) {
container.textContent = 'Загрузка...';
return;
}
const teachers = Array.isArray(lastTeachers) ? lastTeachers : [];
const workload = Array.isArray(lastWorkload) ? lastWorkload : [];
const workloadByTeacher = new Map();
const usedWorkloadKeys = new Set();
workload
.filter(item => item && (item.id || item.name))
.forEach(item => {
workloadKeys(item).forEach(key => {
if (!workloadByTeacher.has(key)) {
workloadByTeacher.set(key, item);
}
});
});
const rows = teachers.map(teacher => {
const workloadItem = workloadKeys(teacher)
.map(key => workloadByTeacher.get(key))
.find(Boolean);
if (workloadItem) {
workloadKeys(workloadItem).forEach(key => usedWorkloadKeys.add(key));
}
return renderTeacherMetric({
name: teacher.fullName || teacher.username || '-',
meta: teacher.jobTitle || 'Должность не указана',
workload: workloadItem
}, workloadErrorMessage);
});
workload
.filter(item => item && (item.id || item.name))
.filter(item => workloadKeys(item).every(key => !usedWorkloadKeys.has(key)))
.forEach(item => {
rows.push(renderTeacherMetric({
name: item.name || '-',
meta: 'Есть занятия в расписании кафедры',
workload: item
}, workloadErrorMessage));
});
if (rows.length) {
container.innerHTML = rows.join('');
return;
}
container.innerHTML = `<div class="loading-row">${escapeHtml(teachersErrorMessage || 'Преподаватели не найдены')}</div>`;
}
function renderTeacherSelect() {
if (!assignTeacherSelect) return;
if (lastTeachers === null) {
assignTeacherSelect.innerHTML = '<option value="">Загрузка...</option>';
return;
}
const assignedIds = new Set((lastTeachers || []).map(teacher => String(teacher.id)));
const availableTeachers = allTeachers.filter(teacher => !assignedIds.has(String(teacher.id)));
assignTeacherSelect.innerHTML = '<option value="">Выберите преподавателя</option>' +
availableTeachers.map(teacher => `
<option value="${escapeHtml(teacher.id)}">
${escapeHtml(teacher.fullName || teacher.username || `ID ${teacher.id}`)}
</option>
`).join('');
if (!availableTeachers.length) {
assignTeacherSelect.innerHTML = '<option value="">Нет доступных преподавателей</option>';
}
}
function renderTeacherRequests() {
if (!teacherRequestsTbody) return;
if (teacherRequests === null) {
teacherRequestsTbody.innerHTML = '<tr><td colspan="6" class="loading-row">Загрузка...</td></tr>';
if (teacherRequestCount) teacherRequestCount.textContent = 'Загрузка...';
return;
}
if (teacherRequestCount) teacherRequestCount.textContent = requestCountLabel(teacherRequests.length);
if (!teacherRequests.length) {
teacherRequestsTbody.innerHTML = '<tr><td colspan="6" class="loading-row">Заявок пока нет</td></tr>';
return;
}
teacherRequestsTbody.innerHTML = teacherRequests.map(request => `
<tr>
<td><span class="badge ${requestStatusClass(request.status)}">${escapeHtml(requestStatusLabel(request.status))}</span></td>
<td>${escapeHtml(request.username || '-')}</td>
<td>${escapeHtml(request.fullName || '-')}</td>
<td>${escapeHtml(request.jobTitle || '-')}</td>
<td>${escapeHtml(request.comment || '-')}</td>
<td>${escapeHtml(request.reviewComment || formatDateTime(request.reviewedAt) || '-')}</td>
</tr>
`).join('');
}
function selectedDepartmentId() {
return role === ROLE_DEPARTMENT ? userDepartmentId : departmentSelect.value;
}
}
async function selectDefaultWorkloadPeriod(startInput, endInput) {
try {
const semesters = await api.get('/api/schedule/semesters');
const period = defaultSchedulePeriod(
semesters,
formatLocalDate(new Date())
);
startInput.value = period.startDate;
endInput.value = period.endDate;
} catch (_error) {
// Текущий диапазон остаётся безопасным fallback, если справочник семестров недоступен.
}
}
async function loadDepartments(role, userDepartmentId) {
const departments = await api.get('/api/departments');
fillSelect('department-workspace-department', departments, item => item.id, item => item.departmentName, 'Выберите кафедру');
if (role === ROLE_DEPARTMENT && userDepartmentId) {
document.getElementById('department-workspace-department').value = userDepartmentId;
} else if (!document.getElementById('department-workspace-department').value && departments.length) {
document.getElementById('department-workspace-department').value = departments[0].id;
}
}
function fillSelect(id, items, valueFn, labelFn, emptyLabel) {
const select = document.getElementById(id);
if (!select) return;
select.innerHTML = `<option value="">${escapeHtml(emptyLabel)}</option>` +
(items || []).map(item => `<option value="${escapeHtml(valueFn(item))}">${escapeHtml(labelFn(item))}</option>`).join('');
}
function setDefaultDates(startInput, endInput) {
const today = new Date();
const nextWeek = new Date(today);
nextWeek.setDate(today.getDate() + 7);
startInput.value = formatLocalDate(today);
endInput.value = formatLocalDate(nextWeek);
}
function formatDateTime(value) {
if (!value) return '-';
return String(value).replace('T', ' ').slice(0, 16);
}
function subjectCountLabel(count) {
const tail = count % 100;
if (tail >= 11 && tail <= 14) return `${count} дисциплин`;
if (count % 10 === 1) return `${count} дисциплина`;
if ([2, 3, 4].includes(count % 10)) return `${count} дисциплины`;
return `${count} дисциплин`;
}
function requestCountLabel(count) {
const tail = count % 100;
if (tail >= 11 && tail <= 14) return `${count} заявок`;
if (count % 10 === 1) return `${count} заявка`;
if ([2, 3, 4].includes(count % 10)) return `${count} заявки`;
return `${count} заявок`;
}
function requestStatusLabel(status) {
return {
PENDING: 'На рассмотрении',
APPROVED: 'Одобрена',
REJECTED: 'Отклонена'
}[status] || status || '-';
}
function requestStatusClass(status) {
return {
PENDING: 'badge-ef',
APPROVED: 'badge-student',
REJECTED: 'badge-admin'
}[status] || 'badge-ef';
}
function renderTeacherMetric(item, workloadErrorMessage) {
return `
<article class="metric-card">
<strong>${escapeHtml(item.name || '-')}</strong>
<span>${escapeHtml(item.meta || 'Должность не указана')}</span>
<small>${escapeHtml(workloadLabel(item.workload, workloadErrorMessage))}</small>
</article>
`;
}
function workloadLabel(item, workloadErrorMessage) {
if (workloadErrorMessage) {
return workloadErrorMessage;
}
if (!item) {
return '0 ак. ч., 0 занятий за период';
}
const hours = String(item.academicHours ?? 0);
const lessons = String(item.lessonCount ?? 0);
return `${hours} ак. ч., ${lessons} занятий за период`;
}
function workloadKeys(item) {
return [
item.id == null ? null : `id:${item.id}`,
normalizeTeacherName(item.fullName || item.name || item.username)
].filter(Boolean);
}
function normalizeTeacherName(value) {
return value ? `name:${String(value).trim().toLowerCase()}` : null;
}

View File

@@ -1,990 +0,0 @@
import { api } from '../api.js';
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
import { buildAcademicCalendarTitle } from './academic-calendar-title.js';
import { AsyncCombobox } from '../async-combobox.js';
import { alertAction, confirmAction } from '../dialog.js';
import { renderPagination } from '../pagination.js';
import { debounce, readUrlState, readUrlValues, writeUrlState } from '../url-state.js';
import { markTableReady, renderTableEmpty, renderTableError, renderTableLoading } from '../view-state.js';
import { trackUxEvent } from '../../../telemetry.js';
async function fetchEducationForms() {
try {
return await api.get('/api/education-forms');
} catch (e) {
console.error('Не удалось загрузить формы обучения');
return [];
}
}
const FILTER_EF_EMPTY_TEXT = 'Выберите форму обучения';
export async function initGroups() {
const groupsTbody = document.getElementById('groups-tbody');
const createGroupForm = document.getElementById('create-group-form');
const newGroupEfSelect = document.getElementById('new-group-ef');
const newGroupDepartmentSelect = document.getElementById('new-group-department');
const newGroupSpecialitySelect = document.getElementById('new-group-speciality-code');
const newGroupProfileSelect = document.getElementById('new-group-profile');
const filterEfBox = document.getElementById('filter-ef-box');
const filterEfMenu = document.getElementById('filter-ef-menu');
const filterEfText = document.getElementById('filter-ef-text');
const filterEfCheckboxes = document.getElementById('filter-ef-checkboxes');
const searchInput = document.getElementById('groups-search');
const sortSelect = document.getElementById('groups-sort');
const filterResetButton = document.getElementById('groups-filter-reset');
const pagination = document.getElementById('groups-pagination');
// Модалка группы
const modalEditGroup = document.getElementById('modal-edit-group');
const modalEditGroupClose = document.getElementById('modal-edit-group-close');
const editGroupForm = document.getElementById('edit-group-form');
const editGroupEfSelect = document.getElementById('edit-group-ef');
const editGroupDepartmentSelect = document.getElementById('edit-group-department');
const editGroupSpecialitySelect = document.getElementById('edit-group-speciality-code');
const editGroupProfileSelect = document.getElementById('edit-group-profile');
// Модалка подгрупп
const modalManageSubgroups = document.getElementById('modal-manage-subgroups');
const modalManageSubgroupsClose = document.getElementById('modal-manage-subgroups-close');
const subgroupsGroupContext = document.getElementById('subgroups-group-context');
const subgroupFormTitle = document.getElementById('subgroup-form-title');
const manageSubgroupForm = document.getElementById('manage-subgroup-form');
const manageSubgroupIdInput = document.getElementById('manage-subgroup-id');
const manageSubgroupGroupIdInput = document.getElementById('manage-subgroup-group-id');
const manageSubgroupCountSelect = document.getElementById('manage-subgroup-count');
const manageSubgroupCapacities = document.getElementById('manage-subgroup-capacities');
const btnSaveSubgroup = document.getElementById('btn-save-subgroup');
const btnCancelSubgroupEdit = document.getElementById('btn-cancel-subgroup-edit');
const manageSubgroupsTbody = document.getElementById('manage-subgroups-tbody');
// Модалка календарей
const modalManageCalendar = document.getElementById('modal-manage-calendar');
const modalManageCalendarClose = document.getElementById('modal-manage-calendar-close');
const calendarGroupContext = document.getElementById('calendar-group-context');
const manageCalendarForm = document.getElementById('manage-calendar-form');
const manageCalendarGroupIdInput = document.getElementById('manage-calendar-group-id');
const manageCalendarYearSelect = document.getElementById('manage-calendar-year');
const manageCalendarSelect = document.getElementById('manage-calendar-id');
const manageCalendarTbody = document.getElementById('manage-calendar-tbody');
let allGroups = [];
let subgroups = [];
let educationForms = [];
let departments = [];
let specialties = [];
let profilesBySpecialty = new Map();
let academicYears = [];
let currentAssignments = [];
let activeSubgroupGroup = null;
let calendarCombobox = null;
const restored = readUrlState({ groupQuery: '', groupPage: '0', groupSize: '25', groupSort: 'name,asc' });
const pageState = {
query: restored.groupQuery,
page: Math.max(Number(restored.groupPage) || 0, 0),
size: [25, 50, 100].includes(Number(restored.groupSize)) ? Number(restored.groupSize) : 25,
sort: restored.groupSort,
educationFormIds: readUrlValues('groupEducationForm')
};
searchInput.value = pageState.query;
sortSelect.value = pageState.sort;
bindEvents();
await loadInitialData();
function bindEvents() {
initFilterEfDropdown();
filterEfCheckboxes.addEventListener('change', handleFilterEfChange);
newGroupSpecialitySelect.addEventListener('change', () => populateProfileSelect(newGroupProfileSelect, newGroupSpecialitySelect.value));
editGroupSpecialitySelect.addEventListener('change', () => populateProfileSelect(editGroupProfileSelect, editGroupSpecialitySelect.value));
createGroupForm.addEventListener('submit', createGroup);
editGroupForm.addEventListener('submit', updateGroup);
// Модалка подгрупп
manageSubgroupForm.addEventListener('submit', saveSubgroup);
manageSubgroupCountSelect.addEventListener('change', () => renderSubgroupCapacityFields(Number(manageSubgroupCountSelect.value)));
btnCancelSubgroupEdit.addEventListener('click', resetSubgroupForm);
manageSubgroupsTbody.addEventListener('click', handleSubgroupTableClick);
// Модалка календарей
manageCalendarForm.addEventListener('submit', saveAssignment);
manageCalendarYearSelect.addEventListener('change', () => {
calendarCombobox?.clear();
populateCalendarSelect();
});
searchInput.addEventListener('input', debounce(() => {
pageState.query = searchInput.value.trim();
pageState.page = 0;
loadGroups();
}));
sortSelect.addEventListener('change', () => {
pageState.sort = sortSelect.value;
pageState.page = 0;
loadGroups();
});
filterResetButton.addEventListener('click', () => {
pageState.query = '';
pageState.page = 0;
pageState.sort = 'name,asc';
pageState.educationFormIds = [];
searchInput.value = '';
sortSelect.value = pageState.sort;
renderFilterEfCheckboxes(educationForms);
loadGroups();
});
groupsTbody.addEventListener('click', handleGroupTableClick);
// Закрытие модалок
modalEditGroupClose.addEventListener('click', () => modalEditGroup.classList.remove('open'));
modalEditGroup.addEventListener('click', (event) => {
if (event.target === modalEditGroup) modalEditGroup.classList.remove('open');
});
modalManageSubgroupsClose.addEventListener('click', () => modalManageSubgroups.classList.remove('open'));
modalManageSubgroups.addEventListener('click', (event) => {
if (event.target === modalManageSubgroups) modalManageSubgroups.classList.remove('open');
});
modalManageCalendarClose.addEventListener('click', () => modalManageCalendar.classList.remove('open'));
modalManageCalendar.addEventListener('click', (event) => {
if (event.target === modalManageCalendar) modalManageCalendar.classList.remove('open');
});
}
function initFilterEfDropdown() {
if (!filterEfBox || !filterEfMenu) return;
const toggleDropdown = (event) => {
event.stopPropagation();
const isOpen = filterEfMenu.classList.contains('open');
document.querySelectorAll('.dropdown-menu').forEach(menu => menu.classList.remove('open'));
document.querySelectorAll('.select-box').forEach(box => box.classList.remove('active'));
if (!isOpen) {
filterEfMenu.classList.add('open');
filterEfBox.classList.add('active');
filterEfBox.setAttribute('aria-expanded', 'true');
} else {
filterEfBox.setAttribute('aria-expanded', 'false');
}
};
filterEfBox.addEventListener('click', toggleDropdown);
filterEfBox.addEventListener('keydown', (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
toggleDropdown(event);
}
});
filterEfMenu.addEventListener('click', (event) => {
event.stopPropagation();
});
}
async function loadInitialData() {
try {
[educationForms, departments, specialties, academicYears] = await Promise.all([
fetchEducationForms(),
api.get('/api/departments'),
api.get('/api/specialties'),
api.get('/api/admin/calendar/years')
]);
await loadProfiles();
populateEfSelects(educationForms);
populateDepartmentSelects(departments);
populateSpecialitySelects(specialties);
populateYearSelect();
initCalendarCombobox();
await loadGroups();
} catch (error) {
groupsTbody.innerHTML = `<tr><td colspan="10" class="loading-row">Ошибка загрузки данных: ${escapeHtml(error.message)}</td></tr>`;
}
}
async function loadProfiles() {
const pairs = await Promise.all(specialties.map(async (speciality) => {
const profiles = await api.get(`/api/specialties/${speciality.id}/profiles`);
return [String(speciality.id), profiles];
}));
profilesBySpecialty = new Map(pairs);
}
async function loadGroups() {
renderTableLoading(groupsTbody, 10, 'Загрузка групп...');
try {
const [sort, direction] = pageState.sort.split(',');
const params = new URLSearchParams({
query: pageState.query,
page: String(pageState.page),
size: String(pageState.size),
sort,
direction,
includeArchived: 'true'
});
pageState.educationFormIds.forEach(id => params.append('educationFormId', id));
const result = await api.get(`/api/groups/page?${params}`);
allGroups = result.items || [];
if (!allGroups.length && result.totalItems > 0 && pageState.page > 0) {
pageState.page = Math.max(0, result.totalPages - 1);
return loadGroups();
}
renderGroups(allGroups);
renderPagination(pagination, result, {
label: 'групп',
onPage: page => { pageState.page = page; loadGroups(); },
onSize: size => { pageState.size = size; pageState.page = 0; loadGroups(); }
});
persistPageState();
trackUxEvent('реестр_групп_загружен', { total: Number(result.totalItems || 0), page: pageState.page });
} catch (error) {
renderTableError(groupsTbody, 10, error.message || 'Не удалось загрузить группы', loadGroups);
}
}
async function loadSubgroups(groupId) {
try {
subgroups = groupId ? await api.get(`/api/groups/${groupId}/subgroups`) : [];
} catch (error) {
subgroups = [];
throw error;
}
}
function applyGroupFilter() {
pageState.educationFormIds = selectedFilterEducationFormIds();
pageState.page = 0;
loadGroups();
}
function handleFilterEfChange(event) {
const changedCheckbox = event.target.closest('input[type="checkbox"]');
const allCheckbox = filterEfCheckboxes.querySelector('input[value="all"]');
if (changedCheckbox?.value === 'all' && changedCheckbox.checked) {
filterEfCheckboxes.querySelectorAll('input[type="checkbox"]:not([value="all"])').forEach(checkbox => {
checkbox.checked = false;
});
} else if (changedCheckbox?.value !== 'all' && changedCheckbox?.checked) {
allCheckbox.checked = false;
}
if (!selectedFilterEducationFormIds().length) {
allCheckbox.checked = true;
}
updateFilterEfText();
applyGroupFilter();
}
function selectedFilterEducationFormIds() {
const allCheckbox = filterEfCheckboxes.querySelector('input[value="all"]');
if (allCheckbox?.checked) return [];
return Array.from(filterEfCheckboxes.querySelectorAll('input[type="checkbox"]:checked:not([value="all"])'))
.map(checkbox => String(checkbox.value));
}
function updateFilterEfText() {
const allCheckbox = filterEfCheckboxes.querySelector('input[value="all"]');
const selectedIds = selectedFilterEducationFormIds();
if (allCheckbox?.checked) {
filterEfText.textContent = 'Все';
return;
}
if (!selectedIds.length) {
filterEfText.textContent = FILTER_EF_EMPTY_TEXT;
return;
}
if (selectedIds.length === 1) {
const form = educationForms.find(item => String(item.id) === selectedIds[0]);
filterEfText.textContent = form?.name || FILTER_EF_EMPTY_TEXT;
return;
}
filterEfText.textContent = `Выбрано: ${selectedIds.length}`;
}
function renderGroups(groups) {
if (!groups || !groups.length) {
renderTableEmpty(groupsTbody, 10, pageState.query || pageState.educationFormIds.length
? 'По заданным условиям группы не найдены'
: 'Групп пока нет');
return;
}
groupsTbody.innerHTML = groups.map(group => `
<tr>
<td>${group.id}</td>
<td><strong>${escapeHtml(group.name)}</strong></td>
<td>${escapeHtml(String(group.groupSize))}</td>
<td><span class="badge badge-ef">${escapeHtml(group.educationFormName)}</span></td>
<td>${escapeHtml(departmentLabel(group.departmentId))}</td>
<td>${group.course || '-'}</td>
<td>${escapeHtml(specialityLabel(group.specialtyId || group.specialityCode))}</td>
<td>${escapeHtml(group.specialtyProfileName || '-')}</td>
<td><span class="badge ${statusBadgeClass(group)}">${escapeHtml(statusLabel(group))}</span></td>
<td class="group-actions-cell">
<div class="group-actions">
<button class="btn btn-sm btn-secondary btn-edit-group group-action-btn" data-id="${group.id}">Изменить</button>
<button class="btn btn-sm btn-secondary btn-manage-subgroups group-action-btn" data-id="${group.id}">Подгруппы</button>
<button class="btn btn-sm btn-secondary btn-manage-calendar group-action-btn" data-id="${group.id}">Календарь</button>
${group.status === 'ARCHIVED'
? `<button class="btn btn-sm btn-secondary btn-restore-group group-action-btn" data-id="${group.id}">Восстановить</button>`
: `<button class="btn btn-sm btn-danger-subtle btn-archive-group group-action-btn" data-id="${group.id}">Архив</button>`}
</div>
</td>
</tr>
`).join('');
markTableReady(groupsTbody);
}
function populateEfSelects(forms) {
populateEfSelect(newGroupEfSelect, forms);
populateEfSelect(editGroupEfSelect, forms);
renderFilterEfCheckboxes(forms);
}
function renderFilterEfCheckboxes(forms) {
const selectedIds = new Set(pageState.educationFormIds);
const allChecked = !selectedIds.size;
filterEfCheckboxes.innerHTML = `
<label class="checkbox-item">
<input type="checkbox" value="all" ${allChecked ? 'checked' : ''}>
<span class="checkmark"></span>
<span>Все</span>
</label>
${forms.map(form => {
const id = String(form.id);
return `
<label class="checkbox-item">
<input type="checkbox" value="${escapeHtml(id)}" ${selectedIds.has(id) ? 'checked' : ''}>
<span class="checkmark"></span>
<span>${escapeHtml(form.name)}</span>
</label>
`;
}).join('')}
`;
updateFilterEfText();
}
function populateEfSelect(select, forms) {
const currentVal = select.value;
select.innerHTML = forms.map(form => `<option value="${form.id}">${escapeHtml(form.name)}</option>`).join('');
if (currentVal && forms.find(form => form.id == currentVal)) {
select.value = currentVal;
}
syncSelects(select);
}
function populateDepartmentSelects(items) {
populateDepartmentSelect(newGroupDepartmentSelect, items);
populateDepartmentSelect(editGroupDepartmentSelect, items);
}
function populateDepartmentSelect(select, items) {
const currentVal = select.value;
select.innerHTML = '<option value="">Выберите кафедру</option>' +
items.map(department => {
const name = department.departmentName || department.name || 'Без названия';
const code = department.departmentCode || department.code || department.id;
return `<option value="${department.id}">${escapeHtml(name)} (${escapeHtml(String(code))})</option>`;
}).join('');
if (currentVal && items.find(department => department.id == currentVal)) {
select.value = currentVal;
}
syncSelects(select);
}
function populateSpecialitySelects(items) {
populateSpecialitySelect(newGroupSpecialitySelect, items);
populateSpecialitySelect(editGroupSpecialitySelect, items);
populateProfileSelect(newGroupProfileSelect, newGroupSpecialitySelect.value);
populateProfileSelect(editGroupProfileSelect, editGroupSpecialitySelect.value);
}
function populateSpecialitySelect(select, items) {
const currentVal = select.value;
select.innerHTML = '<option value="">Выберите специальность</option>' +
items.map(speciality => `<option value="${speciality.id}">${escapeHtml(specialityLabel(speciality.id))}</option>`).join('');
if (currentVal && items.find(speciality => speciality.id == currentVal)) {
select.value = currentVal;
}
syncSelects(select);
}
function populateProfileSelect(select, specialtyId, selectedProfileId = null) {
const profiles = profilesBySpecialty.get(String(specialtyId)) || [];
select.innerHTML = profiles.length
? '<option value="">Выберите профиль</option>' + profiles.map(profile =>
`<option value="${profile.id}">${escapeHtml(profile.name)}</option>`
).join('')
: '<option value="">Нет профилей</option>';
if (selectedProfileId && profiles.some(profile => String(profile.id) === String(selectedProfileId))) {
select.value = selectedProfileId;
}
syncSelects(select);
}
function populateYearSelect() {
manageCalendarYearSelect.innerHTML = '<option value="">Выберите учебный год</option>' +
academicYears.map(year => `<option value="${year.id}">${escapeHtml(year.title)}</option>`).join('');
if (academicYears.length) {
manageCalendarYearSelect.value = String(academicYears[0].id);
}
syncSelects(manageCalendarYearSelect);
}
function populateCalendarSelect() {
calendarCombobox?.search('');
}
function initCalendarCombobox() {
calendarCombobox = new AsyncCombobox(manageCalendarSelect, {
placeholder: 'Найдите совместимый график',
emptyLabel: 'Совместимых графиков не найдено',
optionLabel: calendar => buildAcademicCalendarTitle(calendar) || calendar.title || `График ${calendar.id}`,
optionDescription: calendar => `${calendar.academicYearTitle || ''} · ${calendar.studyFormName || ''}`,
load: async (query, selectedId) => {
const group = allGroups.find(item => String(item.id) === String(manageCalendarGroupIdInput.value));
if (!group || !manageCalendarYearSelect.value) return [];
const params = new URLSearchParams({
query,
academicYearId: manageCalendarYearSelect.value,
specialtyId: String(group.specialtyId || group.specialityCode),
profileId: String(group.specialtyProfileId),
studyFormId: String(group.educationFormId),
limit: '30'
});
if (selectedId) params.set('selectedId', selectedId);
return api.get(`/api/admin/academic-calendars/options?${params}`);
}
});
}
function persistPageState() {
writeUrlState({
groupQuery: pageState.query,
groupPage: pageState.page || '',
groupSize: pageState.size === 25 ? '' : pageState.size,
groupSort: pageState.sort === 'name,asc' ? '' : pageState.sort,
groupEducationForm: pageState.educationFormIds
});
}
async function createGroup(event) {
event.preventDefault();
hideAlert('create-group-alert');
const payload = readGroupPayload('new');
if (!payload) return;
try {
const data = await api.post('/api/groups', payload);
showAlert('create-group-alert', `Группа "${escapeHtml(data.name || payload.name)}" создана`, 'success');
createGroupForm.reset();
syncSelects(newGroupEfSelect, newGroupDepartmentSelect, newGroupSpecialitySelect, newGroupProfileSelect);
await loadGroups();
} catch (error) {
showAlert('create-group-alert', error.message || 'Ошибка создания', 'error');
}
}
async function updateGroup(event) {
event.preventDefault();
hideAlert('edit-group-alert');
const id = document.getElementById('edit-group-id').value;
const payload = readGroupPayload('edit');
if (!payload) return;
try {
const data = await api.put('/api/groups/' + id, payload);
modalEditGroup.classList.remove('open');
showAlert('create-group-alert', `Группа "${escapeHtml(data.name || payload.name)}" обновлена`, 'success');
await loadGroups();
} catch (error) {
showAlert('edit-group-alert', error.message || 'Ошибка обновления', 'error');
}
}
function readGroupPayload(prefix) {
const isEdit = prefix === 'edit';
const alertId = isEdit ? 'edit-group-alert' : 'create-group-alert';
const name = document.getElementById(`${prefix}-group-name`).value.trim();
const groupSize = document.getElementById(`${prefix}-group-size`).value;
const educationFormId = (isEdit ? editGroupEfSelect : newGroupEfSelect).value;
const departmentId = (isEdit ? editGroupDepartmentSelect : newGroupDepartmentSelect).value;
const yearStartStudy = document.getElementById(`${prefix}-group-yearStartStudy`).value;
const specialtyId = (isEdit ? editGroupSpecialitySelect : newGroupSpecialitySelect).value;
const specialtyProfileId = (isEdit ? editGroupProfileSelect : newGroupProfileSelect).value;
if (!name) { showAlert(alertId, 'Введите название группы', 'error'); return null; }
if (!groupSize) { showAlert(alertId, 'Введите размер группы', 'error'); return null; }
if (!educationFormId) { showAlert(alertId, 'Выберите форму обучения', 'error'); return null; }
if (!departmentId) { showAlert(alertId, 'Выберите кафедру', 'error'); return null; }
if (!yearStartStudy) { showAlert(alertId, 'Введите год начала обучения', 'error'); return null; }
if (!specialtyId) { showAlert(alertId, 'Выберите специальность', 'error'); return null; }
if (!specialtyProfileId) { showAlert(alertId, 'Выберите профиль обучения', 'error'); return null; }
return {
name,
groupSize: Number(groupSize),
educationFormId: Number(educationFormId),
departmentId: Number(departmentId),
yearStartStudy: Number(yearStartStudy),
specialtyId: Number(specialtyId),
specialtyProfileId: Number(specialtyProfileId)
};
}
async function handleGroupTableClick(event) {
const btnDelete = event.target.closest('.btn-archive-group');
const btnEdit = event.target.closest('.btn-edit-group');
const btnRestore = event.target.closest('.btn-restore-group');
const btnSubgroups = event.target.closest('.btn-manage-subgroups');
const btnCalendar = event.target.closest('.btn-manage-calendar');
if (btnDelete) {
if (!await confirmAction({
title: 'Архивировать группу?',
message: 'Группа исчезнет из рабочих списков, но история расписания сохранится.',
confirmLabel: 'Архивировать',
danger: true
})) return;
try {
await api.delete('/api/groups/' + btnDelete.dataset.id);
await loadGroups();
} catch (error) {
await alertAction({ title: 'Не удалось архивировать группу', message: error.message || 'Ошибка архивирования' });
}
}
if (btnRestore) {
try {
await api.post('/api/groups/' + btnRestore.dataset.id + '/restore', {});
await loadGroups();
} catch (error) {
await alertAction({ title: 'Не удалось восстановить группу', message: error.message || 'Ошибка восстановления' });
}
}
if (btnEdit) {
openEditGroupModal(btnEdit.dataset.id);
}
if (btnSubgroups) {
const groupId = btnSubgroups.dataset.id;
const group = allGroups.find(g => g.id == groupId);
if (group) await openSubgroupsModal(group);
}
if (btnCalendar) {
const groupId = btnCalendar.dataset.id;
const group = allGroups.find(g => g.id == groupId);
if (group) openCalendarModal(group);
}
}
async function openEditGroupModal(id) {
const group = allGroups.find(item => item.id == id);
if (!group) {
await alertAction({ title: 'Группа не найдена', message: 'Обновите список и повторите действие.' });
return;
}
document.getElementById('edit-group-id').value = group.id;
document.getElementById('edit-group-name').value = group.name || '';
document.getElementById('edit-group-size').value = group.groupSize || '';
editGroupEfSelect.value = group.educationFormId || '';
editGroupDepartmentSelect.value = group.departmentId || '';
document.getElementById('edit-group-yearStartStudy').value = group.yearStartStudy || '';
editGroupSpecialitySelect.value = group.specialtyId || group.specialityCode || '';
populateProfileSelect(editGroupProfileSelect, editGroupSpecialitySelect.value, group.specialtyProfileId);
syncSelects(editGroupEfSelect, editGroupDepartmentSelect, editGroupSpecialitySelect, editGroupProfileSelect);
hideAlert('edit-group-alert');
modalEditGroup.classList.add('open');
}
// --- Управление подгруппами ---
async function openSubgroupsModal(group) {
activeSubgroupGroup = group;
subgroupsGroupContext.textContent = `Группа: ${group.name} (${group.groupSize} чел.)`;
manageSubgroupGroupIdInput.value = group.id;
modalManageSubgroups.classList.add('open');
manageSubgroupsTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Загрузка подгрупп...</td></tr>';
try {
await loadSubgroups(group.id);
resetSubgroupForm();
renderSubgroupsList(group.id);
} catch (error) {
showAlert('manage-subgroup-alert', error.message || 'Не удалось загрузить подгруппы', 'error');
manageSubgroupsTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Не удалось загрузить подгруппы</td></tr>';
}
}
function renderSubgroupsList(groupId) {
const list = subgroups.filter(sub => String(sub.groupId) === String(groupId));
if (!list.length) {
manageSubgroupsTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Подгруппы не созданы</td></tr>';
return;
}
const canDeleteSingleSubgroup = list.length === 1;
const deleteTitle = canDeleteSingleSubgroup
? 'Удалить подгруппу'
: 'Чтобы изменить количество подгрупп, выберите нужный режим выше и нажмите «Применить»';
manageSubgroupsTbody.innerHTML = list.map(sub => `
<tr>
<td>${escapeHtml(sub.name)}</td>
<td>${escapeHtml(String(sub.studentCapacity ?? '-'))} чел.</td>
<td>
<button class="btn btn-sm btn-secondary btn-edit-subgroup" data-id="${sub.id}">Изменить</button>
<button class="btn btn-sm btn-danger btn-delete-subgroup" data-id="${sub.id}"
${canDeleteSingleSubgroup ? '' : 'disabled'}
title="${escapeHtml(deleteTitle)}">${canDeleteSingleSubgroup ? 'Удалить' : 'Изменить выше'}</button>
</td>
</tr>
`).join('');
}
function resetSubgroupForm() {
manageSubgroupIdInput.value = '';
const current = currentGroupSubgroups(manageSubgroupGroupIdInput.value);
const count = [2, 3].includes(current.length) ? current.length : 0;
manageSubgroupCountSelect.value = String(count);
syncSelects(manageSubgroupCountSelect);
renderSubgroupCapacityFields(count, current.map(item => item.studentCapacity));
subgroupFormTitle.textContent = 'Настройка подгрупп';
btnSaveSubgroup.textContent = 'Применить';
btnCancelSubgroupEdit.hidden = true;
hideAlert('manage-subgroup-alert');
}
function currentGroupSubgroups(groupId) {
return subgroups
.filter(sub => String(sub.groupId) === String(groupId))
.sort((left, right) => naturalSubgroupOrder(left.name).localeCompare(naturalSubgroupOrder(right.name), 'ru'));
}
function naturalSubgroupOrder(name) {
const number = String(name || '').match(/\d+/)?.[0];
return number ? String(number).padStart(3, '0') : String(name || '');
}
function renderSubgroupCapacityFields(count, capacities = []) {
manageSubgroupCapacities.className = `subgroup-capacity-fields subgroup-capacity-fields-${count}`;
if (!count) {
manageSubgroupCapacities.innerHTML = '';
return;
}
const groupSize = Number(activeSubgroupGroup?.groupSize || 0);
const suggested = suggestedSubgroupCapacities(groupSize, count);
manageSubgroupCapacities.innerHTML = Array.from({ length: count }, (_, index) => {
const value = capacities[index] ?? suggested[index] ?? '';
return `
<div class="form-group subgroup-capacity-field">
<label for="manage-subgroup-capacity-${index + 1}">Численность ${index + 1}</label>
<input type="number" id="manage-subgroup-capacity-${index + 1}" class="manage-subgroup-capacity"
min="1" max="${groupSize || ''}" value="${escapeHtml(String(value))}" required>
</div>
`;
}).join('');
}
function suggestedSubgroupCapacities(groupSize, count) {
if (!groupSize || !count) return [];
const base = Math.floor(groupSize / count);
const remainder = groupSize % count;
return Array.from({ length: count }, (_, index) => base + (index < remainder ? 1 : 0));
}
async function saveSubgroup(event) {
event.preventDefault();
hideAlert('manage-subgroup-alert');
const groupId = manageSubgroupGroupIdInput.value;
const group = activeSubgroupGroup || allGroups.find(item => String(item.id) === String(groupId));
const count = Number(manageSubgroupCountSelect.value);
const existingSubgroups = currentGroupSubgroups(groupId);
if (!group) {
showAlert('manage-subgroup-alert', 'Группа не найдена', 'error');
return;
}
if (count === 0) {
await applyNoSubgroups(groupId, existingSubgroups);
return;
}
const capacities = Array.from(manageSubgroupCapacities.querySelectorAll('.manage-subgroup-capacity'))
.map(input => Number(input.value));
const validationError = validateSubgroupCapacities(capacities, Number(group.groupSize), count);
if (validationError) {
showAlert('manage-subgroup-alert', validationError, 'error');
return;
}
try {
await syncSubgroups(groupId, existingSubgroups, capacities);
showAlert('manage-subgroup-alert', 'Подгруппы сохранены', 'success');
await loadSubgroups(groupId);
resetSubgroupForm();
renderSubgroupsList(groupId);
} catch (error) {
showAlert('manage-subgroup-alert', error.message || 'Ошибка сохранения подгруппы', 'error');
}
}
function validateSubgroupCapacities(capacities, groupSize, count) {
if (capacities.length !== count || capacities.some(value => !Number.isInteger(value) || value <= 0)) {
return 'Укажите положительную численность для каждой подгруппы';
}
const total = capacities.reduce((sum, value) => sum + value, 0);
if (total !== groupSize) {
return `Сумма численностей подгрупп должна быть равна численности группы (${groupSize} чел.)`;
}
return null;
}
async function applyNoSubgroups(groupId, existingSubgroups) {
try {
for (const subgroup of existingSubgroups) {
await api.delete(`/api/groups/${groupId}/subgroups/${subgroup.id}`);
}
showAlert('manage-subgroup-alert', 'Деление на подгруппы отключено', 'success');
await loadSubgroups(groupId);
resetSubgroupForm();
renderSubgroupsList(groupId);
} catch (error) {
showAlert('manage-subgroup-alert', error.message || 'Ошибка отключения подгрупп', 'error');
}
}
async function syncSubgroups(groupId, existingSubgroups, capacities) {
const targetCount = capacities.length;
const extraSubgroups = existingSubgroups.slice(targetCount);
for (const subgroup of extraSubgroups) {
await api.delete(`/api/groups/${groupId}/subgroups/${subgroup.id}`);
}
const updates = [];
const creates = [];
for (let index = 0; index < targetCount; index += 1) {
const payload = {
name: `Подгруппа ${index + 1}`,
studentCapacity: capacities[index]
};
const existing = existingSubgroups[index];
if (existing) {
updates.push({ existing, payload });
} else {
creates.push(payload);
}
}
updates.sort((left, right) => {
const leftIncreases = left.payload.studentCapacity > Number(left.existing.studentCapacity || 0);
const rightIncreases = right.payload.studentCapacity > Number(right.existing.studentCapacity || 0);
return Number(leftIncreases) - Number(rightIncreases);
});
for (const update of updates) {
await api.put(`/api/groups/${groupId}/subgroups/${update.existing.id}`, update.payload);
}
for (const payload of creates) {
await api.post(`/api/groups/${groupId}/subgroups`, payload);
}
}
async function handleSubgroupTableClick(event) {
const editButton = event.target.closest('.btn-edit-subgroup');
const deleteButton = event.target.closest('.btn-delete-subgroup');
const groupId = manageSubgroupGroupIdInput.value;
if (editButton) {
const subgroup = subgroups.find(item => item.id == editButton.dataset.id);
if (!subgroup) return;
resetSubgroupForm();
const inputIndex = currentGroupSubgroups(groupId).findIndex(item => item.id === subgroup.id);
const input = manageSubgroupCapacities.querySelectorAll('.manage-subgroup-capacity')[inputIndex];
input?.focus();
hideAlert('manage-subgroup-alert');
return;
}
if (deleteButton) {
if (deleteButton.disabled) {
showAlert('manage-subgroup-alert', 'Чтобы изменить количество подгрупп, выберите нужный режим выше и нажмите «Применить»', 'error');
return;
}
if (!await confirmAction({
title: 'Удалить подгруппу?',
message: 'Удаление возможно только если подгруппа не используется в расписании.',
confirmLabel: 'Удалить',
danger: true
})) return;
try {
await api.delete(`/api/groups/${groupId}/subgroups/${deleteButton.dataset.id}`);
showAlert('manage-subgroup-alert', 'Подгруппа удалена', 'success');
resetSubgroupForm();
await loadSubgroups(groupId);
renderSubgroupsList(groupId);
} catch (error) {
showAlert('manage-subgroup-alert', error.message || 'Ошибка удаления подгруппы', 'error');
}
}
}
// --- Управление календарными графиками ---
async function openCalendarModal(group) {
calendarGroupContext.textContent = `Группа: ${group.name} (${specialityLabel(group.specialtyId || group.specialityCode)})`;
manageCalendarGroupIdInput.value = group.id;
hideAlert('manage-calendar-alert');
populateCalendarSelect();
modalManageCalendar.classList.add('open');
await loadAssignmentsList(group.id);
}
async function loadAssignmentsList(groupId) {
manageCalendarTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Загрузка...</td></tr>';
try {
currentAssignments = await api.get(`/api/groups/${groupId}/calendar-assignments`);
renderAssignmentsList();
} catch (error) {
manageCalendarTbody.innerHTML = `<tr><td colspan="4" class="loading-row">Ошибка: ${escapeHtml(error.message)}</td></tr>`;
}
}
function renderAssignmentsList() {
if (!currentAssignments.length) {
manageCalendarTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Календарные графики не назначены</td></tr>';
return;
}
manageCalendarTbody.innerHTML = currentAssignments.map(assignment => `
<tr>
<td>${escapeHtml(assignment.academicYearTitle)}</td>
<td>${escapeHtml(assignmentCalendarTitle(assignment))}</td>
<td>${renderAssignmentSubjects(assignment.subjects || [])}</td>
<td>
<button class="btn btn-sm btn-danger btn-delete-assignment" data-id="${assignment.id}">Удалить</button>
</td>
</tr>
`).join('');
}
function assignmentCalendarTitle(assignment) {
return assignment.calendarTitle || '-';
}
function renderAssignmentSubjects(subjects) {
if (!subjects.length) {
return '<span class="muted">Не привязаны</span>';
}
const grouped = subjects.reduce((acc, subject) => {
const key = String(subject.semesterNumber || '-');
if (!acc.has(key)) acc.set(key, []);
acc.get(key).push(subject);
return acc;
}, new Map());
return `<div class="assignment-subjects">${Array.from(grouped.entries())
.sort(([a], [b]) => Number(a) - Number(b))
.map(([semesterNumber, items]) => `
<div class="assignment-subject-semester">
<strong>${escapeHtml(semesterNumber)} семестр</strong>
<span>${items.map(subject => escapeHtml(subjectLabel(subject))).join(', ')}</span>
</div>
`).join('')}</div>`;
}
async function saveAssignment(event) {
event.preventDefault();
hideAlert('manage-calendar-alert');
const groupId = manageCalendarGroupIdInput.value;
const academicYearId = manageCalendarYearSelect.value;
const calendarId = manageCalendarSelect.value;
if (!academicYearId || !calendarId) {
showAlert('manage-calendar-alert', 'Выберите учебный год и график', 'error');
return;
}
try {
await api.put(`/api/groups/${groupId}/calendar-assignments`, {
academicYearId: Number(academicYearId),
calendarId: Number(calendarId)
});
showAlert('manage-calendar-alert', 'Календарный график назначен', 'success');
await loadAssignmentsList(groupId);
} catch (error) {
showAlert('manage-calendar-alert', error.message || 'Ошибка назначения графика', 'error');
}
}
manageCalendarTbody.addEventListener('click', async (event) => {
const deleteButton = event.target.closest('.btn-delete-assignment');
if (!deleteButton) return;
if (!await confirmAction({
title: 'Удалить назначение графика?',
message: 'Группа потеряет связь с этим графиком на выбранный учебный год.',
confirmLabel: 'Удалить назначение',
danger: true
})) return;
const groupId = manageCalendarGroupIdInput.value;
try {
await api.delete(`/api/groups/${groupId}/calendar-assignments/${deleteButton.dataset.id}`);
showAlert('manage-calendar-alert', 'Назначение удалено', 'success');
await loadAssignmentsList(groupId);
} catch (error) {
showAlert('manage-calendar-alert', error.message || 'Ошибка удаления назначения', 'error');
}
});
// --- Вспомогательные функции ---
function departmentLabel(departmentId) {
const department = departments.find(item => item.id == departmentId);
if (!department) return departmentId || '-';
return department.departmentName || department.name || departmentId;
}
function specialityLabel(specialityId) {
const speciality = specialties.find(item => item.id == specialityId);
if (!speciality) return specialityId || '-';
const code = speciality.specialityCode || speciality.specialtyCode || speciality.specialty_code || speciality.id;
const name = speciality.specialityName || speciality.name || '';
return name ? `${code} — ${name}` : code;
}
function subjectLabel(subject) {
const code = subject.subjectCode ? `${subject.subjectCode} · ` : '';
return `${code}${subject.subjectName || subject.subjectId || 'Без названия'}`;
}
function statusLabel(group) {
if (group.status === 'ARCHIVED') return 'Архив';
return group.studyStateName || (group.active ? 'Активна' : 'Неактивна');
}
function statusBadgeClass(group) {
if (group.status === 'ARCHIVED' || group.studyState === 'GRADUATED' || group.studyState === 'INACTIVE') {
return 'badge-unavailable';
}
if (group.studyState === 'NOT_STARTED') {
return 'badge-ef';
}
return 'badge-available';
}
function syncSelects(...selects) {
selects.filter(Boolean).forEach(select => {
select.dispatchEvent(new Event('change', { bubbles: true }));
});
}
}

View File

@@ -1,634 +0,0 @@
import { api } from '../api.js';
import { escapeHtml, hideAlert, showAlert } from '../utils.js';
import { AsyncCombobox } from '../async-combobox.js';
const EDIT_ROLES = new Set(['ADMIN', 'EDUCATION_OFFICE']);
const ACTION_LABELS = {
MOVE: 'Перенос',
REPLACE: 'Замена',
CANCEL: 'Отмена'
};
const DAY_LABELS = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
const MONTH_LABELS = [
'Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь',
'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'
];
export function canEditScheduleOverrides(role) {
return EDIT_ROLES.has(role);
}
export function overrideActionForChanges(changes) {
if (changes.dateChanged || changes.timeChanged) return 'MOVE';
if (changes.teacherChanged || changes.classroomChanged || changes.formatChanged) return 'REPLACE';
return null;
}
export function weekDates(value) {
const selected = parseIsoDate(value);
const day = selected.getDay() || 7;
const monday = addDays(selected, 1 - day);
return Array.from({ length: 7 }, (_, index) => toIsoDate(addDays(monday, index)));
}
export function pickMatchingTimeSlot(slots, preferredId, startTime, endTime) {
const safeSlots = Array.isArray(slots) ? slots : [];
const byId = preferredId == null
? null
: safeSlots.find(slot => String(slot.id) === String(preferredId));
if (byId) return byId;
const normalizedStart = trimTime(startTime);
const normalizedEnd = trimTime(endTime);
if (!normalizedStart || !normalizedEnd) return null;
return safeSlots.find(slot => trimTime(slot.startTime) === normalizedStart
&& trimTime(slot.endTime) === normalizedEnd) || null;
}
export function createScheduleOverridePanel({ role, dictionaries, getRange, onChanged }) {
if (!canEditScheduleOverrides(role)) {
return {
setRegistry() {},
openLesson() {},
openRegistry() {}
};
}
const drawer = document.getElementById('schedule-override-drawer');
const form = document.getElementById('schedule-override-form');
const empty = document.getElementById('schedule-override-empty');
const registryButton = document.getElementById('schedule-view-overrides-open');
const registryCount = document.getElementById('schedule-view-overrides-count');
const registry = document.getElementById('schedule-override-registry');
const registryPeriod = document.getElementById('schedule-override-registry-period');
const calendar = document.getElementById('schedule-override-calendar');
const calendarToggle = document.getElementById('schedule-override-calendar-toggle');
const week = document.getElementById('schedule-override-week');
const timeSelect = document.getElementById('schedule-override-time');
const teacherSelect = document.getElementById('schedule-override-teacher');
const classroomSelect = document.getElementById('schedule-override-classroom');
const formatSelect = document.getElementById('schedule-override-format');
const commentInput = document.getElementById('schedule-override-comment');
const restoreButton = document.getElementById('schedule-override-restore');
const cancelButton = document.getElementById('schedule-override-cancel');
const saveButton = document.getElementById('schedule-override-save');
const state = {
overrides: [],
selectedLesson: null,
selectedOverride: null,
source: null,
availability: null,
targetDate: null,
timeSlots: [],
busy: false
};
registryButton.hidden = false;
const teacherCombobox = new AsyncCombobox(teacherSelect, {
placeholder: 'Найдите преподавателя',
optionLabel: teacher => teacher.fullName || teacher.username,
optionDescription: teacher => [teacher.jobTitle, teacher.departmentName].filter(Boolean).join(' · '),
load: async (query, selectedId) => {
const params = new URLSearchParams({ query, limit: '30' });
if (selectedId) params.set('selectedId', selectedId);
const items = await api.get(`/api/users/teachers/options?${params}`);
mergeDictionary('teachers', items);
return items;
}
});
const classroomCombobox = new AsyncCombobox(classroomSelect, {
placeholder: 'Найдите аудиторию',
optionLabel: classroomOptionLabel,
optionDescription: room => [room.building, room.floor ? `${room.floor} этаж` : null, room.capacity ? `${room.capacity} мест` : null].filter(Boolean).join(' · '),
load: async (query, selectedId) => {
const params = new URLSearchParams({ query, limit: '30' });
if (selectedId) params.set('selectedId', selectedId);
const items = await api.get(`/api/classrooms/options?${params}`);
mergeDictionary('classrooms', items);
return items;
}
});
bindEvents();
renderRegistry();
return { setRegistry, openLesson, openRegistry };
function bindEvents() {
drawer.querySelectorAll('[data-override-close]').forEach(button => {
button.addEventListener('click', close);
});
drawer.querySelectorAll('[data-override-tab]').forEach(button => {
button.addEventListener('click', () => activateTab(button.dataset.overrideTab));
});
registryButton.addEventListener('click', openRegistry);
calendarToggle.addEventListener('click', () => {
calendar.hidden = !calendar.hidden;
calendarToggle.textContent = calendar.hidden ? 'Выбрать другую дату' : 'Скрыть календарь семестра';
});
week.addEventListener('click', selectDateFromButton);
calendar.addEventListener('click', selectDateFromButton);
registry.addEventListener('click', event => {
const button = event.target.closest('[data-override-registry-id]');
if (!button) return;
const item = state.overrides.find(override => String(override.id) === button.dataset.overrideRegistryId);
if (item) openOverride(item);
});
[timeSelect, teacherSelect, classroomSelect, formatSelect].forEach(select => {
select.addEventListener('change', updateComparison);
});
form.addEventListener('submit', save);
cancelButton.addEventListener('click', cancelLesson);
restoreButton.addEventListener('click', restoreRule);
document.addEventListener('keydown', event => {
if (event.key === 'Escape' && !drawer.hidden) close();
});
}
function setRegistry(overrides, range) {
state.overrides = Array.isArray(overrides) ? overrides : [];
registryCount.textContent = String(state.overrides.length);
const activeRange = range || getRange?.();
registryPeriod.textContent = activeRange
? `${formatDate(activeRange.startDate)} — ${formatDate(activeRange.endDate)}`
: 'Период не выбран';
renderRegistry();
}
async function openLesson(lesson) {
if (!lesson?.scheduleRuleSlotId) return;
const originalDate = lesson.originalLessonDate || lesson.date;
const existing = state.overrides.find(override =>
String(override.baseRuleSlotId) === String(lesson.scheduleRuleSlotId)
&& override.lessonDate === originalDate
) || null;
await prepareEditor(lesson, existing);
}
async function openOverride(override) {
const syntheticLesson = {
scheduleRuleSlotId: override.baseRuleSlotId,
date: override.targetLessonDate || override.lessonDate,
originalLessonDate: override.lessonDate,
scheduleOverrideId: override.id,
subjectName: override.subjectName,
lessonTypeName: override.lessonTypeName,
groupNames: override.groupNames,
timeSlotId: override.newTimeSlotId || override.sourceTimeSlotId,
timeSlotOrder: override.sourceTimeSlotOrder,
startTime: override.sourceStartTime,
endTime: override.sourceEndTime,
teacherId: override.newTeacherId || override.sourceTeacherId,
teacherName: teacherName(override.newTeacherId) || override.sourceTeacherName,
classroomId: override.newClassroomId || override.sourceClassroomId,
classroomName: classroomName(override.newClassroomId) || override.sourceClassroomName,
lessonFormat: override.newLessonFormat || override.sourceLessonFormat
};
await prepareEditor(syntheticLesson, override);
}
function openRegistry() {
open();
activateTab('registry');
renderRegistry();
}
async function prepareEditor(lesson, existing) {
open();
activateTab('edit');
form.hidden = true;
empty.hidden = false;
empty.textContent = 'Загрузка доступных дат и времени...';
hideAlert('schedule-override-alert');
state.selectedLesson = lesson;
state.selectedOverride = existing;
state.source = sourceFrom(lesson, existing);
state.targetDate = existing?.targetLessonDate || state.source.lessonDate;
try {
state.availability = await api.get(
`/api/edu-office/schedule/overrides/availability?baseRuleSlotId=${encodeURIComponent(state.source.baseRuleSlotId)}`
+ `&lessonDate=${encodeURIComponent(state.source.lessonDate)}`
);
renderDateChoices();
await loadTimeSlots(initialTimePreference(existing));
setEditorValues(existing);
renderSource();
updateComparison();
restoreButton.hidden = !existing;
form.hidden = false;
empty.hidden = true;
} catch (error) {
empty.textContent = error.message || 'Не удалось загрузить данные занятия';
}
}
function sourceFrom(lesson, existing) {
return {
baseRuleSlotId: lesson.scheduleRuleSlotId,
lessonDate: lesson.originalLessonDate || existing?.lessonDate || lesson.date,
subjectName: existing?.subjectName || lesson.subjectName || 'Без дисциплины',
lessonTypeName: existing?.lessonTypeName || lesson.lessonTypeName || 'Тип не указан',
groupNames: existing?.groupNames?.length ? existing.groupNames : (lesson.groupNames || []),
timeSlotId: existing?.sourceTimeSlotId || lesson.timeSlotId,
timeSlotOrder: existing?.sourceTimeSlotOrder || lesson.timeSlotOrder,
startTime: existing?.sourceStartTime || lesson.startTime,
endTime: existing?.sourceEndTime || lesson.endTime,
teacherId: existing?.sourceTeacherId || lesson.teacherId,
teacherName: existing?.sourceTeacherName || lesson.teacherName,
classroomId: existing?.sourceClassroomId || lesson.classroomId,
classroomName: existing?.sourceClassroomName || lesson.classroomName,
lessonFormat: existing?.sourceLessonFormat || lesson.lessonFormat || 'Очно'
};
}
function initialTimePreference(existing) {
return {
id: existing?.newTimeSlotId || state.source.timeSlotId,
startTime: existing?.newTimeSlotId ? null : state.source.startTime,
endTime: existing?.newTimeSlotId ? null : state.source.endTime
};
}
function setEditorValues(existing) {
const effectiveTeacherId = existing?.newTeacherId || state.source.teacherId || '';
const effectiveClassroomId = existing?.newClassroomId || state.source.classroomId || '';
ensureSelectOption(
teacherSelect,
effectiveTeacherId,
teacherName(effectiveTeacherId) || state.source.teacherName || `Преподаватель ${effectiveTeacherId}`
);
ensureSelectOption(
classroomSelect,
effectiveClassroomId,
classroomName(effectiveClassroomId) || state.source.classroomName || `Аудитория ${effectiveClassroomId}`
);
teacherSelect.value = String(effectiveTeacherId);
classroomSelect.value = String(effectiveClassroomId);
teacherCombobox.syncFromSelect();
classroomCombobox.syncFromSelect();
formatSelect.value = existing?.newLessonFormat || state.source.lessonFormat || 'Очно';
commentInput.value = existing?.comment || '';
}
async function selectDateFromButton(event) {
const button = event.target.closest('[data-override-date]');
if (!button || button.disabled || button.dataset.overrideDate === state.targetDate) return;
state.targetDate = button.dataset.overrideDate;
renderDateChoices();
await loadTimeSlots({
id: state.source.timeSlotId,
startTime: state.source.startTime,
endTime: state.source.endTime
});
updateComparison();
}
function renderDateChoices() {
const available = new Set(state.availability?.availableDates || []);
week.innerHTML = weekDates(state.source.lessonDate).map((date, index) => dateButton(date, DAY_LABELS[index], available)).join('');
calendar.innerHTML = renderSemesterCalendar(
state.availability.semesterStartDate,
state.availability.semesterEndDate,
available,
state.targetDate
);
}
function dateButton(date, label, available) {
const selected = date === state.targetDate ? ' active' : '';
const disabled = available.has(date) ? '' : ' disabled';
return `
<button type="button" class="schedule-override-date${selected}" data-override-date="${escapeHtml(date)}"${disabled}>
<span>${escapeHtml(label)}</span>
<strong>${escapeHtml(formatDateShort(date))}</strong>
</button>
`;
}
async function loadTimeSlots(preference) {
timeSelect.disabled = true;
timeSelect.innerHTML = '<option value="">Загрузка...</option>';
state.timeSlots = await api.get(
`/api/admin/time-slots/effective?date=${encodeURIComponent(state.targetDate)}`
);
const selected = pickMatchingTimeSlot(
state.timeSlots,
preference?.id,
preference?.startTime,
preference?.endTime
);
timeSelect.innerHTML = '<option value="">Выберите время</option>' + state.timeSlots.map(slot =>
`<option value="${escapeHtml(slot.id)}">${escapeHtml(timeSlotLabel(slot))}</option>`
).join('');
timeSelect.value = selected ? String(selected.id) : '';
timeSelect.disabled = false;
}
function renderSource() {
document.getElementById('schedule-override-before-title').textContent =
`${state.source.subjectName} · ${state.source.lessonTypeName}`;
document.getElementById('schedule-override-before-meta').textContent = [
formatDate(state.source.lessonDate),
intervalLabel(state.source.startTime, state.source.endTime),
state.source.teacherName,
state.source.classroomName,
state.source.lessonFormat,
state.source.groupNames.join(', ')
].filter(Boolean).join(' · ');
}
function updateComparison() {
if (!state.source) return;
const selectedTime = state.timeSlots.find(slot => String(slot.id) === timeSelect.value);
document.getElementById('schedule-override-after-title').textContent =
`${state.source.subjectName} · ${state.source.lessonTypeName}`;
document.getElementById('schedule-override-after-meta').textContent = [
formatDate(state.targetDate),
selectedTime ? intervalLabel(selectedTime.startTime, selectedTime.endTime) : 'Время не выбрано',
teacherName(teacherSelect.value),
classroomName(classroomSelect.value),
formatSelect.value,
state.source.groupNames.join(', ')
].filter(Boolean).join(' · ');
}
async function save(event) {
event.preventDefault();
if (state.busy) return;
hideAlert('schedule-override-alert');
const payload = buildPayload();
if (!payload) return;
await mutate(async () => {
if (state.selectedOverride) {
await api.put(`/api/edu-office/schedule/overrides/${state.selectedOverride.id}`, payload);
} else {
await api.post('/api/edu-office/schedule/overrides', payload);
}
});
}
function buildPayload() {
if (!timeSelect.value) {
showAlert('schedule-override-alert', 'Выберите время занятия для выбранной даты', 'error');
return null;
}
const dateChanged = state.targetDate !== state.source.lessonDate;
const timeChanged = String(timeSelect.value) !== String(state.source.timeSlotId);
const teacherChanged = String(teacherSelect.value) !== String(state.source.teacherId);
const classroomChanged = String(classroomSelect.value) !== String(state.source.classroomId);
const formatChanged = formatSelect.value !== state.source.lessonFormat;
const action = overrideActionForChanges({
dateChanged,
timeChanged,
teacherChanged,
classroomChanged,
formatChanged
});
if (!action) {
showAlert(
'schedule-override-alert',
state.selectedOverride
? 'Параметры совпадают с правилом. Используйте «Вернуть по правилу»'
: 'Измените дату, время, преподавателя, аудиторию или формат',
'error'
);
return null;
}
return {
baseRuleSlotId: state.source.baseRuleSlotId,
lessonDate: state.source.lessonDate,
targetLessonDate: dateChanged ? state.targetDate : null,
action,
newTimeSlotId: (dateChanged || timeChanged) ? Number(timeSelect.value) : null,
newTeacherId: teacherChanged ? Number(teacherSelect.value) : null,
newClassroomId: classroomChanged ? Number(classroomSelect.value) : null,
newLessonFormat: formatChanged ? formatSelect.value : null,
comment: commentInput.value.trim() || null
};
}
async function cancelLesson() {
if (state.busy || !state.source) return;
const payload = {
baseRuleSlotId: state.source.baseRuleSlotId,
lessonDate: state.source.lessonDate,
targetLessonDate: null,
action: 'CANCEL',
newTimeSlotId: null,
newTeacherId: null,
newClassroomId: null,
newLessonFormat: null,
comment: commentInput.value.trim() || null
};
await mutate(async () => {
if (state.selectedOverride) {
await api.put(`/api/edu-office/schedule/overrides/${state.selectedOverride.id}`, payload);
} else {
await api.post('/api/edu-office/schedule/overrides', payload);
}
});
}
async function restoreRule() {
if (state.busy || !state.selectedOverride) return;
await mutate(() => api.delete(`/api/edu-office/schedule/overrides/${state.selectedOverride.id}`));
}
async function mutate(operation) {
setBusy(true);
hideAlert('schedule-override-alert');
try {
await operation();
await onChanged?.();
state.selectedLesson = null;
state.selectedOverride = null;
state.source = null;
form.hidden = true;
empty.hidden = false;
empty.textContent = 'Выберите занятие в расписании или откройте его из реестра изменений.';
openRegistry();
} catch (error) {
showAlert('schedule-override-alert', error.message || 'Не удалось сохранить разовое изменение', 'error');
} finally {
setBusy(false);
}
}
function setBusy(busy) {
state.busy = busy;
saveButton.disabled = busy;
cancelButton.disabled = busy;
restoreButton.disabled = busy;
saveButton.textContent = busy ? 'Сохранение...' : 'Сохранить';
}
function renderRegistry() {
if (!registry) return;
if (!state.overrides.length) {
registry.innerHTML = '<div class="schedule-override-empty">За выбранный период разовых изменений нет.</div>';
return;
}
registry.innerHTML = state.overrides.map(override => {
const target = override.targetLessonDate
? ` → ${formatDate(override.targetLessonDate)}`
: '';
const result = override.action === 'CANCEL'
? 'Занятие отменено'
: [
override.newTimeSlotId ? `слот ${override.newTimeSlotId}` : null,
teacherName(override.newTeacherId),
classroomName(override.newClassroomId),
override.newLessonFormat
].filter(Boolean).join(' · ');
return `
<article class="schedule-override-registry-item">
<div class="schedule-override-registry-topline">
<span class="schedule-override-action action-${escapeHtml(override.action.toLowerCase())}">${escapeHtml(ACTION_LABELS[override.action] || override.action)}</span>
<time>${escapeHtml(formatDate(override.lessonDate))}${escapeHtml(target)}</time>
</div>
<h4>${escapeHtml(override.subjectName || 'Занятие')}</h4>
<p>${escapeHtml([
override.lessonTypeName,
(override.groupNames || []).join(', '),
result || 'Параметры изменены'
].filter(Boolean).join(' · '))}</p>
${override.comment ? `<blockquote>${escapeHtml(override.comment)}</blockquote>` : ''}
<button type="button" class="btn btn-md btn-secondary" data-override-registry-id="${escapeHtml(override.id)}">Открыть</button>
</article>
`;
}).join('');
}
function activateTab(tab) {
drawer.querySelectorAll('[data-override-tab]').forEach(button => {
button.classList.toggle('active', button.dataset.overrideTab === tab);
});
drawer.querySelectorAll('[data-override-section]').forEach(section => {
section.hidden = section.dataset.overrideSection !== tab;
});
}
function open() {
drawer.hidden = false;
drawer.setAttribute('aria-hidden', 'false');
document.body.classList.add('schedule-override-open');
}
function close() {
drawer.hidden = true;
drawer.setAttribute('aria-hidden', 'true');
document.body.classList.remove('schedule-override-open');
}
function teacherName(id) {
if (id == null || id === '') return '';
const teacher = (dictionaries.teachers || []).find(item => String(item.id) === String(id));
return teacher?.fullName || teacher?.username || '';
}
function classroomName(id) {
if (id == null || id === '') return '';
const classroom = (dictionaries.classrooms || []).find(item => String(item.id) === String(id));
return classroom ? classroomOptionLabel(classroom) : '';
}
function mergeDictionary(key, items) {
const current = new Map((dictionaries[key] || []).map(item => [String(item.id), item]));
(items || []).forEach(item => current.set(String(item.id), item));
dictionaries[key] = [...current.values()];
}
}
function renderSemesterCalendar(startDate, endDate, available, selectedDate) {
if (!startDate || !endDate) return '';
const start = parseIsoDate(startDate);
const end = parseIsoDate(endDate);
const months = [];
for (let cursor = new Date(start.getFullYear(), start.getMonth(), 1);
cursor <= end;
cursor = new Date(cursor.getFullYear(), cursor.getMonth() + 1, 1)) {
months.push(renderMonth(cursor, startDate, endDate, available, selectedDate));
}
return months.join('');
}
function renderMonth(month, semesterStart, semesterEnd, available, selectedDate) {
const year = month.getFullYear();
const monthIndex = month.getMonth();
const firstDay = new Date(year, monthIndex, 1).getDay() || 7;
const dayCount = new Date(year, monthIndex + 1, 0).getDate();
const blanks = Array.from({ length: firstDay - 1 }, () => '<span class="schedule-override-calendar-blank"></span>').join('');
const days = Array.from({ length: dayCount }, (_, index) => {
const date = toIsoDate(new Date(year, monthIndex, index + 1));
const enabled = date >= semesterStart && date <= semesterEnd && available.has(date);
const active = date === selectedDate ? ' active' : '';
return `<button type="button" class="schedule-override-calendar-day${active}" data-override-date="${date}"${enabled ? '' : ' disabled'}>${index + 1}</button>`;
}).join('');
return `
<section class="schedule-override-month">
<h4>${MONTH_LABELS[monthIndex]} ${year}</h4>
<div class="schedule-override-calendar-grid">
${DAY_LABELS.map(label => `<strong>${label}</strong>`).join('')}
${blanks}${days}
</div>
</section>
`;
}
function timeSlotLabel(slot) {
const order = slot.orderNumber ? `${slot.orderNumber} пара · ` : '';
return `${order}${intervalLabel(slot.startTime, slot.endTime)}`;
}
function intervalLabel(start, end) {
return `${trimTime(start)}–${trimTime(end)}`;
}
export function classroomOptionLabel(room) {
return room?.name || '';
}
function ensureSelectOption(select, value, label) {
if (value == null || value === '') return;
const exists = Array.from(select.options).some(option => String(option.value) === String(value));
if (exists) return;
const option = document.createElement('option');
option.value = String(value);
option.textContent = label;
select.append(option);
}
function formatDate(value) {
if (!value) return '—';
const [year, month, day] = String(value).split('-');
return `${day}.${month}.${year}`;
}
function formatDateShort(value) {
const [, month, day] = String(value).split('-');
return `${day}.${month}`;
}
function trimTime(value) {
return String(value || '').slice(0, 5);
}
function parseIsoDate(value) {
const [year, month, day] = String(value).split('-').map(Number);
return new Date(year, month - 1, day);
}
function addDays(date, days) {
const next = new Date(date);
next.setDate(next.getDate() + days);
return next;
}
function toIsoDate(date) {
return [
date.getFullYear(),
String(date.getMonth() + 1).padStart(2, '0'),
String(date.getDate()).padStart(2, '0')
].join('-');
}

View File

@@ -1,406 +0,0 @@
import { api } from '../api.js';
import {
academicYearsFromSemesters,
resolveAcademicPeriod,
semestersForAcademicYear
} from '../academic-period.js';
import { escapeHtml } from '../utils.js';
import { trackUxEvent } from '../../../telemetry.js';
let qualityState = null;
export async function initScheduleQuality() {
const root = document.querySelector('.quality-lab');
if (!root) return;
qualityState = {
root,
analysis: null,
semesters: [],
academicYearId: '',
versions: [],
visibleProblems: [],
selectedProblemId: null,
applying: false
};
root.querySelector('#quality-run')?.addEventListener('click', runAnalysis);
root.querySelector('#quality-year')?.addEventListener('change', handleYearChange);
root.querySelector('#quality-semester')?.addEventListener('change', loadVersions);
root.querySelector('#quality-severity-filter')?.addEventListener('change', renderProblems);
root.querySelector('#quality-type-filter')?.addEventListener('change', renderProblems);
root.querySelector('#quality-problems')?.addEventListener('click', handleProblemClick);
root.querySelector('#quality-recommendations')?.addEventListener('click', handleRulePanelClick);
await loadSemesters();
}
async function loadSemesters() {
const select = qualityState?.root.querySelector('#quality-semester');
if (!select) return;
try {
qualityState.semesters = await api.get('/api/schedule/semesters');
if (!qualityState.semesters?.length) {
renderPeriodSelects('', '');
qualityStatus('Сначала создайте учебный год и семестр.', 'warning');
return;
}
const selected = resolveAcademicPeriod(qualityState.semesters, {
academicYearId: localStorage.getItem('magistr.quality.yearId'),
semesterId: localStorage.getItem('magistr.quality.semesterId'),
date: localIsoDate(new Date())
});
renderPeriodSelects(selected.academicYearId, selected.semesterId);
await loadVersions();
} catch (error) {
select.innerHTML = '<option value="">Ошибка загрузки</option>';
qualityStatus(error.message || 'Не удалось загрузить семестры.', 'error');
}
}
async function handleYearChange() {
const yearId = qualityState?.root.querySelector('#quality-year')?.value || '';
const selected = resolveAcademicPeriod(qualityState.semesters, {
academicYearId: yearId,
date: localIsoDate(new Date())
});
renderPeriodSelects(yearId, selected.semesterId);
await loadVersions();
}
function renderPeriodSelects(academicYearId, semesterId) {
const yearSelect = qualityState?.root.querySelector('#quality-year');
const semesterSelect = qualityState?.root.querySelector('#quality-semester');
if (!yearSelect || !semesterSelect) return;
const years = academicYearsFromSemesters(qualityState.semesters);
qualityState.academicYearId = String(academicYearId || years[0]?.id || '');
yearSelect.innerHTML = years.length
? years.map(year => `<option value="${escapeHtml(year.id)}">${escapeHtml(year.title)}</option>`).join('')
: '<option value="">Учебные годы не настроены</option>';
yearSelect.value = qualityState.academicYearId;
const yearSemesters = semestersForAcademicYear(qualityState.semesters, qualityState.academicYearId);
semesterSelect.innerHTML = yearSemesters.length
? yearSemesters.map(semester => `
<option value="${escapeHtml(String(semester.id))}">${escapeHtml(semesterLabel(semester))}</option>
`).join('')
: '<option value="">Семестры не настроены</option>';
const selectedSemester = yearSemesters.find(semester => String(semester.id) === String(semesterId))
|| yearSemesters[0];
semesterSelect.value = String(selectedSemester?.id || '');
localStorage.setItem('magistr.quality.yearId', qualityState.academicYearId);
}
async function loadVersions() {
const semesterId = qualityState?.root.querySelector('#quality-semester')?.value;
const select = qualityState?.root.querySelector('#quality-version');
if (!select || !semesterId) return;
select.innerHTML = '<option value="">Загрузка версий…</option>';
try {
qualityState.versions = await api.get(
`/api/edu-office/schedule/versions?semesterId=${encodeURIComponent(semesterId)}`
);
select.innerHTML = qualityState.versions.map(version => `
<option value="${escapeHtml(String(version.id))}">
v${escapeHtml(String(version.versionNumber))} · ${escapeHtml(version.name)} · ${escapeHtml(versionStatusLabel(version.status))}
</option>
`).join('');
if (!qualityState.versions.length) {
select.innerHTML = '<option value="">Версии не созданы</option>';
return;
}
const savedVersionId = localStorage.getItem('magistr.quality.versionId');
const saved = qualityState.versions.find(version => String(version.id) === savedVersionId);
const published = qualityState.versions.find(version => version.status === 'PUBLISHED');
select.value = String((saved || published || qualityState.versions[0]).id);
localStorage.setItem('magistr.quality.yearId', qualityState.academicYearId);
localStorage.setItem('magistr.quality.semesterId', semesterId);
} catch (error) {
select.innerHTML = '<option value="">Ошибка загрузки</option>';
qualityStatus(error.message || 'Не удалось загрузить версии расписания.', 'error');
}
}
async function runAnalysis() {
const semesterId = qualityState?.root.querySelector('#quality-semester')?.value;
const versionId = qualityState?.root.querySelector('#quality-version')?.value;
if (!semesterId) {
qualityStatus('Выберите семестр для анализа.', 'warning');
return;
}
if (!versionId) {
qualityStatus('Выберите версию расписания для анализа.', 'warning');
return;
}
localStorage.setItem('magistr.quality.semesterId', semesterId);
localStorage.setItem('magistr.quality.versionId', versionId);
const button = qualityState.root.querySelector('#quality-run');
button.disabled = true;
button.textContent = 'Анализируем…';
qualityStatus('Строим фактическое расписание и рассчитываем показатели…', 'loading');
qualityState.root.querySelector('#quality-results').hidden = true;
try {
qualityState.analysis = await api.get(
`/api/edu-office/schedule/quality?semesterId=${encodeURIComponent(semesterId)}&versionId=${encodeURIComponent(versionId)}`
);
qualityState.selectedProblemId = null;
renderAnalysis();
trackUxEvent('качество_расписания_проверено', {
semesterId,
versionId,
score: Number(qualityState.analysis.score || 0)
});
qualityStatus(
qualityState.analysis.scheduleVersionStatus === 'DRAFT'
? 'Черновик проверен. Проблемные правила можно открыть в конструкторе до публикации.'
: 'Анализ завершён. Проблемные правила можно открыть в конструкторе расписания.',
'success'
);
} catch (error) {
qualityStatus(error.message || 'Не удалось выполнить анализ качества.', 'error');
} finally {
button.disabled = false;
button.textContent = 'Выполнить анализ';
}
}
function renderAnalysis() {
const analysis = qualityState?.analysis;
if (!analysis) return;
qualityState.root.querySelector('#quality-results').hidden = false;
const band = qualityBand(analysis.score);
const orbit = qualityState.root.querySelector('#quality-score-orbit');
orbit.dataset.band = band.code;
qualityState.root.querySelector('#quality-score-ring-value')
.setAttribute('stroke-dasharray', `${Math.max(0, Math.min(100, analysis.score))} 100`);
qualityState.root.querySelector('#quality-score').textContent = String(analysis.score);
qualityState.root.querySelector('#quality-score-band').textContent = band.label;
qualityState.root.querySelector('#quality-score-band').dataset.band = band.code;
qualityState.root.querySelector('#quality-semester-label').textContent = analysis.semesterLabel;
qualityState.root.querySelector('#quality-score-summary').textContent = band.summary;
qualityState.root.querySelector('#quality-score-meta').innerHTML = `
<span>${escapeHtml(versionStatusLabel(analysis.scheduleVersionStatus))} · v${escapeHtml(String(analysis.scheduleVersionNumber || '—'))}</span>
<span>${escapeHtml(String(analysis.lessonCount))} занятий</span>
<span>правил с проблемами: ${escapeHtml(String(analysis.totalProblemCount))}</span>
<span>штраф ${escapeHtml(String(analysis.rawPenalty))}</span>
`;
qualityState.root.querySelector('#quality-metrics').innerHTML = (analysis.metrics || [])
.map((metric, index) => `
<article class="quality-metric quality-metric-${index + 1}">
<span class="quality-metric-index">${String(index + 1).padStart(2, '0')}</span>
<strong>${escapeHtml(formatMetricValue(metric))}</strong>
<h4>${escapeHtml(metric.label)}</h4>
<p>${escapeHtml(metric.explanation)}</p>
<span class="quality-metric-penalty">штраф ${escapeHtml(String(metric.penalty))}</span>
</article>
`).join('');
const totalLabel = analysis.problemsTruncated
? `Показаны первые ${analysis.problems.length} из ${analysis.totalProblemCount} правил`
: `Правил с проблемами: ${analysis.totalProblemCount}`;
qualityState.root.querySelector('#quality-problem-total').textContent = totalLabel;
renderProblems();
renderRulePanelEmpty();
}
function renderProblems() {
const container = qualityState?.root.querySelector('#quality-problems');
if (!container || !qualityState.analysis) return;
const severity = qualityState.root.querySelector('#quality-severity-filter')?.value || '';
const category = qualityState.root.querySelector('#quality-type-filter')?.value || '';
qualityState.visibleProblems = filterQualityProblems(
qualityState.analysis.problems || [], severity, category
);
if (!qualityState.visibleProblems.length) {
container.innerHTML = `
<div class="quality-empty-state">
<strong>По выбранному фильтру проблем нет</strong>
<span>Измените уровень важности или категорию.</span>
</div>
`;
return;
}
container.innerHTML = qualityState.visibleProblems.map((problem, index) => {
const selected = problem.id === qualityState.selectedProblemId;
const affectedLessonCount = Number(problem.affectedLessonCount || 1);
const issueLabels = problem.issueLabels?.length ? problem.issueLabels : [problem.title];
const canEdit = qualityState.analysis.scheduleVersionStatus !== 'ARCHIVED' && problem.scheduleRuleId;
return `
<article class="quality-problem ${selected ? 'is-selected' : ''}" data-severity="${escapeHtml(problem.severity)}">
<button type="button" class="quality-problem-main" data-problem-index="${index}">
<span class="quality-severity">${escapeHtml(severityLabel(problem.severity))}</span>
<span class="quality-problem-copy">
<strong>${escapeHtml(problem.title)}</strong>
<span>${escapeHtml(problem.description)}</span>
<span class="quality-rule-issues">${issueLabels.map(label => `
<span>${escapeHtml(label)}</span>
`).join('')}</span>
<small>
Правило #${escapeHtml(String(problem.scheduleRuleId || '—'))}
· затронуто ${escapeHtml(String(affectedLessonCount))} ${escapeHtml(lessonWord(affectedLessonCount))}
· суммарный штраф
</small>
</span>
<span class="quality-penalty">−${escapeHtml(String(problem.penalty))}</span>
</button>
<div class="quality-problem-action">
${canEdit
? `<button type="button" class="btn btn-sm btn-secondary" data-problem-index="${index}" data-action="edit-rule">
Изменить правило
</button>`
: '<span>Только просмотр</span>'}
</div>
</article>
`;
}).join('');
}
function handleProblemClick(event) {
const button = event.target.closest('[data-problem-index]');
if (!button) return;
const problem = qualityState?.visibleProblems[Number(button.dataset.problemIndex)];
if (!problem) return;
if (button.dataset.action === 'edit-rule') {
openRuleInConstructor(problem);
return;
}
qualityState.selectedProblemId = problem.id;
renderProblems();
renderRuleDetails(problem);
}
function renderRulePanelEmpty() {
const archived = qualityState?.analysis?.scheduleVersionStatus === 'ARCHIVED';
renderRulePanelMessage(
archived ? 'Архивная версия' : 'Работа с правилом',
archived
? 'Правила этой версии доступны только для просмотра. Для правок создайте новый черновик.'
: 'Выберите правило, чтобы увидеть его суммарное влияние и открыть в конструкторе.'
);
}
function renderRuleDetails(problem) {
const panel = qualityState?.root.querySelector('#quality-recommendations');
if (!panel) return;
const affectedLessonCount = Number(problem.affectedLessonCount || 1);
const issueLabels = problem.issueLabels?.length ? problem.issueLabels : [problem.title];
const canEdit = qualityState.analysis.scheduleVersionStatus !== 'ARCHIVED' && problem.scheduleRuleId;
panel.innerHTML = `
<div class="quality-recommendations-head">
<span class="quality-kicker">Правило #${escapeHtml(String(problem.scheduleRuleId || '—'))}</span>
<h3>${escapeHtml(problem.subjectName || problem.title)}</h3>
<p>Затронуто ${escapeHtml(String(affectedLessonCount))} ${escapeHtml(lessonWord(affectedLessonCount))}; суммарный штраф −${escapeHtml(String(problem.penalty))}.</p>
</div>
<div class="quality-rule-detail-list">
${issueLabels.map(label => `<span>${escapeHtml(label)}</span>`).join('')}
</div>
${canEdit ? `
<button type="button" class="btn btn-md btn-primary quality-edit-rule" data-edit-rule-id="${escapeHtml(String(problem.scheduleRuleId))}">
Открыть правило в конструкторе
</button>
` : '<p class="quality-recommendations-note">Архивное расписание нельзя изменить.</p>'}
`;
}
function handleRulePanelClick(event) {
const button = event.target.closest('[data-edit-rule-id]');
if (!button) return;
const problem = (qualityState?.analysis?.problems || [])
.find(item => String(item.scheduleRuleId) === button.dataset.editRuleId);
if (problem) openRuleInConstructor(problem);
}
function openRuleInConstructor(problem) {
if (!problem?.scheduleRuleId || qualityState?.analysis?.scheduleVersionStatus === 'ARCHIVED') return;
localStorage.setItem('magistr.schedule.semesterId', String(qualityState.analysis.semesterId));
localStorage.setItem('magistr.schedule.openVersionId', String(qualityState.analysis.scheduleVersionId));
localStorage.setItem('magistr.schedule.openRuleId', String(problem.scheduleRuleId));
document.querySelector('.nav-item[data-tab="schedule"]')?.click();
}
function renderRulePanelMessage(title, message) {
const panel = qualityState?.root.querySelector('#quality-recommendations');
if (!panel) return;
panel.innerHTML = `
<div class="quality-recommendations-empty">
<span class="quality-recommendations-mark">↗</span>
<h3>${escapeHtml(title)}</h3>
<p>${escapeHtml(message)}</p>
</div>
`;
}
function qualityStatus(message, tone = '') {
const status = qualityState?.root.querySelector('#quality-status');
if (!status) return;
status.textContent = message;
status.dataset.tone = tone;
}
export function filterQualityProblems(problems, severity = '', category = '') {
return (problems || []).filter(problem => {
const severityMatches = !severity || problem.severity === severity;
const problemTypes = problem.problemTypes?.length ? problem.problemTypes : [problem.type];
const categoryMatches = !category || problemTypes.some(type => problemCategory(type) === category);
return severityMatches && categoryMatches;
}).sort((a, b) => (Number(b.penalty) || 0) - (Number(a.penalty) || 0));
}
function problemCategory(type = '') {
if (type.includes('GAP') || type.includes('COMPACTNESS')) return 'GAPS';
if (type.includes('LOAD')) return 'LOAD';
if (type.includes('ROOM')) return 'ROOM';
return 'PREFERENCE';
}
export function qualityBand(score) {
if (score >= 90) {
return { code: 'excellent', label: 'Сильное расписание', summary: 'Найдены только локальные неудобства с небольшим влиянием.' };
}
if (score >= 75) {
return { code: 'stable', label: 'Устойчивое расписание', summary: 'Расписание работоспособно, но несколько перестановок заметно улучшат комфорт.' };
}
if (score >= 55) {
return { code: 'attention', label: 'Нужна доработка', summary: 'Мягкие ограничения и распределение нагрузки требуют внимания учебного отдела.' };
}
return { code: 'critical', label: 'Высокая цена неудобств', summary: 'Сначала разберите критические проблемы и перегруженные дни.' };
}
export function formatMetricValue(metric) {
const value = Number(metric?.value || 0);
const formatted = Number.isInteger(value)
? String(value)
: value.toLocaleString('ru-RU', { maximumFractionDigits: 1 });
return `${formatted}${metric?.unit === '%' ? '%' : metric?.unit ? ` ${metric.unit}` : ''}`;
}
function severityLabel(severity) {
return { CRITICAL: 'Критично', HIGH: 'Высокая', MEDIUM: 'Средняя', LOW: 'Низкая' }[severity] || severity;
}
function semesterLabel(semester) {
const type = semester.semesterType === 'autumn' ? 'Осенний' : 'Весенний';
return `${semester.academicYearTitle} · ${type} · ${formatDate(semester.startDate)}—${formatDate(semester.endDate)}`;
}
function formatDate(value) {
if (!value) return 'дата не указана';
const [year, month, day] = String(value).split('-');
return `${day}.${month}.${year}`;
}
function localIsoDate(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
function lessonWord(count) {
const mod100 = count % 100;
const mod10 = count % 10;
if (mod100 >= 11 && mod100 <= 14) return 'занятий';
if (mod10 === 1) return 'занятие';
if (mod10 >= 2 && mod10 <= 4) return 'занятия';
return 'занятий';
}
export function versionStatusLabel(status) {
return { DRAFT: 'Черновик', PUBLISHED: 'Опубликована', ARCHIVED: 'Архив' }[status] || 'Версия';
}

View File

@@ -1,462 +0,0 @@
import { api } from '../api.js';
import {
academicYearsFromSemesters,
resolveAcademicPeriod,
semestersForAcademicYear
} from '../academic-period.js';
import { escapeHtml } from '../utils.js';
import { trackUxEvent } from '../../../telemetry.js';
let versionState = null;
export async function initScheduleVersions() {
const root = document.querySelector('.version-studio');
if (!root) return;
versionState = {
root,
semesters: [],
academicYearId: '',
versions: [],
history: [],
modal: null,
loading: false
};
bindEvents();
await loadSemesters();
}
function bindEvents() {
const root = versionState.root;
root.querySelector('#version-year')?.addEventListener('change', handleYearChange);
root.querySelector('#version-semester')?.addEventListener('change', loadWorkspace);
root.querySelector('#version-create')?.addEventListener('click', () => openCreateModal());
root.querySelector('#version-drafts')?.addEventListener('click', handleVersionAction);
root.querySelector('#version-archive')?.addEventListener('click', handleVersionAction);
root.querySelector('#version-current')?.addEventListener('click', handleVersionAction);
root.querySelector('#version-modal-close')?.addEventListener('click', closeModal);
root.querySelector('#version-modal-cancel')?.addEventListener('click', closeModal);
root.querySelector('#version-modal')?.addEventListener('click', event => {
if (event.target.id === 'version-modal') closeModal();
});
root.querySelector('#version-modal-form')?.addEventListener('submit', submitModal);
document.addEventListener('keydown', event => {
if (event.key === 'Escape' && !root.querySelector('#version-modal')?.hidden) closeModal();
});
}
async function loadSemesters() {
try {
versionState.semesters = await api.get('/api/schedule/semesters');
if (!versionState.semesters.length) {
renderPeriodSelects('', '');
setStatus('Сначала создайте учебный год и семестр.', 'error');
return;
}
const selected = resolveAcademicPeriod(versionState.semesters, {
academicYearId: localStorage.getItem('magistr.schedule.yearId'),
semesterId: localStorage.getItem('magistr.schedule.semesterId'),
date: localIsoDate(new Date())
});
renderPeriodSelects(selected.academicYearId, selected.semesterId);
await loadWorkspace();
} catch (error) {
setStatus(error.message || 'Не удалось загрузить семестры.', 'error');
}
}
async function handleYearChange() {
const yearId = versionState.root.querySelector('#version-year')?.value || '';
const selected = resolveAcademicPeriod(versionState.semesters, {
academicYearId: yearId,
date: localIsoDate(new Date())
});
renderPeriodSelects(yearId, selected.semesterId);
await loadWorkspace();
}
function renderPeriodSelects(academicYearId, semesterId) {
const yearSelect = versionState.root.querySelector('#version-year');
const semesterSelect = versionState.root.querySelector('#version-semester');
const years = academicYearsFromSemesters(versionState.semesters);
versionState.academicYearId = String(academicYearId || years[0]?.id || '');
yearSelect.innerHTML = years.length
? years.map(year => `<option value="${escapeHtml(year.id)}">${escapeHtml(year.title)}</option>`).join('')
: '<option value="">Учебные годы не настроены</option>';
yearSelect.value = versionState.academicYearId;
const yearSemesters = semestersForAcademicYear(versionState.semesters, versionState.academicYearId);
semesterSelect.innerHTML = yearSemesters.length
? yearSemesters.map(semester => `
<option value="${escapeHtml(String(semester.id))}">${escapeHtml(semesterLabel(semester))}</option>
`).join('')
: '<option value="">Семестры не настроены</option>';
const selectedSemester = yearSemesters.find(semester => String(semester.id) === String(semesterId))
|| yearSemesters[0];
semesterSelect.value = String(selectedSemester?.id || '');
localStorage.setItem('magistr.schedule.yearId', versionState.academicYearId);
}
async function loadWorkspace() {
const semesterId = selectedSemesterId();
if (!semesterId || versionState.loading) return;
versionState.loading = true;
localStorage.setItem('magistr.schedule.yearId', versionState.academicYearId);
localStorage.setItem('magistr.schedule.semesterId', semesterId);
setStatus('Собираем версии, правила и журнал публикаций…', 'loading');
try {
[versionState.versions, versionState.history] = await Promise.all([
api.get(`/api/edu-office/schedule/versions?semesterId=${encodeURIComponent(semesterId)}`),
api.get(`/api/edu-office/schedule/versions/history?semesterId=${encodeURIComponent(semesterId)}`)
]);
renderWorkspace();
trackUxEvent('версии_расписания_загружены', { semesterId, total: versionState.versions.length });
setStatus('Контур версий синхронизирован.', 'success');
} catch (error) {
setStatus(error.message || 'Не удалось загрузить версии расписания.', 'error');
} finally {
versionState.loading = false;
}
}
function renderWorkspace() {
const published = versionState.versions.find(version => version.status === 'PUBLISHED');
const drafts = versionState.versions.filter(version => version.status === 'DRAFT');
const archive = versionState.versions.filter(version => version.status === 'ARCHIVED');
renderCurrent(published);
renderDrafts(drafts);
renderArchive(archive);
renderHistory(versionState.history);
}
function renderCurrent(version) {
const container = versionState.root.querySelector('#version-current');
if (!version) {
container.innerHTML = `
<div class="version-live-mark">ПУСТО</div>
<div><h3>Опубликованной версии нет</h3><p>Создайте первый черновик и опубликуйте его после проверки.</p></div>
`;
return;
}
container.innerHTML = `
<div class="version-live-mark">LIVE</div>
<div>
<span class="version-eyebrow">Сейчас видят пользователи</span>
<h3>${escapeHtml(version.name)} · версия ${escapeHtml(String(version.versionNumber))}</h3>
<p>${escapeHtml(version.changeReason || 'Причина первой публикации не указана')}</p>
<div class="version-stats">
<span>${escapeHtml(String(version.ruleCount))} правил</span>
<span>${escapeHtml(String(version.slotCount))} слотов</span>
<span>${escapeHtml(formatDateTime(version.publishedAt))}</span>
</div>
</div>
<div class="version-card-actions">
<button type="button" class="btn btn-sm btn-primary" data-action="edit" data-id="${version.id}">Редактировать</button>
<button type="button" class="btn btn-sm btn-secondary" data-action="quality" data-id="${version.id}">Проверить качество</button>
</div>
`;
}
function renderDrafts(drafts) {
const container = versionState.root.querySelector('#version-drafts');
versionState.root.querySelector('#version-draft-count').textContent = String(drafts.length);
if (!drafts.length) {
container.innerHTML = `
<div class="version-empty">
<span>+</span><h3>Нет активных черновиков</h3>
<p>Создайте копию опубликованной версии — её изменения останутся невидимыми до публикации.</p>
</div>
`;
return;
}
container.innerHTML = drafts.map(version => `
<article class="version-card" data-number="${escapeHtml(String(version.versionNumber).padStart(2, '0'))}">
<div class="version-card-top">
<div>
<span class="version-status-chip">Черновик · v${escapeHtml(String(version.versionNumber))}</span>
<h4>${escapeHtml(version.name)}</h4>
</div>
</div>
<p>Основа: ${version.basedOnVersionId ? `версия #${escapeHtml(String(version.basedOnVersionId))}` : 'пустое расписание'}</p>
<small>${escapeHtml(String(version.ruleCount))} правил · ${escapeHtml(String(version.slotCount))} слотов · ${escapeHtml(formatDateTime(version.createdAt))}</small>
<div class="version-card-actions">
<button type="button" class="btn btn-sm btn-primary" data-action="edit" data-id="${version.id}">Редактировать</button>
<button type="button" class="btn btn-sm btn-secondary" data-action="diff" data-id="${version.id}">Сравнить</button>
<button type="button" class="btn btn-sm btn-secondary" data-action="quality" data-id="${version.id}">Качество</button>
<button type="button" class="btn btn-sm btn-secondary" data-action="publish" data-id="${version.id}">Опубликовать</button>
</div>
</article>
`).join('');
}
function renderArchive(versions) {
const container = versionState.root.querySelector('#version-archive');
if (!versions.length) {
container.innerHTML = '<p class="version-empty-note">Предыдущих публикаций пока нет.</p>';
return;
}
container.innerHTML = versions.map(version => `
<article class="version-card" data-number="${escapeHtml(String(version.versionNumber).padStart(2, '0'))}">
<span class="version-status-chip">Архив · v${escapeHtml(String(version.versionNumber))}</span>
<h4>${escapeHtml(version.name)}</h4>
<p>${escapeHtml(version.changeReason || 'Причина не указана')}</p>
<small>${escapeHtml(String(version.ruleCount))} правил · публикация ${escapeHtml(formatDateTime(version.publishedAt))}</small>
<div class="version-card-actions">
<button type="button" class="btn btn-sm btn-secondary" data-action="diff" data-id="${version.id}">Сравнить</button>
<button type="button" class="btn btn-sm btn-secondary" data-action="quality" data-id="${version.id}">Качество</button>
<button type="button" class="btn btn-sm btn-danger" data-action="restore" data-id="${version.id}">Восстановить</button>
</div>
</article>
`).join('');
}
function renderHistory(history) {
const container = versionState.root.querySelector('#version-history');
if (!history.length) {
container.innerHTML = '<p class="version-empty-note">Действия ещё не зафиксированы.</p>';
return;
}
container.innerHTML = history.map(item => `
<article class="version-history-row">
<time>${escapeHtml(formatDateTime(item.createdAt))}</time>
<div>
<strong>${escapeHtml(historyActionLabel(item.action))}: v${escapeHtml(String(item.versionNumber))} · ${escapeHtml(item.versionName)}</strong>
<p>${escapeHtml(item.reason || 'Без комментария')}</p>
</div>
<span class="version-status-chip">${escapeHtml(item.actorName || 'Система')}</span>
</article>
`).join('');
}
async function handleVersionAction(event) {
const button = event.target.closest('[data-action][data-id]');
if (!button) return;
const version = versionState.versions.find(item => String(item.id) === button.dataset.id);
if (!version) return;
const action = button.dataset.action;
if (action === 'edit') {
localStorage.setItem('magistr.schedule.semesterId', String(version.semesterId));
localStorage.setItem('magistr.schedule.openVersionId', String(version.id));
navigateTo('schedule');
return;
}
if (action === 'quality') {
localStorage.setItem('magistr.quality.semesterId', String(version.semesterId));
localStorage.setItem('magistr.quality.versionId', String(version.id));
navigateTo('schedule-quality');
return;
}
if (action === 'diff') {
await loadDiff(version);
return;
}
if (action === 'publish') {
await openPublishModal(version);
return;
}
if (action === 'restore') {
openActionModal('restore', version);
}
}
async function loadDiff(version) {
const panel = versionState.root.querySelector('#version-diff');
panel.innerHTML = '<div class="version-empty"><span>…</span><h3>Сравниваем расписания</h3><p>Генерируем занятия обеих версий по всему семестру.</p></div>';
try {
const diff = await api.get(`/api/edu-office/schedule/versions/${version.id}/diff`);
renderDiff(diff);
} catch (error) {
panel.innerHTML = `<div class="version-empty"><span>!</span><h3>Сравнение недоступно</h3><p>${escapeHtml(error.message)}</p></div>`;
}
}
function renderDiff(diff) {
const panel = versionState.root.querySelector('#version-diff');
panel.innerHTML = `
<span class="version-eyebrow">${escapeHtml(diff.baseVersionLabel)} → ${escapeHtml(diff.targetVersionLabel)}</span>
<h3>Что изменится после публикации</h3>
<div class="version-diff-score">
<div><strong>+${escapeHtml(String(diff.addedLessonCount))}</strong><span>занятий</span></div>
<div><strong>${escapeHtml(String(diff.changedLessonCount))}</strong><span>изменено</span></div>
<div><strong>−${escapeHtml(String(diff.removedLessonCount))}</strong><span>удалено</span></div>
</div>
<p class="version-diff-caption">Правила: +${escapeHtml(String(diff.addedRuleCount))} · ~${escapeHtml(String(diff.changedRuleCount))} · −${escapeHtml(String(diff.removedRuleCount))} · без изменений ${escapeHtml(String(diff.unchangedRuleCount))}</p>
<div class="version-change-list">
${(diff.ruleChanges || []).length
? diff.ruleChanges.map(ruleChangeCard).join('')
: '<div class="version-empty"><h3>Правила совпадают</h3><p>Структурных изменений между версиями нет.</p></div>'}
</div>
`;
}
function ruleChangeCard(change) {
const label = { ADDED: 'Добавлено', REMOVED: 'Удалено', CHANGED: 'Изменено' }[change.type] || change.type;
return `
<article class="version-change" data-type="${escapeHtml(change.type)}">
<strong>${escapeHtml(label)} · ${escapeHtml(change.subjectName)}</strong>
<span>${escapeHtml((change.groupNames || []).join(', ') || 'Группы не указаны')}</span>
${change.beforeSummary ? `<span>Было: ${escapeHtml(change.beforeSummary)}</span>` : ''}
${change.afterSummary ? `<span>Стало: ${escapeHtml(change.afterSummary)}</span>` : ''}
</article>
`;
}
function openCreateModal() {
const published = versionState.versions.find(version => version.status === 'PUBLISHED');
openModal({
mode: 'create',
version: published,
kicker: 'Изолированная рабочая копия',
title: 'Создать новый черновик',
copy: published
? `Правила версии ${published.versionNumber} будут скопированы. Пользователи продолжат видеть текущую публикацию.`
: 'Будет создан пустой черновик для первого расписания семестра.',
label: 'Название черновика',
value: '',
submit: 'Создать черновик'
});
}
async function openPublishModal(version) {
setStatus('Проверяем целостность черновика перед публикацией…', 'loading');
try {
const [validation, diff] = await Promise.all([
api.get(`/api/edu-office/schedule/versions/${version.id}/validate`),
api.get(`/api/edu-office/schedule/versions/${version.id}/diff`)
]);
const summary = validation.valid
? `Проверка пройдена: ${validation.ruleCount} правил, ${validation.slotCount} слотов. Занятия: +${diff.addedLessonCount}, ~${diff.changedLessonCount}, −${diff.removedLessonCount}.`
: `Публикация заблокирована: ${(validation.issues || []).join('; ')}`;
openModal({
mode: 'publish',
version,
valid: validation.valid,
kicker: 'Атомарная публикация',
title: `Опубликовать версию ${version.versionNumber}`,
copy: 'После подтверждения текущая версия целиком уйдёт в архив, а этот черновик станет виден студентам и преподавателям.',
label: 'Причина изменений',
value: '',
submit: 'Опубликовать',
summary
});
setStatus(validation.valid ? 'Черновик готов к публикации.' : 'Черновик требует исправлений.', validation.valid ? 'success' : 'error');
} catch (error) {
setStatus(error.message || 'Не удалось проверить черновик.', 'error');
}
}
function openActionModal(mode, version) {
openModal({
mode,
version,
kicker: 'Контролируемый возврат',
title: `Восстановить версию ${version.versionNumber}`,
copy: 'Текущая публикация будет архивирована, а выбранная версия снова станет активной одной транзакцией.',
label: 'Причина восстановления',
value: '',
submit: 'Восстановить'
});
}
function openModal(config) {
versionState.modal = config;
const root = versionState.root;
root.querySelector('#version-modal-kicker').textContent = config.kicker;
root.querySelector('#version-modal-title').textContent = config.title;
root.querySelector('#version-modal-copy').textContent = config.copy;
root.querySelector('#version-modal-label').textContent = config.label;
root.querySelector('#version-modal-value').value = config.value || '';
const summary = root.querySelector('#version-modal-summary');
summary.hidden = !config.summary;
summary.textContent = config.summary || '';
const submit = root.querySelector('#version-modal-submit');
submit.textContent = config.submit;
submit.disabled = config.valid === false;
root.querySelector('#version-modal').hidden = false;
root.querySelector('#version-modal-value').focus();
}
function closeModal() {
const modal = versionState?.root.querySelector('#version-modal');
if (modal) modal.hidden = true;
if (versionState) versionState.modal = null;
}
async function submitModal(event) {
event.preventDefault();
const config = versionState.modal;
if (!config || config.valid === false) return;
const value = versionState.root.querySelector('#version-modal-value').value.trim();
if (!value) {
setStatus(config.mode === 'create' ? 'Укажите название черновика.' : 'Укажите причину операции.', 'error');
return;
}
const submit = versionState.root.querySelector('#version-modal-submit');
submit.disabled = true;
submit.textContent = 'Выполняем…';
try {
if (config.mode === 'create') {
const created = await api.post('/api/edu-office/schedule/versions', {
semesterId: Number(selectedSemesterId()),
name: value,
basedOnVersionId: config.version?.id || null
});
localStorage.setItem('magistr.schedule.versionId', String(created.id));
setStatus(`Черновик «${created.name}» создан.`, 'success');
} else {
await api.post(
`/api/edu-office/schedule/versions/${config.version.id}/${config.mode}`,
{ reason: value }
);
setStatus(config.mode === 'publish' ? 'Версия опубликована атомарно.' : 'Предыдущая версия восстановлена.', 'success');
}
closeModal();
await loadWorkspace();
} catch (error) {
setStatus(error.message || 'Операцию не удалось выполнить.', 'error');
submit.disabled = false;
submit.textContent = config.submit;
}
}
function navigateTo(tab) {
document.querySelector(`.nav-item[data-tab="${tab}"]`)?.click();
}
function selectedSemesterId() {
return versionState?.root.querySelector('#version-semester')?.value || '';
}
function setStatus(message, tone = '') {
const status = versionState?.root.querySelector('#version-status');
if (!status) return;
status.textContent = message;
status.dataset.tone = tone;
}
export function historyActionLabel(action) {
return {
CREATED: 'Создан черновик',
PUBLISHED: 'Опубликовано',
ARCHIVED: 'Архивировано',
RESTORED: 'Восстановлено'
}[action] || action;
}
function semesterLabel(semester) {
const type = semester.semesterType === 'autumn' ? 'Осенний' : 'Весенний';
return `${semester.academicYearTitle} · ${type} семестр`;
}
function formatDateTime(value) {
if (!value) return 'дата не указана';
return new Intl.DateTimeFormat('ru-RU', {
day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit'
}).format(new Date(value));
}
function localIsoDate(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,356 +0,0 @@
import { api } from '../api.js';
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
import { confirmAction } from '../dialog.js';
import { renderPagination } from '../pagination.js';
import { debounce, readUrlState, writeUrlState } from '../url-state.js';
import { markTableReady, renderTableEmpty, renderTableError, renderTableLoading } from '../view-state.js';
import { trackUxEvent } from '../../../telemetry.js';
import { AsyncCombobox } from '../async-combobox.js';
export async function initSubjects() {
const subjectsTbody = document.getElementById('subjects-tbody');
const createSubjectForm = document.getElementById('create-subject-form');
const newSubjectDeptSelect = document.getElementById('new-subject-department');
const departmentFilter = document.getElementById('subjects-department-filter');
const searchInput = document.getElementById('subjects-search');
const sortSelect = document.getElementById('subjects-sort');
const pagination = document.getElementById('subjects-pagination');
// Модалка управления преподавателями
const modalManageTeachers = document.getElementById('modal-manage-teachers');
const modalManageTeachersClose = document.getElementById('modal-manage-teachers-close');
const subjectTeachersContext = document.getElementById('subject-teachers-context');
const manageTeacherSubjectForm = document.getElementById('manage-teacher-subject-form');
const manageTsSubjectIdInput = document.getElementById('manage-ts-subject-id');
const manageTsTeacherSelect = document.getElementById('manage-ts-teacher-select');
const manageTsTbody = document.getElementById('manage-ts-tbody');
let allSubjects = [];
let allDepartments = [];
let teacherSubjects = [];
let teacherCombobox = null;
const restored = readUrlState({ subjectQuery: '', subjectDepartment: '', subjectPage: '0', subjectSize: '25', subjectSort: 'name,asc' });
const pageState = {
query: restored.subjectQuery,
departmentId: restored.subjectDepartment,
page: Math.max(Number(restored.subjectPage) || 0, 0),
size: [25, 50, 100].includes(Number(restored.subjectSize)) ? Number(restored.subjectSize) : 25,
sort: restored.subjectSort
};
searchInput.value = pageState.query;
sortSelect.value = pageState.sort;
bindEvents();
await loadInitialData();
function bindEvents() {
createSubjectForm.addEventListener('submit', createSubject);
subjectsTbody.addEventListener('click', handleSubjectsTableClick);
manageTeacherSubjectForm.addEventListener('submit', saveTeacherAssignment);
manageTsTbody.addEventListener('click', handleTeacherAssignmentTableClick);
modalManageTeachersClose.addEventListener('click', () => modalManageTeachers.classList.remove('open'));
modalManageTeachers.addEventListener('click', (e) => {
if (e.target === modalManageTeachers) modalManageTeachers.classList.remove('open');
});
searchInput.addEventListener('input', debounce(() => {
pageState.query = searchInput.value.trim();
pageState.page = 0;
loadSubjects();
}));
departmentFilter.addEventListener('change', () => {
pageState.departmentId = departmentFilter.value;
pageState.page = 0;
loadSubjects();
});
sortSelect.addEventListener('change', () => {
pageState.sort = sortSelect.value;
pageState.page = 0;
loadSubjects();
});
}
async function loadInitialData() {
try {
allDepartments = await api.get('/api/departments');
teacherCombobox = new AsyncCombobox(manageTsTeacherSelect, {
placeholder: 'Найдите преподавателя по ФИО',
optionLabel: teacher => teacher.fullName || teacher.username,
optionDescription: teacher => [teacher.jobTitle, teacher.departmentName].filter(Boolean).join(' · '),
load: async (query, selectedId) => {
const params = new URLSearchParams({ query, limit: '30' });
if (selectedId) params.set('selectedId', selectedId);
const teachers = await api.get(`/api/users/teachers/options?${params}`);
const subjectId = manageTsSubjectIdInput.value;
const assigned = new Set(teacherSubjects
.filter(item => String(item.subjectId) === String(subjectId))
.map(item => String(item.userId)));
return teachers.filter(teacher => !assigned.has(String(teacher.id)) || String(teacher.id) === String(selectedId));
}
});
populateDepartmentsSelect();
populateDepartmentFilter();
await loadSubjects();
} catch (e) {
subjectsTbody.innerHTML = `<tr><td colspan="6" class="loading-row">Ошибка загрузки справочников: ${escapeHtml(e.message)}</td></tr>`;
}
}
function populateDepartmentsSelect() {
newSubjectDeptSelect.innerHTML = '<option value="">Выберите кафедру</option>' +
allDepartments.map(d => {
const name = d.departmentName || d.name || 'Без названия';
const code = d.departmentCode || d.code || d.id;
return `<option value="${d.id}">${escapeHtml(name)} (${escapeHtml(String(code))})</option>`;
}).join('');
}
function populateDepartmentFilter() {
departmentFilter.innerHTML = '<option value="">Все кафедры</option>' + allDepartments.map(department =>
`<option value="${department.id}">${escapeHtml(department.departmentName || department.name || department.id)}</option>`
).join('');
departmentFilter.value = pageState.departmentId;
}
async function loadSubjects() {
renderTableLoading(subjectsTbody, 6, 'Загрузка дисциплин...');
hideAlert('subjects-alert');
try {
const [sort, direction] = pageState.sort.split(',');
const params = new URLSearchParams({
query: pageState.query,
page: String(pageState.page),
size: String(pageState.size),
sort,
direction
});
if (pageState.departmentId) params.set('departmentId', pageState.departmentId);
const page = await api.get(`/api/subjects/page?${params}`);
if (!(page.items || []).length && page.totalItems > 0 && pageState.page > 0) {
pageState.page = Math.max(0, page.totalPages - 1);
return loadSubjects();
}
allSubjects = page.items || [];
teacherSubjects = await loadTeacherAssignments(allSubjects.map(subject => subject.id));
renderSubjects();
renderPagination(pagination, page, {
label: 'дисциплин',
onPage: nextPage => { pageState.page = nextPage; loadSubjects(); },
onSize: size => { pageState.size = size; pageState.page = 0; loadSubjects(); }
});
persistState();
trackUxEvent('реестр_дисциплин_загружен', { total: Number(page.totalItems || 0), page: pageState.page });
} catch (e) {
renderTableError(subjectsTbody, 6, e.message || 'Не удалось загрузить дисциплины', loadSubjects);
}
}
async function loadTeacherAssignments(subjectIds) {
if (!subjectIds.length) return [];
const params = new URLSearchParams();
subjectIds.forEach(subjectId => params.append('subjectId', subjectId));
return api.get(`/api/teacher-subjects?${params}`);
}
function renderSubjects() {
if (!allSubjects || !allSubjects.length) {
renderTableEmpty(subjectsTbody, 6, pageState.query || pageState.departmentId
? 'Дисциплины не найдены'
: 'Дисциплин пока нет');
return;
}
// Группируем преподавателей по дисциплинам
const teachersBySubject = new Map();
teacherSubjects.forEach(ts => {
if (!teachersBySubject.has(ts.subjectId)) {
teachersBySubject.set(ts.subjectId, []);
}
teachersBySubject.get(ts.subjectId).push(ts);
});
subjectsTbody.innerHTML = allSubjects.map(s => {
const teachers = teachersBySubject.get(s.id) || [];
const teachersHtml = teachers.length
? teachers.map(t => `<span class="badge badge-ef" style="margin: 0.1rem;">${escapeHtml(t.fullName || t.username)}</span>`).join('')
: '<span class="text-muted"><small>Нет преподавателей</small></span>';
return `
<tr>
<td>${s.id}</td>
<td><strong>${escapeHtml(s.name)}</strong></td>
<td>${escapeHtml(s.code || '-')}</td>
<td>${escapeHtml(departmentLabel(s.departmentId))}</td>
<td><div style="display: flex; flex-wrap: wrap; gap: 0.25rem;">${teachersHtml}</div></td>
<td style="text-align: right; white-space: nowrap;">
<button class="btn btn-sm btn-secondary btn-manage-teachers" data-id="${s.id}">Преподаватели</button>
<button class="btn btn-sm btn-danger btn-delete-subject" data-id="${s.id}">Удалить</button>
</td>
</tr>
`;
}).join('');
markTableReady(subjectsTbody);
}
async function createSubject(e) {
e.preventDefault();
hideAlert('create-subject-alert');
const name = document.getElementById('new-subject-name').value.trim();
const code = document.getElementById('new-subject-code').value.trim();
const departmentId = newSubjectDeptSelect.value;
if (!name || !code || !departmentId) {
showAlert('create-subject-alert', 'Заполните все поля', 'error');
return;
}
try {
const data = await api.post('/api/subjects', {
name,
code,
departmentId: Number(departmentId)
});
showAlert('create-subject-alert', `Дисциплина "${escapeHtml(data.name || name)}" создана`, 'success');
createSubjectForm.reset();
await loadSubjects();
} catch (error) {
showAlert('create-subject-alert', error.message || 'Ошибка создания', 'error');
}
}
async function handleSubjectsTableClick(e) {
const btnDelete = e.target.closest('.btn-delete-subject');
const btnTeachers = e.target.closest('.btn-manage-teachers');
if (btnDelete) {
if (!await confirmAction({
title: 'Архивировать дисциплину?',
message: 'Дисциплина станет недоступна для новых назначений. Исторические данные сохранятся.',
confirmLabel: 'Архивировать',
danger: true
})) return;
try {
await api.delete('/api/subjects/' + btnDelete.dataset.id);
await loadSubjects();
} catch (error) {
showAlert('subjects-alert', error.message || 'Ошибка архивирования', 'error');
}
}
if (btnTeachers) {
const subjectId = btnTeachers.dataset.id;
const subject = allSubjects.find(s => s.id == subjectId);
if (subject) openTeachersModal(subject);
}
}
// --- Управление привязками преподавателей к дисциплине ---
function openTeachersModal(subject) {
subjectTeachersContext.textContent = `${subject.code ? `${subject.code} · ` : ''}${subject.name}`;
manageTsSubjectIdInput.value = subject.id;
hideAlert('manage-ts-alert');
modalManageTeachers.classList.add('open');
renderSubjectTeachers(subject.id);
populateTeachersSelect(subject.id);
}
function renderSubjectTeachers(subjectId) {
const list = teacherSubjects.filter(ts => String(ts.subjectId) === String(subjectId));
if (!list.length) {
manageTsTbody.innerHTML = '<tr><td colspan="2" class="loading-row">Преподаватели не привязаны</td></tr>';
return;
}
manageTsTbody.innerHTML = list.map(ts => `
<tr>
<td><strong>${escapeHtml(ts.fullName || ts.username)}</strong> <span class="text-muted">(${escapeHtml(ts.username)})</span></td>
<td>
<button class="btn btn-sm btn-danger btn-delete-ts" data-user-id="${ts.userId}" data-subject-id="${ts.subjectId}">Удалить</button>
</td>
</tr>
`).join('');
}
function populateTeachersSelect(subjectId) {
manageTsTeacherSelect.innerHTML = '<option value=""></option>';
manageTsTeacherSelect.value = '';
teacherCombobox?.syncFromSelect();
teacherCombobox?.search('');
}
async function saveTeacherAssignment(e) {
e.preventDefault();
hideAlert('manage-ts-alert');
const subjectId = manageTsSubjectIdInput.value;
const userId = manageTsTeacherSelect.value;
if (!userId) {
showAlert('manage-ts-alert', 'Выберите преподавателя', 'error');
return;
}
try {
await api.post('/api/teacher-subjects', {
userId: Number(userId),
subjectId: Number(subjectId)
});
showAlert('manage-ts-alert', 'Преподаватель успешно привязан', 'success');
// Перезагрузим привязки и обновим интерфейс
teacherSubjects = await loadTeacherAssignments(allSubjects.map(subject => subject.id));
renderSubjectTeachers(subjectId);
populateTeachersSelect(subjectId);
// Также обновим главную таблицу
renderSubjects();
} catch (error) {
showAlert('manage-ts-alert', error.message || 'Ошибка привязки', 'error');
}
}
async function handleTeacherAssignmentTableClick(e) {
const btnDelete = e.target.closest('.btn-delete-ts');
if (!btnDelete) return;
if (!await confirmAction({
title: 'Удалить привязку преподавателя?',
message: 'Преподаватель больше не будет доступен для этой дисциплины в новых правилах.',
confirmLabel: 'Удалить привязку',
danger: true
})) return;
const subjectId = manageTsSubjectIdInput.value;
try {
await api.delete('/api/teacher-subjects', {
userId: Number(btnDelete.dataset.userId),
subjectId: Number(btnDelete.dataset.subjectId)
});
// Перезагрузим привязки
teacherSubjects = await loadTeacherAssignments(allSubjects.map(subject => subject.id));
renderSubjectTeachers(subjectId);
populateTeachersSelect(subjectId);
// Обновим главную таблицу
renderSubjects();
} catch (error) {
showAlert('manage-ts-alert', error.message || 'Ошибка удаления привязки', 'error');
}
}
// --- Вспомогательные функции ---
function departmentLabel(departmentId) {
const department = allDepartments.find(item => item.id == departmentId);
if (!department) return departmentId || '-';
return department.departmentName || department.name || departmentId;
}
function persistState() {
writeUrlState({
subjectQuery: pageState.query,
subjectDepartment: pageState.departmentId,
subjectPage: pageState.page || '',
subjectSize: pageState.size === 25 ? '' : pageState.size,
subjectSort: pageState.sort === 'name,asc' ? '' : pageState.sort
});
}
}

View File

@@ -1,571 +0,0 @@
import { api, getSession } from '../api.js';
import { escapeHtml, hideAlert, showAlert } from '../utils.js';
import { alertAction } from '../dialog.js';
import { AsyncCombobox } from '../async-combobox.js';
const MANAGER_ROLES = new Set(['ADMIN', 'EDUCATION_OFFICE', 'DEPARTMENT']);
export function teacherAbsenceTeacherOptionsUrl({ query = '', selectedId = '', role = '', departmentId = '' } = {}) {
const params = new URLSearchParams({ query, limit: '30' });
if (role === 'DEPARTMENT' && departmentId) params.set('departmentId', departmentId);
if (selectedId) params.set('selectedId', selectedId);
return `/api/users/teachers/options?${params}`;
}
export async function initTeacherAbsences() {
const session = getSession();
const role = session?.role;
if (!MANAGER_ROLES.has(role)) return;
const form = document.getElementById('teacher-absence-form');
const teacherSelect = document.getElementById('teacher-absence-teacher');
const startInput = document.getElementById('teacher-absence-start');
const endInput = document.getElementById('teacher-absence-end');
const reasonInput = document.getElementById('teacher-absence-reason');
const statusFilter = document.getElementById('teacher-absence-status-filter');
const registry = document.getElementById('teacher-absence-registry');
const overlay = document.getElementById('absence-wizard-overlay');
const wizardLessons = document.getElementById('absence-wizard-lessons');
const preferenceSemesterFilter = document.getElementById('teacher-preference-semester-filter');
const preferenceStatusFilter = document.getElementById('teacher-preference-status-filter');
const preferenceReviewList = document.getElementById('teacher-preference-review-list');
const changeStatusFilter = document.getElementById('teacher-change-status-filter');
const changeReviewList = document.getElementById('teacher-change-review-list');
let absences = [];
let currentWizard = null;
let preferenceItems = [];
let changeItems = [];
new AsyncCombobox(teacherSelect, {
placeholder: 'Найдите преподавателя по ФИО',
optionLabel: teacher => teacher.fullName || teacher.username || `ID ${teacher.id}`,
optionDescription: teacher => [teacher.jobTitle, teacher.departmentName].filter(Boolean).join(' · '),
load: async (query, selectedId) => {
return api.get(teacherAbsenceTeacherOptionsUrl({
query,
selectedId,
role,
departmentId: session?.departmentId
}));
}
});
setDefaultPeriod(startInput, endInput);
form?.addEventListener('submit', createAbsence);
statusFilter?.addEventListener('change', loadAbsences);
document.getElementById('teacher-absence-refresh')?.addEventListener('click', loadAbsences);
document.getElementById('absence-wizard-close')?.addEventListener('click', closeWizard);
document.getElementById('absence-wizard-cancel')?.addEventListener('click', closeWizard);
document.getElementById('absence-wizard-apply')?.addEventListener('click', applyWizard);
overlay?.addEventListener('click', event => {
if (event.target === overlay) closeWizard();
});
registry?.addEventListener('click', handleRegistryAction);
preferenceSemesterFilter?.addEventListener('change', loadPreferenceReviews);
preferenceStatusFilter?.addEventListener('change', loadPreferenceReviews);
document.getElementById('teacher-preference-refresh')?.addEventListener('click', loadPreferenceReviews);
preferenceReviewList?.addEventListener('click', handlePreferenceReview);
changeStatusFilter?.addEventListener('change', loadChangeReviews);
document.getElementById('teacher-change-refresh')?.addEventListener('click', loadChangeReviews);
changeReviewList?.addEventListener('click', handleChangeReview);
await Promise.all([loadAbsences(), loadPreferenceMeta(), loadChangeReviews()]);
async function loadAbsences() {
registry.innerHTML = '<div class="loading-row">Загрузка...</div>';
try {
const params = statusFilter.value ? `?status=${encodeURIComponent(statusFilter.value)}` : '';
absences = await api.get(`/api/teacher-absences${params}`);
renderRegistry();
updateStats(absences);
} catch (error) {
registry.innerHTML = `<div class="loading-row">${escapeHtml(error.message || 'Не удалось загрузить отсутствия')}</div>`;
}
}
async function createAbsence(event) {
event.preventDefault();
hideAlert('teacher-absence-form-alert');
if (!teacherSelect.value || !startInput.value || !endInput.value || !reasonInput.value.trim()) {
showAlert('teacher-absence-form-alert', 'Заполните преподавателя, период и причину', 'error');
return;
}
if (endInput.value < startInput.value) {
showAlert('teacher-absence-form-alert', 'Дата окончания не может быть раньше даты начала', 'error');
return;
}
try {
await api.post('/api/teacher-absences', {
teacherId: Number(teacherSelect.value),
startDate: startInput.value,
endDate: endInput.value,
reason: reasonInput.value.trim()
});
showAlert('teacher-absence-form-alert', 'Отсутствие зарегистрировано', 'success');
reasonInput.value = '';
await loadAbsences();
} catch (error) {
showAlert('teacher-absence-form-alert', error.message || 'Не удалось зарегистрировать отсутствие', 'error');
}
}
function renderRegistry() {
if (!absences.length) {
registry.innerHTML = '<div class="absence-empty"><strong>Инцидентов не найдено</strong><span>Измените фильтр или зарегистрируйте отсутствие.</span></div>';
return;
}
registry.innerHTML = absences.map(item => `
<article class="absence-registry-item" data-status="${escapeHtml(item.status)}">
<div class="absence-date-rail">
<strong>${escapeHtml(dayOfMonth(item.startDate))}</strong>
<span>${escapeHtml(monthShort(item.startDate))}</span>
<i></i>
<strong>${escapeHtml(dayOfMonth(item.endDate))}</strong>
<span>${escapeHtml(monthShort(item.endDate))}</span>
</div>
<div class="absence-registry-main">
<div class="absence-registry-title">
<div>
<h3>${escapeHtml(item.teacherName || 'Преподаватель не указан')}</h3>
<p>${escapeHtml(item.reason || 'Причина не указана')}</p>
</div>
<span class="absence-status ${statusClass(item.status)}">${escapeHtml(statusLabel(item.status))}</span>
</div>
<div class="absence-meta-row">
<span>${escapeHtml(formatDate(item.startDate))} — ${escapeHtml(formatDate(item.endDate))}</span>
<span>${escapeHtml(decisionCountLabel(item.decisionCount || 0))}</span>
${item.reviewComment ? `<span>${escapeHtml(item.reviewComment)}</span>` : ''}
</div>
</div>
<div class="absence-registry-buttons">
${item.status === 'PENDING' ? `
<button type="button" class="btn btn-sm btn-primary" data-action="approve" data-id="${item.id}">Подтвердить</button>
<button type="button" class="btn btn-sm btn-danger-subtle" data-action="reject" data-id="${item.id}">Отклонить</button>
` : ''}
${['APPROVED', 'RESOLVED'].includes(item.status) ? `
<button type="button" class="btn btn-sm btn-secondary" data-action="wizard" data-id="${item.id}">
${item.status === 'APPROVED' ? 'Открыть мастер' : 'Открыть журнал'}
</button>
` : ''}
${['PENDING', 'APPROVED'].includes(item.status) ? `
<button type="button" class="btn btn-sm btn-ghost" data-action="cancel" data-id="${item.id}">Отменить</button>
` : ''}
</div>
</article>
`).join('');
}
async function handleRegistryAction(event) {
const button = event.target.closest('[data-action][data-id]');
if (!button) return;
const id = Number(button.dataset.id);
button.disabled = true;
try {
if (button.dataset.action === 'approve' || button.dataset.action === 'reject') {
await api.post(`/api/teacher-absences/${id}/review`, {
approved: button.dataset.action === 'approve',
comment: button.dataset.action === 'approve'
? 'Подтверждено кафедрой или ответственным сотрудником'
: 'Заявка отклонена ответственным сотрудником'
});
await loadAbsences();
} else if (button.dataset.action === 'cancel') {
await api.delete(`/api/teacher-absences/${id}`);
await loadAbsences();
} else if (button.dataset.action === 'wizard') {
await openWizard(id);
}
} catch (error) {
await alertAction({ title: 'Действие не выполнено', message: error.message || 'Не удалось выполнить действие' });
} finally {
button.disabled = false;
}
}
async function openWizard(id) {
overlay.hidden = false;
document.body.classList.add('absence-wizard-open');
wizardLessons.innerHTML = '<div class="loading-row">Подбираем свободные ресурсы...</div>';
hideAlert('absence-wizard-alert');
try {
currentWizard = await api.get(`/api/teacher-absences/${id}/wizard`);
renderWizard();
} catch (error) {
showAlert('absence-wizard-alert', error.message || 'Не удалось открыть мастер замены', 'error');
}
}
function closeWizard() {
overlay.hidden = true;
document.body.classList.remove('absence-wizard-open');
currentWizard = null;
}
function renderWizard() {
const absence = currentWizard.absence;
document.getElementById('absence-wizard-title').textContent = absence.teacherName || 'Мастер замены';
document.getElementById('absence-wizard-summary').textContent =
`${formatDate(absence.startDate)} — ${formatDate(absence.endDate)} · ${absence.reason}`;
const applyButton = document.getElementById('absence-wizard-apply');
applyButton.hidden = absence.status !== 'APPROVED';
wizardLessons.innerHTML = currentWizard.lessons.length
? currentWizard.lessons.map((lesson, index) => renderLessonDecision(lesson, index, absence.status)).join('')
: '<div class="absence-empty"><strong>Затронутых занятий нет</strong><span>Инцидент можно завершить без точечных изменений.</span></div>';
renderJournal(currentWizard.journal || []);
}
function renderLessonDecision(lesson, index, absenceStatus) {
const options = decisionOptions(lesson);
const locked = absenceStatus !== 'APPROVED' || Boolean(lesson.decisionStatus);
return `
<article class="absence-lesson" data-index="${index}" data-slot-id="${lesson.baseRuleSlotId}" data-date="${lesson.lessonDate}">
<div class="absence-lesson-time">
<strong>${escapeHtml(trimTime(lesson.startTime))}</strong>
<span>${escapeHtml(trimTime(lesson.endTime))}</span>
<small>${escapeHtml(formatDate(lesson.lessonDate))}</small>
</div>
<div class="absence-lesson-copy">
<span class="absence-lesson-type">${escapeHtml(lesson.lessonTypeName || 'Занятие')}</span>
<h3>${escapeHtml(lesson.subjectName || 'Дисциплина не указана')}</h3>
<p>${escapeHtml((lesson.groupNames || []).join(', ') || 'Группы не указаны')} · ${escapeHtml(lesson.classroomName || 'Без аудитории')}</p>
${lesson.existingOverrideId ? '<small class="absence-warning">У пары уже есть точечное изменение — доступна только запись отклонения.</small>' : ''}
</div>
<div class="absence-lesson-decision">
<label for="absence-decision-${index}">Решение</label>
<select id="absence-decision-${index}" class="absence-decision-select" ${locked ? 'disabled' : ''}>
<option value="">Оставить без изменений</option>
${options.map(option => `<option value="${escapeHtml(option.value)}">${escapeHtml(option.label)}</option>`).join('')}
</select>
${lesson.decisionStatus ? `<span class="absence-decision-fixed">${escapeHtml(decisionStatusLabel(lesson.decisionStatus))}</span>` : ''}
</div>
</article>
`;
}
async function applyWizard() {
if (!currentWizard) return;
const decisions = Array.from(wizardLessons.querySelectorAll('.absence-lesson')).map(card => {
const value = card.querySelector('.absence-decision-select')?.value;
if (!value) return null;
return {
baseRuleSlotId: Number(card.dataset.slotId),
lessonDate: card.dataset.date,
...parseDecisionOption(value)
};
}).filter(Boolean);
if (!decisions.length && currentWizard.lessons.length) {
showAlert('absence-wizard-alert', 'Выберите хотя бы одно решение', 'error');
return;
}
const button = document.getElementById('absence-wizard-apply');
button.disabled = true;
hideAlert('absence-wizard-alert');
try {
currentWizard = await api.post(`/api/teacher-absences/${currentWizard.absence.id}/resolve`, { decisions });
renderWizard();
showAlert('absence-wizard-alert', 'Выбранные решения применены к расписанию', 'success');
await loadAbsences();
} catch (error) {
showAlert('absence-wizard-alert', error.message || 'Не удалось применить решения', 'error');
} finally {
button.disabled = false;
}
}
async function loadPreferenceMeta() {
preferenceReviewList.innerHTML = '<div class="loading-row">Загрузка семестров...</div>';
try {
const meta = await api.get('/api/teacher-preferences/meta');
preferenceSemesterFilter.innerHTML = '<option value="">Все семестры</option>' + (meta.semesters || []).map(item => `
<option value="${item.id}">${escapeHtml(semesterLabel(item))}</option>
`).join('');
const today = isoDate(new Date());
const current = (meta.semesters || []).find(item => item.startDate <= today && item.endDate >= today);
if (current) preferenceSemesterFilter.value = String(current.id);
await loadPreferenceReviews();
} catch (error) {
preferenceReviewList.innerHTML = `<div class="loading-row">${escapeHtml(error.message || 'Не удалось загрузить семестры')}</div>`;
}
}
async function loadPreferenceReviews() {
preferenceReviewList.innerHTML = '<div class="loading-row">Загрузка пожеланий...</div>';
const params = new URLSearchParams();
if (preferenceSemesterFilter.value) params.set('semesterId', preferenceSemesterFilter.value);
if (preferenceStatusFilter.value) params.set('status', preferenceStatusFilter.value);
try {
preferenceItems = await api.get(`/api/teacher-preferences${params.size ? `?${params}` : ''}`);
document.getElementById('teacher-preference-pending-count').textContent = String(
preferenceItems.filter(item => item.status === 'PENDING').length
);
renderPreferenceReviews();
} catch (error) {
preferenceReviewList.innerHTML = `<div class="loading-row">${escapeHtml(error.message || 'Не удалось загрузить пожелания')}</div>`;
}
}
function renderPreferenceReviews() {
preferenceReviewList.innerHTML = preferenceItems.length ? preferenceItems.map(item => `
<article class="teacher-dialogue-item">
<span class="teacher-dialogue-mark preference-${escapeHtml(item.preferenceType.toLowerCase())}"></span>
<div class="teacher-dialogue-copy">
<small>${escapeHtml(item.academicYearTitle)} · ${escapeHtml(item.semesterType === 'AUTUMN' ? 'осенний семестр' : 'весенний семестр')}</small>
<h3>${escapeHtml(item.teacherName)}</h3>
<p>${escapeHtml(preferenceDescription(item))}</p>
${item.comment ? `<blockquote>${escapeHtml(item.comment)}</blockquote>` : ''}
${item.reviewComment ? `<span class="teacher-dialogue-review">Решение: ${escapeHtml(item.reviewComment)}</span>` : ''}
</div>
<div class="teacher-dialogue-action">
<span class="absence-status ${statusClass(item.status)}">${escapeHtml(genericStatusLabel(item.status))}</span>
${item.status === 'PENDING' ? `
<input type="text" class="teacher-dialogue-comment" maxlength="2000" placeholder="Комментарий к решению">
<div>
<button class="btn btn-sm btn-primary" type="button" data-preference-review="approve" data-id="${item.id}">Согласовать</button>
<button class="btn btn-sm btn-danger-subtle" type="button" data-preference-review="reject" data-id="${item.id}">Отклонить</button>
</div>
` : ''}
</div>
</article>
`).join('') : '<div class="absence-empty"><strong>Пожеланий не найдено</strong><span>Измените фильтры или дождитесь новых отметок преподавателей.</span></div>';
}
async function handlePreferenceReview(event) {
const button = event.target.closest('[data-preference-review][data-id]');
if (!button) return;
const card = button.closest('.teacher-dialogue-item');
const approved = button.dataset.preferenceReview === 'approve';
const comment = card.querySelector('.teacher-dialogue-comment')?.value.trim() || '';
if (!approved && !comment) {
await alertAction({ title: 'Нужен комментарий', message: 'При отклонении укажите комментарий.' });
return;
}
button.disabled = true;
try {
await api.post(`/api/teacher-preferences/${button.dataset.id}/review`, {
approved,
comment: comment || 'Согласовано ответственным сотрудником'
});
await loadPreferenceReviews();
} catch (error) {
await alertAction({ title: 'Ошибка рассмотрения', message: error.message || 'Не удалось рассмотреть пожелание' });
button.disabled = false;
}
}
async function loadChangeReviews() {
changeReviewList.innerHTML = '<div class="loading-row">Загрузка заявок...</div>';
const params = changeStatusFilter.value ? `?status=${encodeURIComponent(changeStatusFilter.value)}` : '';
try {
changeItems = await api.get(`/api/teacher-change-requests${params}`);
document.getElementById('teacher-change-pending-count').textContent = String(
changeItems.filter(item => item.status === 'PENDING').length
);
renderChangeReviews();
} catch (error) {
changeReviewList.innerHTML = `<div class="loading-row">${escapeHtml(error.message || 'Не удалось загрузить заявки')}</div>`;
}
}
function renderChangeReviews() {
const canApply = role === 'ADMIN' || role === 'EDUCATION_OFFICE';
changeReviewList.innerHTML = changeItems.length ? changeItems.map(item => {
const target = item.requestType === 'MOVE'
? `${formatDate(item.targetLessonDate || item.lessonDate)} · ${trimTime(item.requestedStartTime)}–${trimTime(item.requestedEndTime)}`
: item.requestType === 'CHANGE_CLASSROOM' ? item.requestedClassroomName : 'Отмена занятия';
return `<article class="teacher-dialogue-item change-item">
<span class="teacher-dialogue-mark change-${escapeHtml(item.requestType.toLowerCase())}"></span>
<div class="teacher-dialogue-copy">
<small>${formatDate(item.lessonDate)} · ${trimTime(item.sourceStartTime)}–${trimTime(item.sourceEndTime)} · ${escapeHtml(item.sourceClassroomName)}</small>
<h3>${escapeHtml(item.teacherName)} · ${escapeHtml(item.subjectName)}</h3>
<p>${escapeHtml(changeTypeLabel(item.requestType))} → <strong>${escapeHtml(target)}</strong></p>
<blockquote>${escapeHtml(item.reason)}</blockquote>
${item.reviewComment ? `<span class="teacher-dialogue-review">Решение: ${escapeHtml(item.reviewComment)}</span>` : ''}
<details><summary>История · ${(item.history || []).length}</summary>
<ol>${(item.history || []).map(entry => `<li><b>${genericStatusLabel(entry.status)}</b> · ${escapeHtml(entry.comment || 'Без комментария')}</li>`).join('')}</ol>
</details>
</div>
<div class="teacher-dialogue-action">
<span class="absence-status ${statusClass(item.status)}">${escapeHtml(genericStatusLabel(item.status))}</span>
${item.status === 'PENDING' && canApply ? `
<input type="text" class="teacher-dialogue-comment" maxlength="2000" placeholder="Комментарий к решению">
<div>
<button class="btn btn-sm btn-primary" type="button" data-change-review="approve" data-id="${item.id}">Применить</button>
<button class="btn btn-sm btn-danger-subtle" type="button" data-change-review="reject" data-id="${item.id}">Отклонить</button>
</div>
` : item.status === 'PENDING' ? '<small>Применение доступно учебному отделу</small>' : ''}
</div>
</article>`;
}).join('') : '<div class="absence-empty"><strong>Заявок не найдено</strong><span>Очередь изменений сейчас пуста.</span></div>';
}
async function handleChangeReview(event) {
const button = event.target.closest('[data-change-review][data-id]');
if (!button) return;
const card = button.closest('.teacher-dialogue-item');
const approved = button.dataset.changeReview === 'approve';
const comment = card.querySelector('.teacher-dialogue-comment')?.value.trim() || '';
if (!approved && !comment) {
await alertAction({ title: 'Нужен комментарий', message: 'При отклонении укажите комментарий.' });
return;
}
button.disabled = true;
try {
await api.post(`/api/teacher-change-requests/${button.dataset.id}/review`, { approved, comment });
await Promise.all([loadChangeReviews(), loadAbsences()]);
} catch (error) {
await alertAction({ title: 'Решение не применено', message: error.message || 'Не удалось применить решение' });
button.disabled = false;
}
}
}
export function decisionOptions(lesson) {
const options = [];
(lesson.teacherCandidates || []).forEach(candidate => options.push({
value: `REPLACE_TEACHER|${candidate.id}`,
label: `Заменить: ${candidate.name}`
}));
(lesson.moveCandidates || []).forEach(candidate => options.push({
value: `MOVE_TIME|${candidate.targetDate}|${candidate.timeSlotId}`,
label: `Перенести: ${formatDate(candidate.targetDate)}, ${trimTime(candidate.startTime)}–${trimTime(candidate.endTime)}`
}));
(lesson.classroomCandidates || []).forEach(candidate => options.push({
value: `CHANGE_CLASSROOM|${candidate.id}`,
label: `Другая аудитория: ${candidate.name}`
}));
if (lesson.cancellationAvailable) {
options.push({ value: 'CANCEL', label: 'Отменить занятие' });
}
options.push({ value: 'REJECT', label: 'Отклонить предложения' });
return options;
}
export function parseDecisionOption(value) {
const [resolution, first, second] = String(value || '').split('|');
if (resolution === 'REPLACE_TEACHER') return { resolution, newTeacherId: Number(first) };
if (resolution === 'MOVE_TIME') {
return { resolution, targetLessonDate: first, newTimeSlotId: Number(second) };
}
if (resolution === 'CHANGE_CLASSROOM') return { resolution, newClassroomId: Number(first) };
if (resolution === 'CANCEL' || resolution === 'REJECT') return { resolution };
throw new Error('Неизвестное решение мастера замены');
}
function renderJournal(journal) {
document.getElementById('absence-journal-count').textContent = decisionCountLabel(journal.length);
document.getElementById('absence-journal-list').innerHTML = journal.length ? journal.map(item => `
<article>
<span class="absence-journal-mark ${item.status === 'APPLIED' ? 'applied' : 'rejected'}"></span>
<div><strong>${escapeHtml(resolutionLabel(item.resolution))}</strong><small>${escapeHtml(formatDate(item.lessonDate))}</small></div>
<span>${escapeHtml(decisionStatusLabel(item.status))}${item.scheduleOverrideId ? ` · override №${escapeHtml(item.scheduleOverrideId)}` : ''}</span>
</article>
`).join('') : '<div class="loading-row">Решений пока нет</div>';
}
function updateStats(items) {
document.getElementById('absence-stat-active').textContent = items.filter(item => item.status === 'APPROVED').length;
document.getElementById('absence-stat-pending').textContent = items.filter(item => item.status === 'PENDING').length;
document.getElementById('absence-stat-resolved').textContent = items.filter(item => item.status === 'RESOLVED').length;
}
function setDefaultPeriod(startInput, endInput) {
const today = new Date();
startInput.value = isoDate(today);
endInput.value = isoDate(today);
}
function isoDate(date) {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
}
function formatDate(value) {
if (!value) return '—';
const [year, month, day] = String(value).split('-');
return `${day}.${month}.${year}`;
}
function dayOfMonth(value) {
return String(value || '').split('-')[2] || '—';
}
function monthShort(value) {
const month = Number(String(value || '').split('-')[1]);
return ['янв', 'фев', 'мар', 'апр', 'май', 'июн', 'июл', 'авг', 'сен', 'окт', 'ноя', 'дек'][month - 1] || '';
}
function trimTime(value) {
return String(value || '').slice(0, 5);
}
function statusLabel(status) {
return {
PENDING: 'На подтверждении',
APPROVED: 'Требует решения',
REJECTED: 'Отклонено',
RESOLVED: 'Разрешено',
CANCELLED: 'Отменено'
}[status] || status || '—';
}
function statusClass(status) {
return `is-${String(status || '').toLowerCase()}`;
}
function genericStatusLabel(status) {
return {
PENDING: 'На рассмотрении',
APPROVED: 'Согласовано',
REJECTED: 'Отклонено',
CANCELLED: 'Отменено'
}[status] || status || '—';
}
function semesterLabel(semester) {
const type = semester.semesterType === 'AUTUMN' ? 'осенний' : semester.semesterType === 'SPRING' ? 'весенний' : semester.semesterType;
return `${semester.academicYearTitle} · ${type} · ${formatDate(semester.startDate)}–${formatDate(semester.endDate)}`;
}
function preferenceDescription(item) {
const type = {
HARD_UNAVAILABLE: 'Строгая недоступность',
SOFT_PREFERRED: 'Предпочтительное время',
SOFT_UNWANTED: 'Нежелательное время',
CONSECUTIVE: 'Пожелание «пары подряд»',
NO_GAPS: 'Пожелание «без окон»'
}[item.preferenceType] || item.preferenceType;
if (item.preferenceDate) return `${type}: ${formatDate(item.preferenceDate)}`;
if (item.dayName) return `${type}: ${item.dayName}, ${trimTime(item.startTime)}–${trimTime(item.endTime)}`;
return type;
}
function changeTypeLabel(type) {
return {
MOVE: 'Перенос даты или времени',
CHANGE_CLASSROOM: 'Смена аудитории',
CANCEL: 'Отмена занятия'
}[type] || type || 'Изменение';
}
function resolutionLabel(resolution) {
return {
REPLACE_TEACHER: 'Замена преподавателя',
MOVE_TIME: 'Перенос занятия',
CHANGE_CLASSROOM: 'Замена аудитории',
CANCEL: 'Отмена занятия',
REJECT: 'Предложение отклонено'
}[resolution] || resolution || 'Решение';
}
function decisionStatusLabel(status) {
return status === 'APPLIED' ? 'Применено' : status === 'REJECTED' ? 'Отклонено' : status || '—';
}
function decisionCountLabel(count) {
const value = Number(count) || 0;
const tail = value % 100;
if (tail >= 11 && tail <= 14) return `${value} решений`;
if (value % 10 === 1) return `${value} решение`;
if ([2, 3, 4].includes(value % 10)) return `${value} решения`;
return `${value} решений`;
}

View File

@@ -1,205 +0,0 @@
import { api } from '../api.js';
import { escapeHtml, hideAlert, renderTableMessage, showAlert } from '../utils.js';
import { promptAction } from '../dialog.js';
import { renderPagination } from '../pagination.js';
import { debounce, readUrlState, writeUrlState } from '../url-state.js';
import { markTableReady, renderTableEmpty, renderTableError, renderTableLoading } from '../view-state.js';
import { trackUxEvent } from '../../../telemetry.js';
export async function initTeacherRequests() {
const requestsTbody = document.getElementById('teacher-requests-tbody');
const refreshButton = document.getElementById('teacher-requests-refresh');
const requestsCount = document.getElementById('teacher-requests-count');
const searchInput = document.getElementById('teacher-requests-search');
const statusSelect = document.getElementById('teacher-requests-status');
const pagination = document.getElementById('teacher-requests-pagination');
let departments = [];
const restored = readUrlState({ requestQuery: '', requestStatus: 'PENDING', requestPage: '0', requestSize: '25' });
const state = {
query: restored.requestQuery,
status: restored.requestStatus,
page: Math.max(Number(restored.requestPage) || 0, 0),
size: [25, 50, 100].includes(Number(restored.requestSize)) ? Number(restored.requestSize) : 25
};
searchInput.value = state.query;
statusSelect.value = state.status;
async function loadDepartments() {
departments = await api.get('/api/departments');
}
async function loadRequests() {
renderTableLoading(requestsTbody, 7, 'Загрузка заявок...');
hideAlert('teacher-requests-alert');
try {
const params = new URLSearchParams({
query: state.query,
page: String(state.page),
size: String(state.size),
sort: 'createdAt',
direction: 'desc'
});
if (state.status) params.set('status', state.status);
const page = await api.get(`/api/teacher-requests/page?${params}`);
if (!(page.items || []).length && page.totalItems > 0 && state.page > 0) {
state.page = Math.max(0, page.totalPages - 1);
return loadRequests();
}
const normalizedRequests = page.items || [];
renderRequests(normalizedRequests);
updateCount(Number(page.totalItems || 0));
renderPagination(pagination, page, {
label: 'заявок',
onPage: nextPage => { state.page = nextPage; loadRequests(); },
onSize: size => { state.size = size; state.page = 0; loadRequests(); }
});
persistState();
document.dispatchEvent(new CustomEvent('teacherRequestsChanged', {
detail: { count: state.status === 'PENDING' && !state.query ? Number(page.totalItems || 0) : undefined }
}));
trackUxEvent('реестр_заявок_загружен', { total: Number(page.totalItems || 0), page: state.page });
} catch (error) {
renderTableError(requestsTbody, 7, 'Не удалось загрузить заявки преподавателей', loadRequests);
updateCount(0);
}
}
function renderRequests(requests) {
if (!requests.length) {
renderTableEmpty(requestsTbody, 7, state.query ? 'Заявки не найдены' : 'Заявок с выбранным статусом нет');
return;
}
requestsTbody.innerHTML = requests.map(request => `
<tr data-request-id="${escapeHtml(request.id)}">
<td>
<select class="teacher-request-department" data-native-select="true">
${departments.map(department => `
<option value="${escapeHtml(department.id)}" ${String(department.id) === String(request.departmentId) ? 'selected' : ''}>
${escapeHtml(department.departmentName || department.name || department.id)}
</option>
`).join('')}
</select>
</td>
<td><input type="text" class="teacher-request-username" value="${escapeHtml(request.username || '')}" maxlength="50" autocomplete="username"></td>
<td><input type="text" class="teacher-request-fullname" value="${escapeHtml(request.fullName || '')}" maxlength="255"></td>
<td><input type="text" class="teacher-request-jobtitle" value="${escapeHtml(request.jobTitle || '')}" maxlength="255"></td>
<td><input type="password" class="teacher-request-password" placeholder="Минимум 8 символов" autocomplete="new-password"></td>
<td>${escapeHtml(request.comment || '-')}</td>
<td>
<div class="teacher-request-actions">
${request.status === 'PENDING' ? `
<button type="button" class="btn btn-sm btn-primary teacher-request-approve">Одобрить</button>
<button type="button" class="btn btn-sm btn-danger-subtle teacher-request-reject">Отклонить</button>
` : `<span class="badge">${escapeHtml(request.status === 'APPROVED' ? 'Одобрена' : 'Отклонена')}</span>`}
</div>
</td>
</tr>
`).join('');
markTableReady(requestsTbody);
}
function updateCount(count) {
if (!requestsCount) return;
requestsCount.textContent = requestCountLabel(count);
}
function requestCountLabel(count) {
const mod10 = count % 10;
const mod100 = count % 100;
if (mod10 === 1 && mod100 !== 11) return `${count} заявка`;
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return `${count} заявки`;
return `${count} заявок`;
}
async function approveRequest(row, requestId) {
const payload = {
departmentId: Number(row.querySelector('.teacher-request-department')?.value),
username: row.querySelector('.teacher-request-username')?.value.trim(),
fullName: row.querySelector('.teacher-request-fullname')?.value.trim(),
jobTitle: row.querySelector('.teacher-request-jobtitle')?.value.trim(),
password: row.querySelector('.teacher-request-password')?.value
};
if (!payload.departmentId || !payload.username || !payload.fullName || !payload.password) {
showAlert('teacher-requests-alert', 'Заполните кафедру, логин, ФИО и пароль', 'error');
return;
}
if (payload.password.length < 8) {
showAlert('teacher-requests-alert', 'Пароль должен быть не короче 8 символов', 'error');
return;
}
try {
await api.post(`/api/teacher-requests/${encodeURIComponent(requestId)}/approve`, payload);
showAlert('teacher-requests-alert', 'Преподаватель создан, заявка одобрена', 'success');
await loadRequests();
} catch (error) {
showAlert('teacher-requests-alert', error.message || 'Ошибка одобрения заявки', 'error');
}
}
async function rejectRequest(requestId) {
const reviewComment = await promptAction({
title: 'Отклонить заявку?',
message: 'Комментарий увидит кафедра, отправившая заявку.',
confirmLabel: 'Отклонить',
danger: true,
input: {
label: 'Причина отклонения',
placeholder: 'Укажите, что нужно исправить',
required: true,
requiredMessage: 'Укажите причину отклонения'
}
});
if (reviewComment === null) return;
try {
await api.post(`/api/teacher-requests/${encodeURIComponent(requestId)}/reject`, { reviewComment });
showAlert('teacher-requests-alert', 'Заявка отклонена', 'success');
await loadRequests();
} catch (error) {
showAlert('teacher-requests-alert', error.message || 'Ошибка отклонения заявки', 'error');
}
}
refreshButton?.addEventListener('click', loadRequests);
searchInput.addEventListener('input', debounce(() => {
state.query = searchInput.value.trim();
state.page = 0;
loadRequests();
}));
statusSelect.addEventListener('change', () => {
state.status = statusSelect.value;
state.page = 0;
loadRequests();
});
requestsTbody.addEventListener('click', async (event) => {
const approveButton = event.target.closest('.teacher-request-approve');
const rejectButton = event.target.closest('.teacher-request-reject');
if (!approveButton && !rejectButton) return;
const row = event.target.closest('tr[data-request-id]');
const requestId = row?.dataset.requestId;
if (!requestId) return;
if (approveButton) {
await approveRequest(row, requestId);
return;
}
await rejectRequest(requestId);
});
try {
await loadDepartments();
await loadRequests();
} catch (error) {
renderTableMessage(requestsTbody, 7, 'Не удалось загрузить заявки преподавателей');
}
function persistState() {
writeUrlState({
requestQuery: state.query,
requestStatus: state.status === 'PENDING' ? '' : state.status,
requestPage: state.page || '',
requestSize: state.size === 25 ? '' : state.size
});
}
}

View File

@@ -1,563 +0,0 @@
import { renderPagination } from '../pagination.js';
import { debounce } from '../url-state.js';
import { api } from '../api.js';
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
import { confirmAction } from '../dialog.js';
export async function initUniversityStructure() {
const deptTbody = document.getElementById('departments-tbody');
const specTbody = document.getElementById('specialties-tbody');
const createDeptForm = document.getElementById('create-department-form');
const createSpecForm = document.getElementById('create-specialty-form');
const editDeptForm = document.getElementById('edit-department-form');
const editSpecForm = document.getElementById('edit-specialty-form');
const editDeptModal = document.getElementById('modal-edit-department');
const editSpecModal = document.getElementById('modal-edit-specialty');
const editDeptClose = document.getElementById('modal-edit-department-close');
const editSpecClose = document.getElementById('modal-edit-specialty-close');
// Элементы вкладок
const tabDepartments = document.getElementById('btn-tab-departments');
const tabSpecialties = document.getElementById('btn-tab-specialties');
const tabProfiles = document.getElementById('btn-tab-profiles');
const contentDepartments = document.getElementById('tab-content-departments');
const contentSpecialties = document.getElementById('tab-content-specialties');
const contentProfiles = document.getElementById('tab-content-profiles');
// Элементы модалки профилей
const profilesModal = document.getElementById('modal-manage-profiles');
const profilesModalClose = document.getElementById('modal-manage-profiles-close');
const profilesSpecContext = document.getElementById('profiles-spec-context');
const profileFormTitle = document.getElementById('profile-form-title');
const manageProfileForm = document.getElementById('manage-profile-form');
const profileIdInput = document.getElementById('manage-profile-id');
const profileSpecIdInput = document.getElementById('manage-profile-spec-id');
const profileNameInput = document.getElementById('manage-profile-name');
const profileDescInput = document.getElementById('manage-profile-description');
const btnSaveProfile = document.getElementById('btn-save-profile');
const btnCancelProfileEdit = document.getElementById('btn-cancel-profile-edit');
const profilesTbody = document.getElementById('manage-profiles-tbody');
// Элементы вкладки Профилей
const tabProfileTbody = document.getElementById('profiles-tbody');
const createProfileTabForm = document.getElementById('create-profile-tab-form');
const tabProfileIdInput = document.getElementById('tab-profile-id');
const tabProfileSpecSelect = document.getElementById('tab-profile-spec');
const tabProfileNameInput = document.getElementById('tab-profile-name');
const tabProfileDescInput = document.getElementById('tab-profile-description');
const mainProfileFormTitle = document.getElementById('main-profile-form-title');
const btnTabSaveProfile = document.getElementById('btn-tab-save-profile');
const btnTabCancelProfileEdit = document.getElementById('btn-tab-cancel-profile-edit');
let departments = [];
let specialties = [];
let currentProfiles = [];
let allProfiles = [];
const listStates = Object.fromEntries(['departments', 'specialties', 'profiles'].map(key => [key, { page: 0, size: 25 }]));
const listRenderers = { departments: renderDepartments, specialties: renderSpecialties, profiles: renderAllProfiles };
Object.keys(listStates).forEach(key => {
document.getElementById(`${key}-search`)?.addEventListener('input', debounce(() => {
listStates[key].page = 0;
listRenderers[key]();
}, 250));
});
function listPage(key, items, fields) {
const query = document.getElementById(`${key}-search`).value.trim().toLocaleLowerCase('ru');
const filtered = items.filter(item => fields(item).join(' ').toLocaleLowerCase('ru').includes(query));
const state = listStates[key];
const totalPages = Math.ceil(filtered.length / state.size);
state.page = Math.min(state.page, Math.max(0, totalPages - 1));
renderPagination(document.getElementById(`${key}-pagination`), {
...state, totalItems: filtered.length, totalPages
}, {
onPage: value => { state.page = value; listRenderers[key](); },
onSize: value => { state.size = value; state.page = 0; listRenderers[key](); }
});
return filtered.slice(state.page * state.size, (state.page + 1) * state.size);
}
// --- Логика переключения табов ---
const structureTabs = [
{ button: tabDepartments, panel: contentDepartments },
{ button: tabSpecialties, panel: contentSpecialties },
{ button: tabProfiles, panel: contentProfiles },
];
function activateStructureTab(activeButton) {
structureTabs.forEach(({ button, panel }) => {
const active = button === activeButton;
button?.classList.toggle('active', active);
button?.setAttribute('aria-selected', active ? 'true' : 'false');
if (panel) {
panel.classList.toggle('active', active);
panel.hidden = !active;
}
});
}
structureTabs.forEach(({ button }) => {
button?.addEventListener('click', () => activateStructureTab(button));
});
// --- Загрузка данных ---
async function loadData() {
// Загрузка кафедр
try {
departments = await api.get('/api/departments');
renderDepartments();
} catch (e) {
deptTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Ошибка загрузки</td></tr>';
}
// Загрузка специальностей
try {
specialties = await api.get('/api/specialties');
renderSpecialties();
populateSpecialtiesDropdown();
} catch (e) {
specTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Ошибка загрузки</td></tr>';
}
// Загрузка всех профилей
try {
allProfiles = await api.get('/api/specialties/profiles');
renderAllProfiles();
} catch (e) {
tabProfileTbody.innerHTML = '<tr><td colspan="5" class="loading-row">Ошибка загрузки</td></tr>';
}
}
function renderDepartments() {
const visible = listPage('departments', departments || [], d => [d.id, d.departmentName || d.name, d.departmentCode ?? d.code]);
if (!visible.length) {
deptTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Ничего не найдено</td></tr>';
return;
}
deptTbody.innerHTML = visible.map(d => `
<tr>
<td>${d.id}</td>
<td>${escapeHtml(d.departmentName || d.name)}</td>
<td>${escapeHtml(String(d.departmentCode || d.code))}</td>
<td>
<button class="btn btn-sm btn-secondary btn-edit-department" data-id="${d.id}">Изменить</button>
<button class="btn btn-sm btn-danger btn-delete-department" data-id="${d.id}">Удалить</button>
</td>
</tr>
`).join('');
}
function renderSpecialties() {
const visible = listPage('specialties', specialties || [], d => [d.id, d.specialityName || d.name, d.specialityCode || d.specialtyCode || d.specialty_code]);
if (!visible.length) {
specTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Ничего не найдено</td></tr>';
return;
}
specTbody.innerHTML = visible.map(s => `
<tr>
<td>${s.id}</td>
<td>${escapeHtml(s.specialityName || s.name)}</td>
<td>${escapeHtml(s.specialityCode || s.specialtyCode || s.specialty_code)}</td>
<td>
<button class="btn btn-sm btn-secondary btn-edit-specialty" data-id="${s.id}">Изменить</button>
<button class="btn btn-sm btn-secondary btn-manage-profiles" data-id="${s.id}">Профили</button>
<button class="btn btn-sm btn-danger btn-delete-specialty" data-id="${s.id}">Удалить</button>
</td>
</tr>
`).join('');
}
// --- Кафедры: CRUD ---
createDeptForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideAlert('create-dept-alert');
const name = document.getElementById('dept-name').value.trim();
const code = document.getElementById('dept-code').value.trim();
if (!name || !code) {
showAlert('create-dept-alert', 'Заполните все поля', 'error');
return;
}
try {
await api.post('/api/departments', { departmentName: name, departmentCode: Number(code) });
showAlert('create-dept-alert', `Кафедра "${name}" создана`, 'success');
createDeptForm.reset();
loadData();
} catch (error) {
showAlert('create-dept-alert', error.message || 'Ошибка создания кафедры', 'error');
}
});
deptTbody.addEventListener('click', async (e) => {
const editBtn = e.target.closest('.btn-edit-department');
const deleteBtn = e.target.closest('.btn-delete-department');
if (editBtn) {
const department = departments.find(item => item.id == editBtn.dataset.id);
if (!department) return;
document.getElementById('edit-dept-id').value = department.id;
document.getElementById('edit-dept-name').value = department.departmentName || department.name || '';
document.getElementById('edit-dept-code').value = department.departmentCode || department.code || '';
hideAlert('edit-dept-alert');
editDeptModal.classList.add('open');
return;
}
if (deleteBtn) {
if (!await confirmAction({ title: 'Удаление кафедры', message: 'Удалить кафедру?', confirmLabel: 'Удалить', danger: true })) return;
try {
await api.delete('/api/departments/' + deleteBtn.dataset.id);
showAlert('create-dept-alert', 'Кафедра удалена', 'success');
await loadData();
} catch (error) {
showAlert('create-dept-alert', error.message || 'Ошибка удаления кафедры', 'error');
}
}
});
editDeptForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideAlert('edit-dept-alert');
const id = document.getElementById('edit-dept-id').value;
const name = document.getElementById('edit-dept-name').value.trim();
const code = document.getElementById('edit-dept-code').value.trim();
if (!name || !code) {
showAlert('edit-dept-alert', 'Заполните все поля', 'error');
return;
}
try {
await api.put('/api/departments/' + id, { departmentName: name, departmentCode: Number(code) });
editDeptModal.classList.remove('open');
showAlert('create-dept-alert', `Кафедра "${name}" обновлена`, 'success');
await loadData();
} catch (error) {
showAlert('edit-dept-alert', error.message || 'Ошибка обновления кафедры', 'error');
}
});
// --- Специальности: CRUD ---
createSpecForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideAlert('create-spec-alert');
const name = document.getElementById('spec-name').value.trim();
const code = document.getElementById('spec-code').value.trim();
if (!name || !code) {
showAlert('create-spec-alert', 'Заполните все поля', 'error');
return;
}
try {
await api.post('/api/specialties', { specialityName: name, specialityCode: code });
showAlert('create-spec-alert', `Специальность "${name}" создана`, 'success');
createSpecForm.reset();
loadData();
} catch (error) {
showAlert('create-spec-alert', error.message || 'Ошибка создания специальности', 'error');
}
});
specTbody.addEventListener('click', async (e) => {
const editBtn = e.target.closest('.btn-edit-specialty');
const deleteBtn = e.target.closest('.btn-delete-specialty');
const profilesBtn = e.target.closest('.btn-manage-profiles');
if (editBtn) {
const speciality = specialties.find(item => item.id == editBtn.dataset.id);
if (!speciality) return;
document.getElementById('edit-spec-id').value = speciality.id;
document.getElementById('edit-spec-name').value = speciality.specialityName || speciality.name || '';
document.getElementById('edit-spec-code').value = speciality.specialityCode || speciality.specialtyCode || speciality.specialty_code || '';
hideAlert('edit-spec-alert');
editSpecModal.classList.add('open');
return;
}
if (deleteBtn) {
if (!await confirmAction({ title: 'Удаление специальности', message: 'Удалить специальность?', confirmLabel: 'Удалить', danger: true })) return;
try {
await api.delete('/api/specialties/' + deleteBtn.dataset.id);
showAlert('create-spec-alert', 'Специальность удалена', 'success');
await loadData();
} catch (error) {
showAlert('create-spec-alert', error.message || 'Ошибка удаления специальности', 'error');
}
}
if (profilesBtn) {
const specId = profilesBtn.dataset.id;
const speciality = specialties.find(item => item.id == specId);
if (!speciality) return;
openProfilesModal(speciality);
}
});
editSpecForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideAlert('edit-spec-alert');
const id = document.getElementById('edit-spec-id').value;
const name = document.getElementById('edit-spec-name').value.trim();
const code = document.getElementById('edit-spec-code').value.trim();
if (!name || !code) {
showAlert('edit-spec-alert', 'Заполните все поля', 'error');
return;
}
try {
await api.put('/api/specialties/' + id, { specialityName: name, specialityCode: code });
editSpecModal.classList.remove('open');
showAlert('create-spec-alert', `Специальность "${name}" обновлена`, 'success');
await loadData();
} catch (error) {
showAlert('edit-spec-alert', error.message || 'Ошибка обновления специальности', 'error');
}
});
// --- Профили обучения: Логика ---
async function openProfilesModal(speciality) {
profilesSpecContext.textContent = `${speciality.specialityCode || speciality.specialtyCode || speciality.specialty_code} · ${speciality.specialityName || speciality.name}`;
profileSpecIdInput.value = speciality.id;
resetProfileForm();
profilesModal.classList.add('open');
await loadProfiles(speciality.id);
}
async function loadProfiles(specId) {
profilesTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Загрузка...</td></tr>';
try {
currentProfiles = await api.get(`/api/specialties/${specId}/profiles`);
renderProfilesTable();
} catch (e) {
profilesTbody.innerHTML = `<tr><td colspan="4" class="loading-row">Ошибка загрузки: ${escapeHtml(e.message)}</td></tr>`;
}
}
function renderProfilesTable() {
if (!currentProfiles || !currentProfiles.length) {
profilesTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Профили не созданы</td></tr>';
return;
}
profilesTbody.innerHTML = currentProfiles.map(p => `
<tr>
<td>${p.id}</td>
<td>${escapeHtml(p.name)}</td>
<td>${escapeHtml(p.description || '-')}</td>
<td>
<button class="btn btn-sm btn-secondary btn-edit-profile" data-id="${p.id}">Изменить</button>
<button class="btn btn-sm btn-danger btn-delete-profile" data-id="${p.id}">Удалить</button>
</td>
</tr>
`).join('');
}
function resetProfileForm() {
manageProfileForm.reset();
profileIdInput.value = '';
profileFormTitle.textContent = 'Создание профиля';
btnSaveProfile.textContent = 'Создать';
btnCancelProfileEdit.hidden = true;
hideAlert('manage-profile-alert');
}
manageProfileForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideAlert('manage-profile-alert');
const specId = profileSpecIdInput.value;
const profileId = profileIdInput.value;
const name = profileNameInput.value.trim();
const description = profileDescInput.value.trim();
if (!name) {
showAlert('manage-profile-alert', 'Введите название профиля', 'error');
return;
}
try {
if (profileId) {
// Обновление
await api.put(`/api/specialties/${specId}/profiles/${profileId}`, { id: Number(profileId), name, description });
showAlert('manage-profile-alert', `Профиль "${name}" обновлен`, 'success');
} else {
// Создание
await api.post(`/api/specialties/${specId}/profiles`, { name, description });
showAlert('manage-profile-alert', `Профиль "${name}" создан`, 'success');
}
resetProfileForm();
await loadProfiles(specId);
} catch (error) {
showAlert('manage-profile-alert', error.message || 'Ошибка сохранения профиля', 'error');
}
});
profilesTbody.addEventListener('click', async (e) => {
const editBtn = e.target.closest('.btn-edit-profile');
const deleteBtn = e.target.closest('.btn-delete-profile');
const specId = profileSpecIdInput.value;
if (editBtn) {
const profile = currentProfiles.find(item => item.id == editBtn.dataset.id);
if (!profile) return;
profileIdInput.value = profile.id;
profileNameInput.value = profile.name || '';
profileDescInput.value = profile.description || '';
profileFormTitle.textContent = 'Редактирование профиля';
btnSaveProfile.textContent = 'Сохранить';
btnCancelProfileEdit.hidden = false;
hideAlert('manage-profile-alert');
return;
}
if (deleteBtn) {
if (!await confirmAction({ title: 'Удаление профиля', message: 'Удалить профиль обучения?', confirmLabel: 'Удалить', danger: true })) return;
try {
await api.delete(`/api/specialties/${specId}/profiles/${deleteBtn.dataset.id}`);
showAlert('manage-profile-alert', 'Профиль удален', 'success');
await loadProfiles(specId);
} catch (error) {
showAlert('manage-profile-alert', error.message || 'Ошибка удаления профиля', 'error');
}
}
});
btnCancelProfileEdit.addEventListener('click', resetProfileForm);
// Закрытие модалок
editDeptClose.addEventListener('click', () => editDeptModal.classList.remove('open'));
editSpecClose.addEventListener('click', () => editSpecModal.classList.remove('open'));
profilesModalClose.addEventListener('click', () => profilesModal.classList.remove('open'));
editDeptModal.addEventListener('click', (e) => {
if (e.target === editDeptModal) editDeptModal.classList.remove('open');
});
editSpecModal.addEventListener('click', (e) => {
if (e.target === editSpecModal) editSpecModal.classList.remove('open');
});
profilesModal.addEventListener('click', (e) => {
if (e.target === profilesModal) profilesModal.classList.remove('open');
});
// --- Дополнительные функции для вкладки Профилей ---
function populateSpecialtiesDropdown() {
const currentValue = tabProfileSpecSelect.value;
tabProfileSpecSelect.innerHTML = '<option value="">Выберите специальность</option>' +
specialties.map(s => `<option value="${s.id}">${escapeHtml(s.specialityCode || s.specialtyCode)} · ${escapeHtml(s.specialityName || s.name)}</option>`).join('');
tabProfileSpecSelect.value = currentValue;
}
function renderAllProfiles() {
const visible = listPage('profiles', allProfiles || [], d => [d.id, d.name, d.description, d.specialityCode, d.specialityName]);
if (!visible.length) {
tabProfileTbody.innerHTML = '<tr><td colspan="5" class="loading-row">Ничего не найдено</td></tr>';
return;
}
tabProfileTbody.innerHTML = visible.map(p => `
<tr>
<td>${p.id}</td>
<td>${escapeHtml(p.specialityCode)} · ${escapeHtml(p.specialityName)}</td>
<td>${escapeHtml(p.name)}</td>
<td>${escapeHtml(p.description || '-')}</td>
<td>
<button class="btn btn-sm btn-secondary btn-edit-tab-profile" data-id="${p.id}">Изменить</button>
<button class="btn btn-sm btn-danger btn-delete-tab-profile" data-id="${p.id}">Удалить</button>
</td>
</tr>
`).join('');
}
function resetTabProfileForm() {
createProfileTabForm.reset();
tabProfileIdInput.value = '';
tabProfileSpecSelect.disabled = false;
mainProfileFormTitle.textContent = 'Создание профиля';
btnTabSaveProfile.textContent = 'Создать';
btnTabCancelProfileEdit.hidden = true;
hideAlert('tab-profile-alert');
tabProfileSpecSelect.dispatchEvent(new Event('change'));
}
createProfileTabForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideAlert('tab-profile-alert');
const specId = tabProfileSpecSelect.value;
const profileId = tabProfileIdInput.value;
const name = tabProfileNameInput.value.trim();
const description = tabProfileDescInput.value.trim();
if (!specId) {
showAlert('tab-profile-alert', 'Выберите специальность', 'error');
return;
}
if (!name) {
showAlert('tab-profile-alert', 'Введите название профиля', 'error');
return;
}
try {
if (profileId) {
await api.put(`/api/specialties/${specId}/profiles/${profileId}`, { id: Number(profileId), name, description });
showAlert('tab-profile-alert', `Профиль "${name}" обновлен`, 'success');
} else {
await api.post(`/api/specialties/${specId}/profiles`, { name, description });
showAlert('tab-profile-alert', `Профиль "${name}" создан`, 'success');
}
resetTabProfileForm();
await loadData();
} catch (error) {
showAlert('tab-profile-alert', error.message || 'Ошибка сохранения профиля', 'error');
}
});
tabProfileTbody.addEventListener('click', async (e) => {
const editBtn = e.target.closest('.btn-edit-tab-profile');
const deleteBtn = e.target.closest('.btn-delete-tab-profile');
if (editBtn) {
const profile = allProfiles.find(item => item.id == editBtn.dataset.id);
if (!profile) return;
tabProfileIdInput.value = profile.id;
tabProfileSpecSelect.value = profile.specialityId;
tabProfileSpecSelect.disabled = true;
tabProfileSpecSelect.dispatchEvent(new Event('change'));
tabProfileNameInput.value = profile.name || '';
tabProfileDescInput.value = profile.description || '';
mainProfileFormTitle.textContent = 'Редактирование профиля';
btnTabSaveProfile.textContent = 'Сохранить';
btnTabCancelProfileEdit.hidden = false;
hideAlert('tab-profile-alert');
createProfileTabForm.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
return;
}
if (deleteBtn) {
const profile = allProfiles.find(item => item.id == deleteBtn.dataset.id);
if (!profile) return;
if (!await confirmAction({ title: 'Удаление профиля', message: 'Удалить профиль обучения?', confirmLabel: 'Удалить', danger: true })) return;
try {
await api.delete(`/api/specialties/${profile.specialityId}/profiles/${profile.id}`);
showAlert('tab-profile-alert', 'Профиль удален', 'success');
await loadData();
} catch (error) {
showAlert('tab-profile-alert', error.message || 'Ошибка удаления профиля', 'error');
}
}
});
btnTabCancelProfileEdit.addEventListener('click', resetTabProfileForm);
await loadData();
}

View File

@@ -1,191 +0,0 @@
import { api } from '../api.js';
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
import { confirmAction } from '../dialog.js';
import { renderPagination } from '../pagination.js';
import { debounce, readUrlState, writeUrlState } from '../url-state.js';
import { markTableReady, renderTableEmpty, renderTableError, renderTableLoading } from '../view-state.js';
import { trackUxEvent } from '../../../telemetry.js';
const ROLE_LABELS = {
ADMIN: 'Администратор',
EDUCATION_OFFICE: 'Учебный отдел',
DEPARTMENT: 'Кафедра',
SCHEDULE_VIEWER: 'Просмотр расписаний',
TEACHER: 'Преподаватель',
STUDENT: 'Студент'
};
const ROLE_BADGE = {
ADMIN: 'badge-admin',
EDUCATION_OFFICE: 'badge-admin',
DEPARTMENT: 'badge-teacher',
SCHEDULE_VIEWER: 'badge-student',
TEACHER: 'badge-teacher',
STUDENT: 'badge-student'
};
export async function initUsers() {
const usersTbody = document.getElementById('users-tbody');
const createForm = document.getElementById('create-form');
const searchInput = document.getElementById('users-search');
const roleFilter = document.getElementById('users-role-filter');
const sortSelect = document.getElementById('users-sort');
const pagination = document.getElementById('users-pagination');
const restored = readUrlState({ userQuery: '', userRole: '', userPage: '0', userSize: '25', userSort: 'fullName,asc' });
const state = {
query: restored.userQuery,
role: restored.userRole,
page: Math.max(Number(restored.userPage) || 0, 0),
size: [25, 50, 100].includes(Number(restored.userSize)) ? Number(restored.userSize) : 25,
sort: restored.userSort
};
searchInput.value = state.query;
roleFilter.value = state.role;
sortSelect.value = state.sort;
async function loadUsers() {
renderTableLoading(usersTbody, 7, 'Загрузка пользователей...');
hideAlert('users-alert');
try {
const [sort, direction] = state.sort.split(',');
const params = new URLSearchParams({
query: state.query,
page: String(state.page),
size: String(state.size),
sort,
direction,
includeArchived: 'true'
});
if (state.role) params.set('role', state.role);
const result = await api.get(`/api/users/page?${params}`);
renderUsers(result.items || []);
renderPagination(pagination, result, {
label: 'пользователей',
onPage: page => { state.page = page; loadUsers(); },
onSize: size => { state.size = size; state.page = 0; loadUsers(); }
});
persistState();
trackUxEvent('реестр_пользователей_загружен', { total: Number(result.totalItems || 0), page: state.page });
} catch (e) {
renderTableError(usersTbody, 7, e.message || 'Не удалось загрузить пользователей', loadUsers);
}
}
function renderUsers(users) {
if (!users || !users.length) {
renderTableEmpty(usersTbody, 7, state.query || state.role ? 'Пользователи не найдены' : 'Пользователей пока нет');
return;
}
usersTbody.innerHTML = users.map(u => `
<tr>
<td>${u.id}</td>
<td>${escapeHtml(u.username)}</td>
<td>${escapeHtml(u.fullName || '-')}</td>
<td>${escapeHtml(u.jobTitle || '-')}</td>
<td>${escapeHtml(u.departmentName || '-')}</td>
<td><span class="badge ${ROLE_BADGE[u.role] || ''}">${ROLE_LABELS[u.role] || escapeHtml(u.role)}</span></td>
<td>
${u.status === 'ARCHIVED'
? `<button class="btn btn-sm btn-secondary btn-restore-user" data-id="${u.id}">Восстановить</button>`
: `<button class="btn btn-sm btn-danger-subtle btn-archive-user" data-id="${u.id}">Архивировать</button>`}
</td>
</tr>
`).join('');
markTableReady(usersTbody);
}
searchInput.addEventListener('input', debounce(() => {
state.query = searchInput.value.trim();
state.page = 0;
loadUsers();
}));
roleFilter.addEventListener('change', () => {
state.role = roleFilter.value;
state.page = 0;
loadUsers();
});
sortSelect.addEventListener('change', () => {
state.sort = sortSelect.value;
state.page = 0;
loadUsers();
});
createForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideAlert('create-alert');
const username = document.getElementById('new-username').value.trim();
const password = document.getElementById('new-password').value;
const role = document.getElementById('new-role').value;
const fullName = document.getElementById('new-fullname').value.trim();
const jobTitle = document.getElementById('new-jobtitle').value.trim();
const departmentId = document.getElementById('new-department').value;
if (!username || !password || !fullName || !jobTitle || !departmentId) {
showAlert('create-alert', 'Заполните все поля', 'error');
return;
}
if (password.length < 8) {
showAlert('create-alert', 'Пароль должен быть не короче 8 символов', 'error');
return;
}
try {
const data = await api.post('/api/users', {
username,
password,
role,
fullName,
jobTitle,
departmentId: Number(departmentId)
});
showAlert('create-alert', `Пользователь "${escapeHtml(data.username)}" создан`, 'success');
createForm.reset();
loadUsers();
} catch (err) {
showAlert('create-alert', err.message || 'Ошибка соединения', 'error');
}
});
usersTbody.addEventListener('click', async (e) => {
const deleteBtn = e.target.closest('.btn-archive-user');
if (!deleteBtn) return;
if (!await confirmAction({
title: 'Архивировать пользователя?',
message: 'Доступ будет отключён, а история расписания и связей сохранится.',
confirmLabel: 'Архивировать',
danger: true
})) return;
try {
await api.delete('/api/users/' + deleteBtn.dataset.id);
loadUsers();
} catch (err) {
showAlert('users-alert', err.message || 'Ошибка архивирования', 'error');
}
});
usersTbody.addEventListener('click', async (e) => {
const restoreBtn = e.target.closest('.btn-restore-user');
if (!restoreBtn) return;
try {
await api.post('/api/users/' + restoreBtn.dataset.id + '/restore', {});
loadUsers();
} catch (err) {
showAlert('users-alert', err.message || 'Ошибка восстановления', 'error');
}
});
function persistState() {
writeUrlState({
userQuery: state.query,
userRole: state.role,
userPage: state.page || '',
userSize: state.size === 25 ? '' : state.size,
userSort: state.sort === 'fullName,asc' ? '' : state.sort
});
}
await loadUsers();
}

View File

@@ -1,3 +1,13 @@
/* ===== Корневой контейнер ===== */
/* body — flex-контейнер: без явного растяжения корневой div сжимается
до max-content, и узкие страницы не занимают всю ширину монитора.
Растягиваем корень ровно на ширину окна (flex-basis 0), а не под контент. */
#settings-root {
flex: 1 1 0%;
min-width: 0;
}
/* ===== Sidebar ===== */
.sidebar {
width: 260px;

View File

@@ -360,6 +360,46 @@ body {
padding: 1.25rem !important;
}
/* Карточка статуса текущего подключения к БД (замена inline-стилей legacy) */
.db-status-card {
display: flex;
align-items: flex-start;
}
.db-status-grid {
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
width: 100%;
}
.db-status-grid > div {
display: flex;
align-items: center;
gap: 0.4rem;
flex-wrap: wrap;
}
.db-status-grid span {
color: var(--text-secondary);
font-size: 0.85rem;
}
.db-status-grid code {
font-size: 0.85rem;
}
/* Широкое поле формы и выравнивание кнопок действий в строке формы */
.form-group-wide {
flex: 3;
}
.form-row-actions {
height: fit-content;
}
.scope-badge {
display: inline-flex;
align-items: center;

View File

@@ -12,95 +12,9 @@
</head>
<body>
<!-- Боковая панель -->
<aside class="sidebar">
<div class="sidebar-header">
<div class="logo">
<svg width="32" height="32" viewBox="0 0 40 40" fill="none">
<rect width="40" height="40" rx="12" fill="url(#lg)" />
<path d="M12 20L18 26L28 14" stroke="#fff" stroke-width="3" stroke-linecap="round"
stroke-linejoin="round" />
<defs>
<linearGradient id="lg" x1="0" y1="0" x2="40" y2="40">
<stop stop-color="#6366f1" />
<stop offset="1" stop-color="#8b5cf6" />
</linearGradient>
</defs>
</svg>
<span>Настройки</span>
</div>
<button class="btn btn-icon-sm btn-ghost sidebar-close-btn" id="sidebar-close-btn" aria-label="Скрыть панель">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="15 18 9 12 15 6" />
</svg>
</button>
</div>
<nav class="sidebar-nav">
<a href="#" class="nav-item" data-tab="general">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
<span>Общие настройки</span>
</a>
<a href="#" class="nav-item" data-tab="time-slots">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="9" />
<polyline points="12 7 12 12 16 14" />
<path d="M4 4l3 3" />
<path d="M20 4l-3 3" />
</svg>
<span>Временные слоты</span>
</a>
<a href="#" class="nav-item" data-tab="edu-forms">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
<line x1="9" y1="7" x2="17" y2="7" />
<line x1="9" y1="11" x2="15" y2="11" />
</svg>
<span>Формы обучения</span>
</a>
<a href="#" class="nav-item" data-tab="database">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<ellipse cx="12" cy="5" rx="9" ry="3"></ellipse>
<path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"></path>
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"></path>
</svg>
<span>База данных</span>
</a>
</nav>
<div class="sidebar-footer">
<a href="/admin/" class="btn-back">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<line x1="19" y1="12" x2="5" y2="12" />
<polyline points="12 19 5 12 12 5" />
</svg>
<span>Назад в панель</span>
</a>
</div>
</aside>
<div id="settings-root"></div>
<!-- Затемнение боковой панели на мобильных -->
<div class="sidebar-overlay" id="sidebar-overlay"></div>
<!-- Основная область -->
<main class="main">
<header class="topbar">
<h1 id="page-title">Загрузка...</h1>
</header>
<section class="content" id="app-content">
<!-- Контент загружается через JS -->
</section>
</main>
<script src="/theme-toggle.js"></script>
<script type="module" src="js/main.js"></script>
<script type="module" src="/react/settings.js"></script>
</body>
</html>

View File

@@ -1,133 +0,0 @@
// Основной модуль страницы настроек
import { startDropdownAutoObserver, initAllCustomDropdowns } from '../../js/dropdown.js';
import { renderStatusMessage } from '../../js/utils.js';
import { SETTINGS_ROLES, capabilitiesForRole } from '../../js/role-capabilities.js';
import { initializeSession } from '../../js/api.js';
async function bootstrap() {
const session = await initializeSession();
if (!session || !SETTINGS_ROLES.includes(session.role)) {
window.location.replace('/');
return;
}
const role = session.role;
// Глобальная инициализация кастомных списков
document.addEventListener('DOMContentLoaded', () => {
initAllCustomDropdowns(document.body);
startDropdownAutoObserver();
});
// Настройка вкладок
const ROUTES = {
general: { title: 'Общие настройки', file: 'views/general.html' },
'time-slots': {
title: 'Временные слоты',
file: 'views/time-slots.html',
init: async () => {
const module = await import('./views/time-slots.js');
await module.initTimeSlotsSettings();
}
},
database: {
title: 'База данных',
file: 'views/database.html',
init: async () => {
const module = await import('./views/database.js');
await module.initDatabase();
}
},
'edu-forms': {
title: 'Формы обучения',
file: 'views/edu-forms.html',
init: async () => {
const module = await import('./views/edu-forms.js');
await module.initEduForms();
}
},
};
const roleCapabilities = capabilitiesForRole(role);
const allowedTabs = new Set(roleCapabilities.settingsTabs);
const defaultTab = roleCapabilities.defaultSettingsTab;
let currentTab = null;
// DOM-элементы
const appContent = document.getElementById('app-content');
const pageTitle = document.getElementById('page-title');
const navItems = document.querySelectorAll('.nav-item[data-tab]');
const sidebar = document.querySelector('.sidebar');
const sidebarOverlay = document.getElementById('sidebar-overlay');
const sidebarCloseBtn = document.getElementById('sidebar-close-btn');
const main = document.querySelector('.main');
// Состояние боковой панели из localStorage
if (window.innerWidth > 768 && localStorage.getItem('sidebar-collapsed') === 'true') {
sidebar.classList.add('collapsed');
main.classList.add('sidebar-collapsed');
}
// Сворачивание боковой панели
sidebarCloseBtn?.addEventListener('click', () => {
if (window.innerWidth <= 768) {
sidebar.classList.remove('open');
sidebarOverlay.classList.remove('open');
} else {
sidebar.classList.toggle('collapsed');
main.classList.toggle('sidebar-collapsed');
localStorage.setItem('sidebar-collapsed', sidebar.classList.contains('collapsed'));
}
});
sidebarOverlay.addEventListener('click', () => {
sidebar.classList.remove('open');
sidebarOverlay.classList.remove('open');
});
// Навигация
navItems.forEach(item => {
item.hidden = !allowedTabs.has(item.dataset.tab);
item.addEventListener('click', (e) => {
e.preventDefault();
const tab = item.dataset.tab;
switchTab(tab);
});
});
async function switchTab(tab) {
if (currentTab === tab || !ROUTES[tab] || !allowedTabs.has(tab)) return;
navItems.forEach(n => n.classList.remove('active'));
document.querySelector(`.nav-item[data-tab="${tab}"]`)?.classList.add('active');
pageTitle.textContent = ROUTES[tab].title;
try {
appContent.innerHTML = '<div style="text-align:center;color:var(--text-secondary);padding:2rem;">Загрузка...</div>';
const response = await fetch(ROUTES[tab].file);
if (!response.ok) throw new Error('Не удалось загрузить вкладку');
const html = await response.text();
appContent.innerHTML = html;
initAllCustomDropdowns(appContent);
if (ROUTES[tab].init) {
await ROUTES[tab].init();
}
currentTab = tab;
} catch (e) {
renderStatusMessage(appContent, 'Не удалось загрузить раздел настроек');
console.error('Не удалось загрузить раздел настроек');
}
// Закрываем мобильное меню после перехода
sidebar.classList.remove('open');
sidebarOverlay.classList.remove('open');
}
// Вкладка по умолчанию
switchTab(defaultTab);
}
await bootstrap();

View File

@@ -1,163 +0,0 @@
import { api } from '../../../js/api.js';
import { escapeHtml, showAlert, hideAlert, renderStatusMessage, renderTableMessage } from '../../../js/utils.js';
import { alertAction, confirmAction } from '../../../js/dialog.js';
export async function initDatabase() {
const tenantsTbody = document.getElementById('tenants-tbody');
const addTenantForm = document.getElementById('add-tenant-form');
const statusInfo = document.getElementById('db-status-info');
const btnTest = document.getElementById('btn-test-connection');
// === Загрузка статуса текущего подключения ===
async function loadStatus() {
try {
const data = await api.get('/api/database/status');
const statusBadge = data.connected
? '<span class="badge badge-available">Доступно</span>'
: '<span class="badge badge-unavailable">Недоступно</span>';
statusInfo.innerHTML = `
<div style="display: flex; align-items: center; gap: 1rem; flex-wrap: wrap;">
<div>
<span style="color: var(--text-secondary); font-size: 0.85rem;">Тенант:</span>
<strong>${escapeHtml(data.tenant || '—')}</strong>
</div>
<div>
<span style="color: var(--text-secondary); font-size: 0.85rem;">Название:</span>
<strong>${escapeHtml(data.name || '—')}</strong>
</div>
<div>
<span style="color: var(--text-secondary); font-size: 0.85rem;">Статус:</span>
${statusBadge}
</div>
${data.url ? `<div>
<span style="color: var(--text-secondary); font-size: 0.85rem;">URL:</span>
<code style="font-size: 0.85rem;">${escapeHtml(data.url)}</code>
</div>` : ''}
</div>
`;
} catch (e) {
renderStatusMessage(statusInfo, 'Не удалось загрузить статус подключения');
}
}
// === Загрузка списка тенантов ===
async function loadTenants() {
try {
const tenants = await api.get('/api/database/tenants');
renderTenantsTable(tenants);
} catch (e) {
renderTableMessage(tenantsTbody, 6, 'Не удалось загрузить список тенантов');
}
}
function renderTenantsTable(tenants) {
if (!tenants || !tenants.length) {
tenantsTbody.innerHTML = '<tr><td colspan="6" class="loading-row">Нет подключённых тенантов</td></tr>';
return;
}
tenantsTbody.innerHTML = tenants.map(t => {
const statusBadge = t.connected
? '<span class="badge badge-available">Доступно</span>'
: '<span class="badge badge-unavailable">Недоступно</span>';
return `
<tr>
<td>${escapeHtml(t.name || '—')}</td>
<td><code>${escapeHtml(t.domain)}</code></td>
<td><code style="font-size: 0.82rem;">${escapeHtml(t.url)}</code></td>
<td>${escapeHtml(t.username || '—')}</td>
<td>${statusBadge}</td>
<td><button class="btn btn-sm btn-danger btn-delete-tenant" data-domain="${escapeHtml(t.domain)}">Удалить</button></td>
</tr>`;
}).join('');
}
// === Тест подключения ===
btnTest.addEventListener('click', async () => {
hideAlert('add-tenant-alert');
const url = document.getElementById('tenant-url').value.trim();
const username = document.getElementById('tenant-username').value.trim();
const password = document.getElementById('tenant-password').value;
if (!url) {
showAlert('add-tenant-alert', 'Введите JDBC URL', 'error');
return;
}
btnTest.textContent = '...';
btnTest.disabled = true;
try {
const result = await api.post('/api/database/test', { url, username, password });
if (result.success) {
showAlert('add-tenant-alert', '✓ Подключение успешно!', 'success');
} else {
showAlert('add-tenant-alert', `✗ ${result.message}`, 'error');
}
} catch (e) {
showAlert('add-tenant-alert', 'Не удалось проверить подключение', 'error');
} finally {
btnTest.textContent = 'Тест';
btnTest.disabled = false;
}
});
// === Добавление тенанта ===
addTenantForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideAlert('add-tenant-alert');
const name = document.getElementById('tenant-name').value.trim();
const domain = document.getElementById('tenant-domain').value.trim().toLowerCase();
const url = document.getElementById('tenant-url').value.trim();
const username = document.getElementById('tenant-username').value.trim();
const password = document.getElementById('tenant-password').value;
if (!name || !domain || !url) {
showAlert('add-tenant-alert', 'Заполните все обязательные поля', 'error');
return;
}
try {
const result = await api.post('/api/database/tenants', { name, domain, url, username, password });
if (result.success) {
showAlert('add-tenant-alert', `Тенант «${domain}» добавлен`, 'success');
addTenantForm.reset();
loadTenants();
loadStatus();
} else {
showAlert('add-tenant-alert', result.message, 'error');
}
} catch (e) {
showAlert('add-tenant-alert', 'Не удалось добавить тенант', 'error');
}
});
// === Удаление тенанта ===
tenantsTbody.addEventListener('click', async (e) => {
const btn = e.target.closest('.btn-delete-tenant');
if (!btn) return;
const domain = btn.dataset.domain;
if (!await confirmAction({
title: 'Удаление тенанта',
message: `Удалить тенант «${domain}»? Пул соединений будет закрыт.`,
confirmLabel: 'Удалить тенант',
danger: true
})) return;
try {
await api.delete(`/api/database/tenants/${domain}`);
loadTenants();
loadStatus();
} catch (e) {
await alertAction({ title: 'Ошибка удаления', message: e.message || 'Не удалось удалить тенант' });
}
});
// === Init ===
loadStatus();
loadTenants();
}

View File

@@ -1,72 +0,0 @@
import { api } from '../../../js/api.js';
import { escapeHtml, showAlert, hideAlert } from '../../../js/utils.js';
import { alertAction, confirmAction } from '../../../js/dialog.js';
export let allEducationForms = [];
export async function fetchEducationForms() {
try {
allEducationForms = await api.get('/api/education-forms');
return allEducationForms;
} catch (e) {
console.error('Не удалось загрузить формы обучения');
return [];
}
}
export async function initEduForms() {
const efTbody = document.getElementById('ef-tbody');
const createEfForm = document.getElementById('create-ef-form');
async function loadEF() {
try {
const forms = await fetchEducationForms();
renderEfTable(forms);
} catch (e) {
efTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Ошибка загрузки</td></tr>';
}
}
function renderEfTable(forms) {
if (!forms || !forms.length) {
efTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Нет форм обучения</td></tr>';
return;
}
efTbody.innerHTML = forms.map(ef => `
<tr>
<td>${ef.id}</td>
<td>${escapeHtml(ef.name)}</td>
<td><button class="btn btn-sm btn-danger btn-delete-edu-form" data-id="${ef.id}">Удалить</button></td>
</tr>`).join('');
}
createEfForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideAlert('create-ef-alert');
const name = document.getElementById('new-ef-name').value.trim();
if (!name) { showAlert('create-ef-alert', 'Введите название', 'error'); return; }
try {
const data = await api.post('/api/education-forms', { name });
showAlert('create-ef-alert', `Форма "${escapeHtml(data.name)}" создана`, 'success');
createEfForm.reset();
loadEF();
} catch (e) {
showAlert('create-ef-alert', e.message || 'Ошибка создания', 'error');
}
});
efTbody.addEventListener('click', async (e) => {
const btn = e.target.closest('.btn-delete-edu-form');
if (!btn) return;
if (!await confirmAction({ title: 'Удаление формы обучения', message: 'Удалить форму обучения?', confirmLabel: 'Удалить', danger: true })) return;
try {
await api.delete('/api/education-forms/' + btn.dataset.id);
loadEF();
} catch (e) {
await alertAction({ title: 'Ошибка удаления', message: e.message || 'Не удалось удалить форму обучения' });
}
});
loadEF();
}

View File

@@ -1,323 +0,0 @@
import { api } from '../../../js/api.js';
import { escapeHtml, hideAlert, showAlert } from '../../../js/utils.js';
import { confirmAction } from '../../../js/dialog.js';
const APPLY_MODE_LABELS = {
DEFAULT: 'применяется по умолчанию',
WEEKDAY: 'применяется автоматически',
MANUAL: 'применяется вручную'
};
const DAY_SHORT_LABELS = {
1: 'Пн',
2: 'Вт',
3: 'Ср',
4: 'Чт',
5: 'Пт',
6: 'Сб',
7: 'Вс'
};
export async function initTimeSlotsSettings() {
const scopeSelect = document.getElementById('time-slot-scope-select');
const scopeSummary = document.getElementById('time-slot-scope-summary');
const scopeOpenCreateButton = document.getElementById('time-slot-scope-open-create');
const scopeModal = document.getElementById('time-slot-scope-modal');
const scopeModalCloseButton = document.getElementById('time-slot-scope-modal-close');
const scopeModalCancelButton = document.getElementById('time-slot-scope-modal-cancel');
const scopeForm = document.getElementById('time-slot-scope-form');
const scopeNameInput = document.getElementById('time-slot-scope-name');
const scopeDeleteButton = document.getElementById('time-slot-scope-delete');
const form = document.getElementById('time-slot-form');
const title = document.getElementById('time-slot-form-title');
const idInput = document.getElementById('time-slot-id');
const orderInput = document.getElementById('time-slot-order');
const startInput = document.getElementById('time-slot-start');
const endInput = document.getElementById('time-slot-end');
const durationInput = document.getElementById('time-slot-duration');
const resetButton = document.getElementById('time-slot-reset');
const refreshButton = document.getElementById('time-slots-refresh');
const tbody = document.getElementById('time-slots-tbody');
let scopes = [];
let slots = [];
let activeScopeId = null;
scopeSelect?.addEventListener('change', () => {
activeScopeId = Number(scopeSelect.value);
resetForm();
renderActiveScope();
});
scopeOpenCreateButton?.addEventListener('click', openScopeModal);
scopeModalCloseButton?.addEventListener('click', closeScopeModal);
scopeModalCancelButton?.addEventListener('click', closeScopeModal);
scopeModal?.addEventListener('click', (event) => {
if (event.target === scopeModal) closeScopeModal();
});
scopeModal?.addEventListener('keydown', (event) => {
if (event.key === 'Escape') closeScopeModal();
});
scopeForm?.addEventListener('submit', createScope);
scopeDeleteButton?.addEventListener('click', deleteActiveScope);
form?.addEventListener('submit', saveSlot);
resetButton?.addEventListener('click', resetForm);
refreshButton?.addEventListener('click', loadAll);
startInput?.addEventListener('change', calculateDuration);
endInput?.addEventListener('change', calculateDuration);
tbody?.addEventListener('click', handleSlotTableClick);
await loadAll();
async function loadAll() {
tbody.innerHTML = '<tr><td colspan="5" class="loading-row">Загрузка...</td></tr>';
hideAlert('time-slot-alert');
try {
[scopes, slots] = await Promise.all([
api.get('/api/admin/time-slots/scopes'),
api.get('/api/admin/time-slots')
]);
if (!activeScopeId || !scopes.some(scope => scope.id === activeScopeId)) {
activeScopeId = defaultScope()?.id || scopes[0]?.id || null;
}
render();
} catch (error) {
tbody.innerHTML = `<tr><td colspan="5" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
}
}
function render() {
renderScopeSelects();
renderActiveScope();
}
function renderActiveScope() {
updateScopeActions();
renderScopeSummary();
renderSlots();
}
function renderScopeSelects() {
scopeSelect.innerHTML = scopes.map(scope => `
<option value="${scope.id}">${escapeHtml(scopeOptionLabel(scope))}</option>
`).join('');
scopeSelect.value = activeScopeId ? String(activeScopeId) : '';
updateScopeActions();
}
function updateScopeActions() {
const activeScope = selectedScope();
scopeDeleteButton.disabled = !activeScope || activeScope.systemScope;
}
function renderScopeSummary() {
const scope = selectedScope();
if (!scope) {
scopeSummary.innerHTML = '<span class="muted">Сетки времени не настроены</span>';
return;
}
const mode = APPLY_MODE_LABELS[scope.applyMode] || scope.applyMode;
const day = scope.applyMode === 'WEEKDAY' ? `, ${dayShort(scope.dayOfWeek)}` : '';
const slotCount = slots.filter(slot => slot.scopeId === scope.id).length;
scopeSummary.innerHTML = `
<span class="scope-badge ${scope.applyMode === 'DEFAULT' ? 'scope-badge-base' : ''}">${escapeHtml(scopeBadgeLabel(scope))}</span>
<span>${escapeHtml(mode + day)}. Слотов: ${slotCount}.</span>
`;
}
function renderSlots() {
const scopeSlots = slots
.filter(slot => slot.scopeId === activeScopeId)
.sort((a, b) => (a.orderNumber || 0) - (b.orderNumber || 0));
if (!scopeSlots.length) {
tbody.innerHTML = '<tr><td colspan="5" class="loading-row">В выбранной сетке нет слотов</td></tr>';
return;
}
tbody.innerHTML = scopeSlots.map(slot => `
<tr>
<td>${escapeHtml(String(slot.orderNumber ?? '-'))}</td>
<td>${escapeHtml(trimTime(slot.startTime))}</td>
<td>${escapeHtml(trimTime(slot.endTime))}</td>
<td>${escapeHtml(String(slot.durationMinutes ?? '-'))}</td>
<td class="actions-cell">
<button type="button" class="btn btn-sm btn-secondary btn-edit-time-slot" data-id="${slot.id}">Изменить</button>
<button type="button" class="btn btn-sm btn-danger btn-delete-time-slot" data-id="${slot.id}">Удалить</button>
</td>
</tr>
`).join('');
}
function openScopeModal() {
scopeForm.reset();
hideAlert('time-slot-scope-alert');
scopeModal.classList.add('open');
scopeModal.setAttribute('aria-hidden', 'false');
window.setTimeout(() => scopeNameInput?.focus(), 0);
}
function closeScopeModal() {
scopeModal.classList.remove('open');
scopeModal.setAttribute('aria-hidden', 'true');
hideAlert('time-slot-scope-alert');
}
async function createScope(event) {
event.preventDefault();
const name = scopeNameInput.value.trim();
if (!name) {
showAlert('time-slot-scope-alert', 'Введите название ручной сетки', 'error');
return;
}
try {
const saved = await api.post('/api/admin/time-slots/scopes', { name });
activeScopeId = saved.id;
closeScopeModal();
showAlert('time-slot-alert', 'Сетка времени создана', 'success');
await loadAll();
} catch (error) {
showAlert('time-slot-scope-alert', error.message || 'Ошибка создания сетки времени', 'error');
}
}
async function deleteActiveScope() {
const scope = selectedScope();
if (!scope || scope.systemScope) return;
if (!await confirmAction({
title: 'Удаление сетки времени',
message: `Удалить сетку «${scope.name}» вместе с её слотами и ручными назначениями?`,
confirmLabel: 'Удалить сетку',
danger: true
})) return;
try {
await api.delete('/api/admin/time-slots/scopes/' + scope.id);
activeScopeId = defaultScope()?.id || null;
resetForm();
showAlert('time-slot-alert', 'Сетка времени удалена', 'success');
await loadAll();
} catch (error) {
showAlert('time-slot-alert', error.message || 'Ошибка удаления сетки времени', 'error');
}
}
async function saveSlot(event) {
event.preventDefault();
hideAlert('time-slot-alert');
const payload = {
id: idInput.value ? Number(idInput.value) : null,
orderNumber: Number(orderInput.value),
scopeId: activeScopeId,
startTime: startInput.value,
endTime: endInput.value
};
try {
validateSlotPayload(payload);
const id = idInput.value;
await (id
? api.put('/api/admin/time-slots/' + id, payload)
: api.post('/api/admin/time-slots', payload));
showAlert('time-slot-alert', 'Временной слот сохранён', 'success');
resetForm();
await loadAll();
} catch (error) {
showAlert('time-slot-alert', error.message || 'Ошибка сохранения временного слота', 'error');
}
}
async function handleSlotTableClick(event) {
const editButton = event.target.closest('.btn-edit-time-slot');
const deleteButton = event.target.closest('.btn-delete-time-slot');
if (editButton) {
const slot = slots.find(item => String(item.id) === String(editButton.dataset.id));
if (!slot) return;
activeScopeId = slot.scopeId;
title.textContent = 'Редактирование слота';
idInput.value = slot.id;
orderInput.value = slot.orderNumber || '';
startInput.value = trimTime(slot.startTime);
endInput.value = trimTime(slot.endTime);
durationInput.value = slot.durationMinutes || '';
hideAlert('time-slot-alert');
render();
form.scrollIntoView({ behavior: 'smooth', block: 'start' });
return;
}
if (deleteButton) {
if (!await confirmAction({ title: 'Удаление временного слота', message: 'Удалить временной слот?', confirmLabel: 'Удалить', danger: true })) return;
try {
await api.delete('/api/admin/time-slots/' + deleteButton.dataset.id);
showAlert('time-slot-alert', 'Временной слот удалён', 'success');
resetForm();
await loadAll();
} catch (error) {
showAlert('time-slot-alert', error.message || 'Ошибка удаления временного слота', 'error');
}
}
}
function validateSlotPayload(payload) {
if (!payload.scopeId) {
throw new Error('Выберите сетку времени');
}
if (!payload.orderNumber || payload.orderNumber <= 0) {
throw new Error('Укажите номер пары');
}
if (!payload.startTime || !payload.endTime) {
throw new Error('Укажите время начала и окончания');
}
if (payload.startTime >= payload.endTime) {
throw new Error('Время начала должно быть раньше времени окончания');
}
}
function calculateDuration() {
durationInput.value = '';
if (!startInput.value || !endInput.value || startInput.value >= endInput.value) return;
const [startHour, startMinute] = startInput.value.split(':').map(Number);
const [endHour, endMinute] = endInput.value.split(':').map(Number);
durationInput.value = String((endHour * 60 + endMinute) - (startHour * 60 + startMinute));
}
function resetForm() {
form.reset();
idInput.value = '';
title.textContent = 'Новый слот';
hideAlert('time-slot-alert');
}
function selectedScope() {
return scopes.find(scope => scope.id === activeScopeId) || null;
}
function defaultScope() {
return scopes.find(scope => scope.applyMode === 'DEFAULT') || null;
}
}
function scopeOptionLabel(scope) {
const suffix = scope.applyMode === 'WEEKDAY'
? `, ${dayShort(scope.dayOfWeek)}`
: '';
return `${scope.name} - ${APPLY_MODE_LABELS[scope.applyMode] || scope.applyMode}${suffix}`;
}
function scopeBadgeLabel(scope) {
if (scope.applyMode === 'DEFAULT') return 'Базовая';
if (scope.applyMode === 'WEEKDAY') return dayShort(scope.dayOfWeek);
return 'Ручная';
}
function dayShort(dayOfWeek) {
return DAY_SHORT_LABELS[Number(dayOfWeek)] || '-';
}
function trimTime(value) {
return value ? String(value).slice(0, 5) : '';
}

View File

@@ -1,73 +0,0 @@
<!-- ===== Database / Tenants Tab ===== -->
<!-- Текущее подключение -->
<div class="card">
<h2>Текущее подключение</h2>
<div id="db-status-info" class="db-status-card">
<div class="loading-row">Загрузка...</div>
</div>
</div>
<!-- Таблица тенантов -->
<div class="card">
<h2>Подключённые университеты</h2>
<div class="table-wrap">
<table id="tenants-table">
<thead>
<tr>
<th>Название</th>
<th>Домен</th>
<th>JDBC URL</th>
<th>Пользователь</th>
<th>Статус</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="tenants-tbody">
<tr>
<td colspan="6" class="loading-row">Загрузка...</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Добавить тенант -->
<div class="card create-card">
<h2>Добавить подключение</h2>
<form id="add-tenant-form">
<div class="form-row">
<div class="form-group">
<label for="tenant-name">Название университета</label>
<input type="text" id="tenant-name" placeholder="ЮЗГУ" required>
</div>
<div class="form-group">
<label for="tenant-domain">Поддомен</label>
<input type="text" id="tenant-domain" placeholder="swsu" required>
</div>
</div>
<div class="form-row" style="margin-top: 0.75rem;">
<div class="form-group" style="flex: 3;">
<label for="tenant-url">JDBC URL</label>
<input type="text" id="tenant-url" placeholder="jdbc:postgresql://192.168.1.50:5432/magistr_db" required>
</div>
</div>
<div class="form-row" style="margin-top: 0.75rem;">
<div class="form-group">
<label for="tenant-username">Пользователь</label>
<input type="text" id="tenant-username" placeholder="postgres" autocomplete="username" required>
</div>
<div class="form-group">
<label for="tenant-password">Пароль</label>
<input type="password" id="tenant-password" placeholder="••••••••" autocomplete="new-password" required>
</div>
<button type="button" class="btn btn-md btn-secondary" id="btn-test-connection" style="height: fit-content;">
Тест
</button>
<button type="submit" class="btn btn-md btn-primary" style="height: fit-content;">
Добавить
</button>
</div>
<div class="form-alert" id="add-tenant-alert" role="alert"></div>
</form>
</div>

View File

@@ -1,34 +0,0 @@
<!-- ===== Education Forms Tab ===== -->
<div class="card create-card">
<h2>Новая форма обучения</h2>
<form id="create-ef-form">
<div class="form-row">
<div class="form-group">
<label for="new-ef-name">Название</label>
<input type="text" id="new-ef-name" placeholder="Бакалавриат" required>
</div>
<button type="submit" class="btn btn-md btn-primary">Создать</button>
</div>
<div class="form-alert" id="create-ef-alert" role="alert"></div>
</form>
</div>
<div class="card">
<h2>Все формы обучения</h2>
<div class="table-wrap">
<table id="ef-table">
<thead>
<tr>
<th>ID</th>
<th>Название</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="ef-tbody">
<tr>
<td colspan="3" class="loading-row">Загрузка...</td>
</tr>
</tbody>
</table>
</div>
</div>

View File

@@ -1,11 +0,0 @@
<div class="settings-placeholder">
<div class="icon-wrap">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
</div>
<h2>Общие настройки</h2>
<p>Этот раздел находится в разработке. Здесь будут доступны общие настройки системы.</p>
</div>

View File

@@ -1,134 +0,0 @@
<section class="settings-section">
<div class="settings-header-row">
<div>
<h2>Временные слоты</h2>
<p>Настройка базовой, субботней и ручных сеток времени.</p>
</div>
<button type="button" class="btn btn-md btn-secondary" id="time-slots-refresh">Обновить</button>
</div>
<div class="settings-grid">
<article class="settings-card">
<div class="card-header-row">
<h3>Сетка времени</h3>
<div class="scope-header-actions">
<button type="button" class="btn btn-md btn-primary" id="time-slot-scope-open-create">Новая ручная сетка</button>
<button type="button" class="btn btn-md btn-danger-subtle" id="time-slot-scope-delete">Удалить сетку</button>
</div>
</div>
<div class="settings-form settings-form-single">
<label class="form-field" for="time-slot-scope-select">
<span>Область действия</span>
<select id="time-slot-scope-select" class="native-select-control" data-native-select="true"></select>
</label>
</div>
<div class="scope-summary" id="time-slot-scope-summary"></div>
</article>
<article class="settings-card settings-card-compact">
<h3>Как применяется</h3>
<div class="scope-summary">
<span class="scope-badge scope-badge-base">Базовая</span>
<span>Используется по умолчанию для всех дат.</span>
</div>
<div class="scope-summary">
<span class="scope-badge">Сб</span>
<span>Субботняя сетка применяется автоматически по субботам.</span>
</div>
<div class="scope-summary">
<span class="scope-badge">Ручная</span>
<span>Ручные сетки назначаются в ячейке редактора календарного графика.</span>
</div>
</article>
</div>
<article class="settings-card">
<div class="card-header-row">
<h3 id="time-slot-form-title">Новый слот</h3>
<button type="button" class="btn btn-md btn-ghost" id="time-slot-reset">Очистить</button>
</div>
<form id="time-slot-form" class="settings-form" novalidate>
<input type="hidden" id="time-slot-id">
<label class="form-field" for="time-slot-order">
<span>Пара</span>
<input type="number" id="time-slot-order" min="1" step="1" required>
</label>
<label class="form-field" for="time-slot-start">
<span>Начало</span>
<input type="time" id="time-slot-start" required>
</label>
<label class="form-field" for="time-slot-end">
<span>Окончание</span>
<input type="time" id="time-slot-end" required>
</label>
<label class="form-field" for="time-slot-duration">
<span>Минут</span>
<input type="number" id="time-slot-duration" min="1" step="1" readonly
aria-describedby="time-slot-duration-hint">
<small id="time-slot-duration-hint">Рассчитывается по времени начала и окончания</small>
</label>
<button type="submit" class="btn btn-md btn-primary">Сохранить слот</button>
<div class="form-alert" id="time-slot-alert" role="alert"></div>
</form>
</article>
<article class="settings-card">
<div class="table-wrap">
<table class="settings-table">
<thead>
<tr>
<th>Пара</th>
<th>Начало</th>
<th>Окончание</th>
<th>Минут</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="time-slots-tbody">
<tr>
<td colspan="5" class="loading-row">Загрузка...</td>
</tr>
</tbody>
</table>
</div>
</article>
<div class="settings-modal-overlay" id="time-slot-scope-modal" aria-hidden="true">
<div class="settings-modal-card" role="dialog" aria-modal="true" aria-labelledby="time-slot-scope-modal-title">
<div class="card-header-row">
<div>
<h3 id="time-slot-scope-modal-title">Новая ручная сетка</h3>
<p class="settings-modal-subtitle">Создайте отдельную сетку времени, которую затем можно назначать датам в календарном графике.</p>
</div>
<button type="button" class="btn btn-icon-md btn-ghost settings-modal-close" id="time-slot-scope-modal-close" aria-label="Закрыть окно">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<form id="time-slot-scope-form" class="settings-modal-form" novalidate>
<label class="form-field" for="time-slot-scope-name">
<span>Название сетки</span>
<input type="text" id="time-slot-scope-name" placeholder="Например: праздничная" required>
</label>
<div class="settings-modal-actions">
<button type="submit" class="btn btn-md btn-primary">Создать сетку</button>
<button type="button" class="btn btn-md btn-ghost" id="time-slot-scope-modal-cancel">Отмена</button>
</div>
<div class="form-alert" id="time-slot-scope-alert" role="alert"></div>
</form>
</div>
</div>
</section>

View File

@@ -1,316 +0,0 @@
<div class="academic-calendar-tabs" role="tablist" aria-label="Разделы календарных учебных графиков">
<button type="button" class="academic-calendar-tab active" data-calendar-tab="manage" role="tab" aria-selected="true" aria-controls="academic-calendar-tab-manage">Графики</button>
<button type="button" class="academic-calendar-tab" data-calendar-tab="grid" role="tab" aria-selected="false" aria-controls="academic-calendar-tab-grid">Сетки</button>
<button type="button" class="academic-calendar-tab" data-calendar-tab="subjects" role="tab" aria-selected="false" aria-controls="academic-calendar-tab-subjects">Дисциплины</button>
</div>
<section class="academic-calendar-tab-panel active" id="academic-calendar-tab-manage" data-calendar-tab-panel="manage" role="tabpanel">
<div class="card">
<div class="card-header-row">
<h2>Учебные годы</h2>
<button class="btn btn-md btn-secondary" id="academic-years-refresh">Обновить</button>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Название</th>
<th>Период</th>
<th>Семестры</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="academic-years-tbody">
<tr>
<td colspan="4" class="loading-row">Загрузка...</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="card create-card">
<div class="card-header-row">
<h2 id="academic-calendar-form-title">Календарный учебный график</h2>
<button type="button" class="btn btn-md btn-ghost" id="academic-calendar-reset">Очистить</button>
</div>
<form id="academic-calendar-form" novalidate>
<input type="hidden" id="academic-calendar-id">
<div class="form-row">
<div class="form-group">
<label for="academic-calendar-year">Учебный год</label>
<select id="academic-calendar-year" required>
<option value="">Загрузка...</option>
</select>
</div>
<div class="form-group">
<label for="academic-calendar-specialty">Специальность</label>
<select id="academic-calendar-specialty" required>
<option value="">Загрузка...</option>
</select>
</div>
<div class="form-group">
<label for="academic-calendar-profile">Профиль</label>
<select id="academic-calendar-profile" required>
<option value="">Выберите специальность</option>
</select>
</div>
<div class="form-group">
<label for="academic-calendar-study-form">Форма обучения</label>
<select id="academic-calendar-study-form" required>
<option value="">Загрузка...</option>
</select>
</div>
<div class="form-group">
<label for="academic-calendar-course-count">Курсов</label>
<input type="number" id="academic-calendar-course-count" min="1" max="8" value="4" required>
</div>
<button type="submit" class="btn btn-md btn-primary">Сохранить график</button>
</div>
<div class="form-alert" id="academic-calendar-alert" role="alert"></div>
</form>
</div>
<div class="card">
<div class="card-header-row">
<h2>Календарные графики</h2>
<button class="btn btn-md btn-secondary" id="academic-calendars-refresh">Обновить</button>
</div>
<div class="form-row">
<div class="form-group">
<label for="academic-calendars-search">Поиск графиков</label>
<input type="search" id="academic-calendars-search" placeholder="Направление, профиль, форма или год">
</div>
<div class="form-group">
<label for="academic-calendars-year-filter">Учебный год</label>
<select id="academic-calendars-year-filter" data-native-select="true"><option value="">Все учебные годы</option></select>
</div>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Учебный год</th>
<th>Специальность</th>
<th>Профиль</th>
<th>Форма</th>
<th>Курсов</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="academic-calendars-tbody">
<tr>
<td colspan="6" class="loading-row">Загрузка...</td>
</tr>
</tbody>
</table>
</div>
<div class="pagination" id="academic-calendars-pagination"></div>
</div>
<div class="card create-card academic-year-builder-card">
<div class="card-header-row">
<div>
<h2 id="academic-year-form-title">Учебный год и семестры</h2>
<p class="muted academic-year-builder-description">Учебный год создаётся сразу с осенним и весенним семестрами.</p>
</div>
<button type="button" class="btn btn-md btn-ghost" id="academic-year-reset">Очистить</button>
</div>
<form id="academic-year-form" novalidate>
<input type="hidden" id="academic-year-id">
<div class="academic-year-period-grid">
<div class="form-group">
<label for="academic-year-start">Год начала учебного года</label>
<input type="text" id="academic-year-start" inputmode="numeric" maxlength="4" pattern="[0-9]{4}" placeholder="2026" autocomplete="off" aria-describedby="academic-year-start-hint" required>
<small class="muted academic-year-field-hint" id="academic-year-start-hint">Введите четыре цифры — дата начала будет 1 сентября.</small>
</div>
<div class="form-group">
<label for="academic-year-end">Год окончания учебного года</label>
<input type="text" id="academic-year-end" inputmode="numeric" maxlength="4" placeholder="2027" readonly aria-readonly="true" required>
<small class="muted academic-year-field-hint">Подставляется автоматически и формирует название учебного года.</small>
</div>
</div>
<section class="academic-year-semester-builder" aria-labelledby="academic-year-semesters-title">
<div class="academic-year-semester-builder-head">
<div>
<h3 id="academic-year-semesters-title">Семестры</h3>
<p class="muted">Проверьте даты: оба семестра будут сохранены вместе с учебным годом.</p>
</div>
<span class="academic-year-required-badge">Обязательно</span>
</div>
<div class="academic-year-semester-grid">
<article class="academic-year-semester-card" data-academic-year-semester="autumn">
<input type="hidden" id="academic-year-autumn-id">
<div class="academic-year-semester-card-title">
<span class="academic-year-semester-dot academic-year-semester-dot-autumn" aria-hidden="true"></span>
<strong>Осенний семестр</strong>
</div>
<div class="academic-year-semester-dates">
<div class="form-group">
<label for="academic-year-autumn-start">Начало</label>
<input type="text" id="academic-year-autumn-start" data-date-input inputmode="numeric" maxlength="10" pattern="[0-9]{2}\.[0-9]{2}\.[0-9]{4}" placeholder="ДД.ММ.ГГГГ" required>
</div>
<div class="form-group">
<label for="academic-year-autumn-end">Окончание</label>
<input type="text" id="academic-year-autumn-end" data-date-input inputmode="numeric" maxlength="10" pattern="[0-9]{2}\.[0-9]{2}\.[0-9]{4}" placeholder="ДД.ММ.ГГГГ" required>
</div>
</div>
</article>
<article class="academic-year-semester-card" data-academic-year-semester="spring">
<input type="hidden" id="academic-year-spring-id">
<div class="academic-year-semester-card-title">
<span class="academic-year-semester-dot academic-year-semester-dot-spring" aria-hidden="true"></span>
<strong>Весенний семестр</strong>
</div>
<div class="academic-year-semester-dates">
<div class="form-group">
<label for="academic-year-spring-start">Начало</label>
<input type="text" id="academic-year-spring-start" data-date-input inputmode="numeric" maxlength="10" pattern="[0-9]{2}\.[0-9]{2}\.[0-9]{4}" placeholder="ДД.ММ.ГГГГ" required>
</div>
<div class="form-group">
<label for="academic-year-spring-end">Окончание</label>
<input type="text" id="academic-year-spring-end" data-date-input inputmode="numeric" maxlength="10" pattern="[0-9]{2}\.[0-9]{2}\.[0-9]{4}" placeholder="ДД.ММ.ГГГГ" required>
</div>
</div>
</article>
</div>
</section>
<div class="academic-year-form-actions">
<button type="submit" class="btn btn-md btn-primary">Сохранить учебный год и семестры</button>
</div>
<div class="form-alert" id="academic-year-alert" role="alert"></div>
</form>
</div>
</section>
<section class="academic-calendar-tab-panel" id="academic-calendar-tab-grid" data-calendar-tab-panel="grid" role="tabpanel" hidden>
<div class="card create-card">
<h2>Редактор годового графика</h2>
<div class="form-row">
<div class="form-group">
<label for="calendar-editor-calendar">График</label>
<select id="calendar-editor-calendar" data-native-select="true">
<option value="">Загрузка...</option>
</select>
</div>
<button type="button" class="btn btn-md btn-secondary" id="calendar-editor-load">Загрузить сетку</button>
<button type="button" class="btn btn-md btn-primary" id="calendar-editor-save">Сохранить сетку</button>
</div>
<div class="calendar-fill-panel">
<div class="form-group">
<label for="calendar-fill-course">Курс</label>
<select id="calendar-fill-course">
<option value="">Все курсы</option>
</select>
</div>
<div class="form-group">
<label for="calendar-fill-start">С даты</label>
<input type="text" id="calendar-fill-start" data-date-input inputmode="numeric" maxlength="10" pattern="[0-9]{2}\.[0-9]{2}\.[0-9]{4}" placeholder="ДД.ММ.ГГГГ">
</div>
<div class="form-group">
<label for="calendar-fill-end">По дату</label>
<input type="text" id="calendar-fill-end" data-date-input inputmode="numeric" maxlength="10" pattern="[0-9]{2}\.[0-9]{2}\.[0-9]{4}" placeholder="ДД.ММ.ГГГГ">
</div>
<div class="form-group">
<label for="calendar-fill-activity">Код</label>
<select id="calendar-fill-activity">
<option value="">Загрузка...</option>
</select>
</div>
<button type="button" class="btn btn-md btn-secondary" id="calendar-fill-apply">Заполнить диапазон</button>
</div>
<div class="form-alert" id="calendar-editor-alert" role="alert"></div>
<div id="calendar-totals" class="calendar-totals"></div>
<div id="calendar-grid" class="academic-calendar-grid"></div>
</div>
</section>
<section class="academic-calendar-tab-panel" id="academic-calendar-tab-subjects" data-calendar-tab-panel="subjects" role="tabpanel" hidden>
<div class="card create-card">
<div class="card-header-row">
<h2>Дисциплины графика</h2>
<button type="button" class="btn btn-md btn-secondary" id="calendar-subject-refresh">Обновить</button>
</div>
<div class="form-row">
<div class="form-group">
<label for="calendar-subject-calendar">График</label>
<select id="calendar-subject-calendar" data-native-select="true">
<option value="">Загрузка...</option>
</select>
</div>
<div class="form-group">
<label for="calendar-subject-semester">Семестр</label>
<select id="calendar-subject-semester">
<option value="">Выберите график</option>
</select>
</div>
<div class="form-group">
<label for="calendar-subject-id">Дисциплина</label>
<select id="calendar-subject-id">
<option value="">Загрузка...</option>
</select>
</div>
<button type="button" class="btn btn-md btn-secondary" id="calendar-subject-add">Добавить</button>
<button type="button" class="btn btn-md btn-primary" id="calendar-subject-save">Сохранить</button>
</div>
<div class="form-alert" id="calendar-subject-alert" role="alert"></div>
</div>
<div class="card">
<div class="card-header-row">
<h2>Привязки по семестрам</h2>
<span id="calendar-subject-count" class="muted"></span>
</div>
<div id="calendar-subject-summary" class="calendar-subject-summary">
<div class="loading-row">Выберите календарный график</div>
</div>
</div>
</section>
<div class="modal-overlay" id="calendar-day-dialog">
<div class="modal-content card calendar-day-dialog-card">
<h2>Ячейка графика</h2>
<button type="button" class="btn btn-icon-md btn-ghost modal-close" id="calendar-day-dialog-close" aria-label="Закрыть окно">&times;</button>
<div class="calendar-day-dialog-grid">
<div>
<span>Дата</span>
<strong id="calendar-day-dialog-date">-</strong>
</div>
<div>
<span>Курс</span>
<strong id="calendar-day-dialog-course">-</strong>
</div>
<div>
<span>Неделя</span>
<strong id="calendar-day-dialog-week">-</strong>
</div>
<div>
<span>День</span>
<strong id="calendar-day-dialog-day">-</strong>
</div>
</div>
<div class="calendar-day-dialog-activity" id="calendar-day-dialog-activity-summary"></div>
<div class="form-group">
<label for="calendar-day-dialog-activity">Код активности</label>
<select id="calendar-day-dialog-activity">
<option value="">Выберите код</option>
</select>
</div>
<div class="calendar-day-dialog-time-grid">
<div class="form-group">
<label for="calendar-day-dialog-time-scope">Временная сетка даты</label>
<select id="calendar-day-dialog-time-scope">
<option value="">По правилу даты</option>
</select>
</div>
<div class="calendar-day-dialog-actions">
<button type="button" class="btn btn-md btn-primary" id="calendar-day-dialog-time-save">Применить</button>
<button type="button" class="btn btn-md btn-danger-subtle" id="calendar-day-dialog-time-clear">Убрать ручную сетку</button>
</div>
<div class="form-alert" id="calendar-day-dialog-time-alert" role="alert"></div>
</div>
</div>
</div>

View File

@@ -1,89 +0,0 @@
<div class="card">
<div class="card-header-row" style="margin-bottom: 1.5rem;">
<h2>Загруженность</h2>
<button type="button" class="btn btn-md btn-secondary" id="workload-refresh">Обновить</button>
</div>
<div class="filter-row workload-filter-row" style="margin-bottom: 2rem; align-items: flex-end; gap: 1.5rem;">
<div class="form-group workload-target-control">
<label for="workload-target-select">Что смотреть</label>
<select id="workload-target-select" class="workload-display-select" data-native-select="true">
<option value="classrooms">Аудитории</option>
<option value="teachers">Преподаватели</option>
<option value="departments">Кафедры</option>
</select>
</div>
<div class="form-group workload-display-control">
<label for="workload-display-select">Отображение</label>
<select id="workload-display-select" class="workload-display-select">
<option value="all">Все аудитории</option>
</select>
</div>
<div class="form-group workload-overview-filter">
<label>Корпус</label>
<div class="custom-multi-select">
<div class="select-box" id="building-box">
<span class="select-text" id="building-text">Все корпуса</span>
<svg class="dropdown-icon" width="12" height="8" viewBox="0 0 12 8" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 1.5L6 6.5L11 1.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<div class="dropdown-menu" id="building-menu">
<div id="building-checkboxes" class="checkbox-group-vertical"></div>
</div>
</div>
</div>
<div class="form-group workload-overview-filter">
<label for="workload-min-capacity">Мин. вместимость</label>
<input type="number" id="workload-min-capacity" placeholder="0" min="0" style="width: 100px;">
</div>
<div class="form-group workload-overview-filter">
<label>Оборудование</label>
<div class="custom-multi-select">
<div class="select-box" id="equipment-box">
<span class="select-text" id="equipment-text">Любое оборудование</span>
<svg class="dropdown-icon" width="12" height="8" viewBox="0 0 12 8" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 1.5L6 6.5L11 1.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<div class="dropdown-menu" id="equipment-menu">
<div id="equipment-checkboxes" class="checkbox-group-vertical"></div>
</div>
</div>
</div>
<div class="form-group" style="max-width: 200px;">
<label>Дата</label>
<input type="date" id="workload-date">
</div>
</div>
<div class="form-alert" id="workload-alert" role="alert"></div>
<div id="workload-overview-block">
<div class="workload-summary" id="workload-summary"></div>
<div class="workload-grid-container">
<table class="workload-table" id="workload-table">
<thead>
<tr id="workload-header-row">
<th class="top-left-cell">
<span class="top-label">Время</span>
<span class="bottom-label">Сущность</span>
</th>
</tr>
</thead>
<tbody id="workload-tbody"></tbody>
</table>
</div>
</div>
<div class="room-workload-block" id="room-workload-block" hidden>
<div class="room-workload-summary" id="room-workload-summary"></div>
<div class="room-workload-tables" id="room-workload-tables"></div>
</div>
</div>

View File

@@ -1,155 +0,0 @@
<!-- ===== Classrooms Tab ===== -->
<div class="card create-card">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; flex-wrap: wrap; gap: 0.5rem;">
<h2 style="margin: 0;">Новая аудитория</h2>
<button type="button" class="btn btn-md btn-secondary" id="btn-manage-equipments">Справочник оборудования</button>
</div>
<form id="create-classroom-form">
<div class="form-row">
<div class="form-group">
<label for="new-classroom-name">Номер / Название</label>
<input type="text" id="new-classroom-name" placeholder="101 Ленинская" required>
</div>
<div class="form-group">
<label for="new-classroom-capacity">Вместимость (чел.)</label>
<input type="number" id="new-classroom-capacity" placeholder="30" min="1" required>
</div>
</div>
<div class="form-row" style="margin-top: 1rem;">
<div class="form-group" style="flex: 2;">
<label>Оборудование</label>
<div class="custom-multi-select">
<div class="select-box" id="equipment-select-box">
<span class="select-text" id="equipment-select-text">Выберите оборудование...</span>
<svg class="dropdown-icon" width="12" height="8" viewBox="0 0 12 8" fill="none"
xmlns="http://www.w3.org/2000/svg">
<path d="M1 1.5L6 6.5L11 1.5" stroke="#9ca3af" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round" />
</svg>
</div>
<div class="dropdown-menu" id="equipment-dropdown-menu">
<div id="equipment-checkboxes" class="checkbox-group-vertical">
<!-- Подгружается через JS -->
<p class="text-muted"><small>Загрузка...</small></p>
</div>
</div>
</div>
</div>
<div class="form-group" style="display: flex; align-items: flex-end;">
<button type="submit" class="btn btn-md btn-primary">Добавить</button>
</div>
</div>
<div class="form-alert" id="create-classroom-alert" role="alert"></div>
</form>
</div>
<div class="card">
<h2>Список аудиторий</h2>
<div class="table-wrap">
<table id="classrooms-table">
<thead>
<tr>
<th>ID</th>
<th>Название</th>
<th>Вместимость</th>
<th>Оборудование</th>
<th>Статус</th>
<th style="text-align: right;">Действия</th>
</tr>
</thead>
<tbody id="classrooms-tbody">
<tr>
<td colspan="6" class="loading-row">Загрузка...</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Edit Classroom Modal -->
<div class="modal-overlay" id="modal-edit-classroom">
<div class="modal-content card">
<h2>Редактировать аудиторию</h2>
<button type="button" class="btn btn-icon-md btn-ghost modal-close" id="modal-edit-classroom-close" aria-label="Закрыть окно">&times;</button>
<form id="edit-classroom-form">
<input type="hidden" id="edit-classroom-id">
<div class="form-row" style="margin-top: 1rem;">
<div class="form-group">
<label for="edit-classroom-name">Номер / Название</label>
<input type="text" id="edit-classroom-name" required>
</div>
<div class="form-group">
<label for="edit-classroom-capacity">Вместимость (чел.)</label>
<input type="number" id="edit-classroom-capacity" min="1" required>
</div>
</div>
<div class="form-row" style="margin-top: 1rem;">
<div class="form-group" style="flex: 2;">
<label>Оборудование</label>
<div class="custom-multi-select">
<div class="select-box" id="edit-equipment-select-box">
<span class="select-text" id="edit-equipment-select-text">Выберите оборудование...</span>
<svg class="dropdown-icon" width="12" height="8" viewBox="0 0 12 8" fill="none"
xmlns="http://www.w3.org/2000/svg">
<path d="M1 1.5L6 6.5L11 1.5" stroke="#9ca3af" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round" />
</svg>
</div>
<div class="dropdown-menu" id="edit-equipment-dropdown-menu">
<div id="edit-equipment-checkboxes" class="checkbox-group-vertical">
<!-- Подгружается через JS -->
</div>
</div>
</div>
</div>
<div class="form-group" style="display: flex; align-items: flex-end;">
<button type="submit" class="btn btn-md btn-primary" style="width: 100%;">Сохранить</button>
</div>
</div>
<div class="form-alert" id="edit-classroom-alert" role="alert"></div>
</form>
</div>
</div>
<!-- Модальное окно управления Оборудованием -->
<div class="modal-overlay" id="modal-manage-equipments">
<div class="modal-content card" style="max-width: 600px; width: 90%;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
<h2>Справочник оборудования</h2>
<button type="button" class="btn btn-icon-md btn-ghost modal-close modal-close--static" id="modal-manage-equipments-close" aria-label="Закрыть окно">&times;</button>
</div>
<!-- Форма добавления -->
<div class="card create-card" style="margin-bottom: 1.5rem; background: var(--bg-secondary);">
<h3 style="margin-top: 0;">Добавить оборудование</h3>
<form id="manage-equipment-form">
<div class="form-row" style="align-items: flex-end;">
<div class="form-group" style="margin-bottom: 0;">
<label for="manage-equipment-name">Название оборудования</label>
<input type="text" id="manage-equipment-name" placeholder="Проектор" required>
</div>
<button type="submit" class="btn btn-md btn-primary" style="height: fit-content; margin-bottom: 0;">Добавить</button>
</div>
<div class="form-alert" id="manage-equipment-alert" role="alert"></div>
</form>
</div>
<!-- Таблица оборудования -->
<div class="table-wrap">
<table id="manage-equipments-table">
<thead>
<tr>
<th>ID</th>
<th>Название</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="manage-equipments-tbody">
<tr>
<td colspan="3" class="loading-row">Загрузка...</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>

View File

@@ -1,145 +0,0 @@
<!-- ===== Dashboard Page ===== -->
<div class="dashboard-container">
<!-- Метрики (Информационные карточки) -->
<div class="dashboard-metrics-grid">
<div class="card dashboard-metric-card dashboard-metric-card--groups">
<div class="metric-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
</div>
<div>
<div style="font-size: 0.85rem; color: var(--text-secondary); font-weight: 500;">Активные группы</div>
<h3 id="metric-groups" style="margin: 0.25rem 0 0; font-size: 1.5rem; font-weight: 700;">—</h3>
</div>
</div>
<div class="card dashboard-metric-card dashboard-metric-card--students">
<div class="metric-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M22 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
</div>
<div>
<div style="font-size: 0.85rem; color: var(--text-secondary); font-weight: 500;">Всего студентов</div>
<h3 id="metric-students" style="margin: 0.25rem 0 0; font-size: 1.5rem; font-weight: 700;">—</h3>
</div>
</div>
<div class="card dashboard-metric-card dashboard-metric-card--teachers">
<div class="metric-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
</div>
<div>
<div style="font-size: 0.85rem; color: var(--text-secondary); font-weight: 500;">Преподаватели</div>
<h3 id="metric-teachers" style="margin: 0.25rem 0 0; font-size: 1.5rem; font-weight: 700;">—</h3>
</div>
</div>
<div class="card dashboard-metric-card dashboard-metric-card--classrooms">
<div class="metric-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="18" height="18" x="3" y="3" rx="2" />
<path d="M9 3v18M15 3v18M3 9h18M3 15h18" />
</svg>
</div>
<div>
<div style="font-size: 0.85rem; color: var(--text-secondary); font-weight: 500;">Загрузка аудиторий сегодня</div>
<h3 id="metric-classrooms" style="margin: 0.25rem 0 0; font-size: 1.5rem; font-weight: 700;">—</h3>
</div>
</div>
</div>
<!-- Мониторинг в реальном времени и Быстрый переход -->
<div class="dashboard-middle">
<!-- Идет сейчас -->
<div class="card dashboard-monitor-card">
<div class="dashboard-card-header">
<h2 style="margin: 0;">Мониторинг пар (Идет сейчас)</h2>
<div id="current-time-slot" class="dashboard-time-slot">—</div>
</div>
<div class="table-wrap dashboard-monitor-table">
<table style="width: 100%; border-collapse: collapse;">
<thead>
<tr>
<th style="text-align: left; padding: 0.75rem;">Аудитория</th>
<th style="text-align: left; padding: 0.75rem;">Дисциплина</th>
<th style="text-align: left; padding: 0.75rem;">Преподаватель</th>
<th style="text-align: left; padding: 0.75rem;">Группа</th>
</tr>
</thead>
<tbody id="current-lessons-tbody">
<tr>
<td colspan="4" class="loading-row" style="padding: 2rem; text-align: center; color: var(--text-secondary);">Определение текущего времени...</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Свободные аудитории и Быстрые действия -->
<div class="dashboard-side-stack">
<!-- Свободные аудитории -->
<div class="card dashboard-compact-card">
<h3 class="dashboard-card-title">Свободные аудитории (ближайшие)</h3>
<div id="free-classrooms-list" class="dashboard-badge-list" tabindex="0" role="region" aria-label="Свободные аудитории">
<div style="color: var(--text-secondary); font-size: 0.9rem;">Загрузка...</div>
</div>
</div>
<!-- Быстрые действия -->
<div class="card dashboard-compact-card">
<h3 class="dashboard-card-title">Быстрый переход</h3>
<div class="dashboard-actions">
<a href="#schedule" class="btn btn-md btn-primary dashboard-action-link">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
<line x1="16" y1="2" x2="16" y2="6"></line>
<line x1="8" y1="2" x2="8" y2="6"></line>
<line x1="3" y1="10" x2="21" y2="10"></line>
</svg>
Конструктор расписания
</a>
<a href="#schedule-view" class="btn btn-md btn-secondary dashboard-action-link">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z" />
<circle cx="12" cy="12" r="3" />
</svg>
Точечные изменения / Просмотр
</a>
</div>
</div>
</div>
</div>
<!-- Центр предупреждений и конфликтов (Red Zone) -->
<div class="card red-zone-card" style="border-top: 4px solid var(--error, #ef4444);">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<div>
<h2 style="margin: 0; display: flex; align-items: center; gap: 0.5rem;">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polygon points="7.86 2 16.14 2 22 7.86 22 16.14 16.14 22 7.86 22 2 16.14 2 7.86 7.86 2" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
Центр предупреждений (Red Zone)
</h2>
<div style="color: var(--text-secondary); font-size: 0.85rem; margin-top: 0.25rem;">Анализ накладок и коллизий расписания на текущую неделю</div>
</div>
<button class="btn btn-sm btn-secondary" id="btn-recheck-conflicts" type="button">Перепроверить</button>
</div>
<div id="conflicts-container" role="status" aria-live="polite" aria-atomic="true" style="display: flex; flex-direction: column; gap: 0.75rem; margin-top: 1rem;">
<div class="loading-row" style="text-align: center; padding: 2rem; color: var(--text-secondary);">Анализ расписания и выявление конфликтов...</div>
</div>
</div>
</div>

View File

@@ -1,161 +0,0 @@
<div class="card department-workspace-control-card">
<div class="card-header-row">
<h2>Рабочая область кафедры</h2>
<button type="button" class="btn btn-md btn-secondary" id="department-workspace-refresh">Обновить</button>
</div>
<div class="department-workspace-filter-grid">
<div class="form-group" id="department-workspace-department-field">
<label for="department-workspace-department">Кафедра</label>
<select id="department-workspace-department" data-native-select="true">
<option value="">Загрузка...</option>
</select>
</div>
<div class="form-group">
<label for="department-workspace-start">Начало периода</label>
<input type="date" id="department-workspace-start">
</div>
<div class="form-group">
<label for="department-workspace-end">Окончание периода</label>
<input type="date" id="department-workspace-end">
</div>
</div>
<div class="form-alert" id="department-workspace-alert" role="alert"></div>
</div>
<div class="department-workspace-grid department-workspace-grid-wide department-workspace-actions-grid">
<div class="card create-card department-subject-import-card">
<div class="card-header-row">
<h2>Загрузка дисциплин</h2>
</div>
<form id="department-subject-import-form" class="stack-form">
<div class="subject-import-slots-panel">
<div class="card-header-row">
<label class="schedule-section-label" style="margin:0">Дисциплины для загрузки</label>
<button type="button" class="btn btn-sm btn-secondary" id="subject-import-add">Добавить строку</button>
</div>
<div id="subject-import-rows" class="subject-import-rows-list"></div>
</div>
<button type="submit" class="btn btn-md btn-secondary">Загрузить</button>
<div class="form-alert" id="department-subject-import-alert" role="alert"></div>
</form>
</div>
<div class="card create-card department-teacher-assign-card">
<div class="card-header-row">
<h2>Добавить преподавателя</h2>
</div>
<form id="department-teacher-assign-form" class="stack-form">
<div class="form-group">
<label for="department-teacher-select">Преподаватель</label>
<select id="department-teacher-select" data-native-select="true">
<option value="">Загрузка...</option>
</select>
</div>
<div class="form-group">
<label for="department-teacher-assign-comment">Комментарий</label>
<input type="text" id="department-teacher-assign-comment" placeholder="Например, ведет лабораторные работы">
</div>
<button type="submit" class="btn btn-md btn-secondary">Привязать к кафедре</button>
<div class="form-alert" id="department-teacher-assign-alert" role="alert"></div>
</form>
</div>
<div class="card create-card department-teacher-request-card">
<div class="card-header-row">
<h2>Заявка на преподавателя</h2>
</div>
<form id="department-teacher-request-form" class="stack-form">
<div class="form-group">
<label for="teacher-request-username">Логин</label>
<input type="text" id="teacher-request-username" placeholder="ivanov_teacher" maxlength="50" required>
</div>
<div class="form-group">
<label for="teacher-request-fullname">ФИО</label>
<input type="text" id="teacher-request-fullname" placeholder="Иванов Иван Иванович" maxlength="255" required>
</div>
<div class="form-group">
<label for="teacher-request-jobtitle">Должность</label>
<input type="text" id="teacher-request-jobtitle" placeholder="Доцент" maxlength="255">
</div>
<div class="form-group">
<label for="teacher-request-comment">Комментарий</label>
<input type="text" id="teacher-request-comment" placeholder="Что важно проверить администратору">
</div>
<button type="submit" class="btn btn-md btn-primary">Отправить заявку</button>
<div class="form-alert" id="department-teacher-request-alert" role="alert"></div>
</form>
</div>
<div class="card department-teachers-card">
<div class="card-header-row">
<h2>Преподаватели и нагрузка</h2>
</div>
<div class="metric-grid" id="department-teachers-list">Загрузка...</div>
</div>
</div>
<div class="card department-workspace-table-card">
<div class="card-header-row">
<h2>Заявки кафедры</h2>
<span class="muted" id="department-teacher-request-count">0 заявок</span>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Статус</th>
<th>Логин</th>
<th>ФИО</th>
<th>Должность</th>
<th>Комментарий</th>
<th>Рассмотрение</th>
</tr>
</thead>
<tbody id="department-teacher-requests-tbody">
<tr><td colspan="6" class="loading-row">Загрузка...</td></tr>
</tbody>
</table>
</div>
</div>
<div class="card department-workspace-table-card">
<div class="card-header-row">
<h2>Дисциплины кафедры</h2>
<span class="muted" id="department-subject-count">0 дисциплин</span>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Код</th>
<th>Название</th>
<th>Описание</th>
<th>Комментарии</th>
</tr>
</thead>
<tbody id="department-subjects-tbody">
<tr><td colspan="4" class="loading-row">Загрузка...</td></tr>
</tbody>
</table>
</div>
</div>
<div class="card department-workspace-table-card" id="department-comments-card" hidden>
<div class="card-header-row">
<h2 id="department-comments-title">Комментарии</h2>
<button type="button" class="btn btn-sm btn-ghost" id="department-comments-close">Скрыть</button>
</div>
<form id="department-comment-form" class="form-row">
<input type="hidden" id="department-comment-subject-id">
<div class="form-group">
<label for="department-comment-text">Новый комментарий</label>
<input type="text" id="department-comment-text" placeholder="Добавьте примечание по дисциплине">
</div>
<button type="submit" class="btn btn-md btn-primary">Сохранить</button>
</form>
<div class="form-alert" id="department-comment-alert" role="alert"></div>
<div class="department-comment-list" id="department-comment-list">Выберите дисциплину</div>
</div>

View File

@@ -1,282 +0,0 @@
<!-- ===== Вкладка групп ===== -->
<div class="card create-card">
<h2>Новая группа</h2>
<form id="create-group-form">
<div class="form-row">
<div class="form-group">
<label for="new-group-name">Название группы</label>
<input type="text" id="new-group-name" placeholder="ИВТ-21-1" required>
</div>
<div class="form-group">
<label for="new-group-size">Численность группы</label>
<input type="number" id="new-group-size" min="1" placeholder="20" required>
</div>
<div class="form-group">
<label for="new-group-ef">Форма обучения</label>
<select id="new-group-ef">
<option value="">Загрузка...</option>
</select>
</div>
<div class="form-group">
<label for="new-group-department">Кафедра</label>
<select id="new-group-department">
<option value="">Загрузка...</option>
</select>
</div>
<div class="form-group">
<label for="new-group-yearStartStudy">Год начала обучения</label>
<input type="number" id="new-group-yearStartStudy" min="2000" max="2100" placeholder="2026" required>
</div>
<div class="form-group">
<label for="new-group-speciality-code">Специальность</label>
<select id="new-group-speciality-code">
<option value="">Загрузка...</option>
</select>
</div>
<div class="form-group">
<label for="new-group-profile">Профиль обучения</label>
<select id="new-group-profile">
<option value="">Выберите специальность</option>
</select>
</div>
<button type="submit" class="btn btn-md btn-primary">Создать</button>
</div>
<div class="form-alert" id="create-group-alert" role="alert"></div>
</form>
</div>
<div class="card">
<div class="card-header-row">
<h2>Все группы</h2>
<div class="filter-row">
<label for="filter-ef-box">Фильтр:</label>
<div class="custom-multi-select">
<div class="select-box" id="filter-ef-box" role="button" tabindex="0" aria-expanded="false">
<span class="select-text" id="filter-ef-text">Выберите форму обучения</span>
<svg class="dropdown-icon" width="12" height="8" viewBox="0 0 12 8" fill="none"
xmlns="http://www.w3.org/2000/svg">
<path d="M1 1.5L6 6.5L11 1.5" stroke="#9ca3af" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round" />
</svg>
</div>
<div class="dropdown-menu" id="filter-ef-menu">
<div id="filter-ef-checkboxes" class="checkbox-group-vertical">
<label class="checkbox-item">
<input type="checkbox" value="all" checked>
<span class="checkmark"></span>
<span>Все</span>
</label>
</div>
</div>
</div>
</div>
</div>
<div class="registry-toolbar">
<div class="form-group registry-search">
<label for="groups-search">Поиск</label>
<input type="search" id="groups-search" placeholder="Название, код специальности, профиль">
</div>
<div class="form-group">
<label for="groups-sort">Сортировка</label>
<select id="groups-sort" data-native-select="true">
<option value="name,asc">По названию А–Я</option>
<option value="name,desc">По названию Я–А</option>
<option value="yearStartStudy,desc">Сначала новые наборы</option>
<option value="groupSize,desc">Сначала крупные</option>
<option value="status,asc">По статусу</option>
</select>
</div>
<button type="button" class="btn btn-md btn-ghost" id="groups-filter-reset">Сбросить фильтры</button>
</div>
<div class="table-wrap">
<table id="groups-table">
<thead>
<tr>
<th>ID</th>
<th>Название</th>
<th>Численность (чел.)</th>
<th>Форма обучения</th>
<th>Кафедра</th>
<th>Курс</th>
<th>Специальность</th>
<th>Профиль</th>
<th>Статус</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="groups-tbody">
<tr>
<td colspan="10" class="loading-row">Загрузка...</td>
</tr>
</tbody>
</table>
</div>
<div class="pagination" id="groups-pagination"></div>
</div>
<!-- Модальное окно редактирования группы -->
<div class="modal-overlay" id="modal-edit-group">
<div class="modal-content card edit-group-modal">
<h2>Редактировать группу</h2>
<button type="button" class="btn btn-icon-md btn-ghost modal-close" id="modal-edit-group-close" aria-label="Закрыть окно">&times;</button>
<form id="edit-group-form">
<input type="hidden" id="edit-group-id">
<div class="form-row edit-group-form-row edit-group-form-row-main">
<div class="form-group">
<label for="edit-group-name">Название группы</label>
<input type="text" id="edit-group-name" required>
</div>
<div class="form-group">
<label for="edit-group-size">Численность группы</label>
<input type="number" id="edit-group-size" min="1" required>
</div>
<div class="form-group">
<label for="edit-group-ef">Форма обучения</label>
<select id="edit-group-ef">
<option value="">Загрузка...</option>
</select>
</div>
</div>
<div class="form-row edit-group-form-row edit-group-form-row-details">
<div class="form-group">
<label for="edit-group-department">Кафедра</label>
<select id="edit-group-department">
<option value="">Загрузка...</option>
</select>
</div>
<div class="form-group">
<label for="edit-group-yearStartStudy">Год начала обучения</label>
<input type="number" id="edit-group-yearStartStudy" min="2000" max="2100" required>
</div>
<div class="form-group">
<label for="edit-group-speciality-code">Специальность</label>
<select id="edit-group-speciality-code">
<option value="">Загрузка...</option>
</select>
</div>
<div class="form-group">
<label for="edit-group-profile">Профиль обучения</label>
<select id="edit-group-profile">
<option value="">Выберите специальность</option>
</select>
</div>
<div class="form-group edit-group-submit">
<button type="submit" class="btn btn-md btn-primary">Сохранить</button>
</div>
</div>
<div class="form-alert" id="edit-group-alert" role="alert"></div>
</form>
</div>
</div>
<!-- Модальное окно управления Подгруппами -->
<div class="modal-overlay" id="modal-manage-subgroups">
<div class="modal-content card" style="max-width: 700px; width: 90%;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
<div>
<h2>Подгруппы группы</h2>
<div id="subgroups-group-context" style="color: var(--text-secondary); font-size: 0.9rem; margin-top: 0.25rem;"></div>
</div>
<button type="button" class="btn btn-icon-md btn-ghost modal-close modal-close--static" id="modal-manage-subgroups-close" aria-label="Закрыть окно">&times;</button>
</div>
<!-- Форма -->
<div class="card create-card" style="margin-bottom: 1.5rem; background: var(--bg-secondary);">
<h3 id="subgroup-form-title" style="margin-top: 0;">Создание подгруппы</h3>
<form id="manage-subgroup-form">
<input type="hidden" id="manage-subgroup-id">
<input type="hidden" id="manage-subgroup-group-id">
<div class="subgroup-config-grid">
<div class="form-group subgroup-count-field">
<label for="manage-subgroup-count">Количество подгрупп</label>
<select id="manage-subgroup-count">
<option value="0">Без разделения на подгруппы</option>
<option value="2">Две подгруппы</option>
<option value="3">Три подгруппы</option>
</select>
</div>
<div id="manage-subgroup-capacities" class="subgroup-capacity-fields"></div>
<div class="subgroup-actions">
<button type="submit" class="btn btn-md btn-primary" id="btn-save-subgroup">Применить</button>
<button type="button" class="btn btn-md btn-ghost" id="btn-cancel-subgroup-edit" style="display: none;">Отмена</button>
</div>
</div>
<div class="form-alert" id="manage-subgroup-alert" role="alert"></div>
</form>
</div>
<!-- Таблица -->
<div class="table-wrap">
<table id="manage-subgroups-table">
<thead>
<tr>
<th>Подгруппа</th>
<th>Численность</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="manage-subgroups-tbody">
<tr>
<td colspan="3" class="loading-row">Загрузка...</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Модальное окно назначения Календарного графика -->
<div class="modal-overlay" id="modal-manage-calendar">
<div class="modal-content card" style="max-width: 960px; width: 90%;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
<div>
<h2>Календарные графики группы</h2>
<div id="calendar-group-context" style="color: var(--text-secondary); font-size: 0.9rem; margin-top: 0.25rem;"></div>
</div>
<button type="button" class="btn btn-icon-md btn-ghost modal-close modal-close--static" id="modal-manage-calendar-close" aria-label="Закрыть окно">&times;</button>
</div>
<!-- Форма -->
<div class="card create-card" style="margin-bottom: 1.5rem; background: var(--bg-secondary);">
<h3 style="margin-top: 0;">Назначить график</h3>
<form id="manage-calendar-form">
<input type="hidden" id="manage-calendar-group-id">
<div class="form-row" style="align-items: flex-end;">
<div class="form-group" style="margin-bottom: 0;">
<label for="manage-calendar-year">Учебный год</label>
<select id="manage-calendar-year" required>
<option value="">Загрузка...</option>
</select>
</div>
<div class="form-group" style="flex: 2; margin-bottom: 0;">
<label for="manage-calendar-id">Календарный график</label>
<select id="manage-calendar-id" data-native-select="true" required>
<option value="">Выберите учебный год</option>
</select>
</div>
<button type="submit" class="btn btn-md btn-primary" style="height: fit-content; margin-bottom: 0;">Назначить</button>
</div>
<div class="form-alert" id="manage-calendar-alert" role="alert"></div>
</form>
</div>
<!-- Таблица -->
<div class="table-wrap">
<table id="manage-calendar-table">
<thead>
<tr>
<th>Учебный год</th>
<th>График</th>
<th>Дисциплины</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="manage-calendar-tbody">
<tr>
<td colspan="4" class="loading-row">Загрузка...</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>

View File

@@ -1,102 +0,0 @@
<section class="quality-lab" aria-labelledby="quality-lab-title">
<header class="quality-hero">
<div class="quality-hero-copy">
<span class="quality-kicker">Диагностика · публикация или черновик</span>
<h2 id="quality-lab-title">Качество видно до перестановок</h2>
<p>
Анализатор находит окна, перегруженные дни, неудачные аудитории и нарушения
пожеланий. Все проблемные занятия объединяются по правилу, а их штрафы
суммируются. Черновик можно оценить до публикации.
</p>
</div>
<div class="quality-run-panel">
<label for="quality-year">Учебный год</label>
<select id="quality-year" data-native-select="true">
<option value="">Загрузка годов…</option>
</select>
<label for="quality-semester">Семестр</label>
<select id="quality-semester" data-native-select="true">
<option value="">Сначала выберите год</option>
</select>
<label for="quality-version">Версия</label>
<select id="quality-version" data-native-select="true">
<option value="">Сначала выберите семестр</option>
</select>
<button type="button" class="btn btn-md btn-primary" id="quality-run">
Выполнить анализ
</button>
</div>
</header>
<div class="quality-status" id="quality-status" role="status" aria-live="polite">
Выберите семестр, чтобы получить объяснимую оценку.
</div>
<div class="quality-results" id="quality-results" hidden>
<section class="quality-scoreboard" aria-label="Итог анализа">
<div class="quality-score-orbit" id="quality-score-orbit">
<svg class="quality-score-ring" viewBox="0 0 36 36" aria-hidden="true">
<circle class="quality-score-ring-track" cx="18" cy="18" r="15.9155"></circle>
<circle class="quality-score-ring-value" id="quality-score-ring-value"
cx="18" cy="18" r="15.9155" pathLength="100"></circle>
</svg>
<div class="quality-score-inner">
<strong id="quality-score">—</strong>
<span>из 100</span>
</div>
</div>
<div class="quality-score-copy">
<span class="quality-score-band" id="quality-score-band">Нет данных</span>
<h3 id="quality-semester-label">Семестр</h3>
<p id="quality-score-summary"></p>
<div class="quality-score-meta" id="quality-score-meta"></div>
</div>
</section>
<section class="quality-metrics" id="quality-metrics" aria-label="Метрики качества"></section>
<section class="quality-workbench">
<div class="quality-problems-column">
<div class="quality-section-heading">
<div>
<span class="quality-kicker">Карта правил</span>
<h3>Правила, снижающие оценку</h3>
</div>
<span class="quality-problem-total" id="quality-problem-total"></span>
</div>
<div class="quality-filters">
<label>
Важность
<select id="quality-severity-filter" data-native-select="true">
<option value="">Все уровни</option>
<option value="CRITICAL">Критические</option>
<option value="HIGH">Высокие</option>
<option value="MEDIUM">Средние</option>
<option value="LOW">Низкие</option>
</select>
</label>
<label>
Категория
<select id="quality-type-filter" data-native-select="true">
<option value="">Все категории</option>
<option value="GAPS">Окна и компактность</option>
<option value="LOAD">Дневная нагрузка</option>
<option value="ROOM">Аудитории</option>
<option value="PREFERENCE">Пожелания</option>
</select>
</label>
</div>
<div class="quality-problems" id="quality-problems"></div>
</div>
<aside class="quality-recommendations" id="quality-recommendations" aria-live="polite">
<div class="quality-recommendations-empty">
<span class="quality-recommendations-mark">↗</span>
<h3>Работа с правилом</h3>
<p>Выберите правило, чтобы увидеть суммарное влияние и открыть его в конструкторе.</p>
</div>
</aside>
</section>
</div>
</section>

View File

@@ -1,89 +0,0 @@
<section class="version-studio" aria-labelledby="version-studio-title">
<header class="version-hero">
<div>
<span class="version-eyebrow">Контур публикации</span>
<h2 id="version-studio-title">Расписание меняется за кулисами</h2>
<p>Черновики изолированы от студентов и преподавателей. Сравните правила и занятия, проверьте целостность и опубликуйте весь пакет одной операцией.</p>
</div>
<div class="version-hero-controls">
<div class="version-hero-period-grid">
<div>
<label for="version-year">Учебный год</label>
<select id="version-year" data-native-select="true">
<option value="">Загрузка годов…</option>
</select>
</div>
<div>
<label for="version-semester">Рабочий семестр</label>
<select id="version-semester" data-native-select="true">
<option value="">Сначала выберите год</option>
</select>
</div>
</div>
<button type="button" class="btn btn-md btn-primary" id="version-create">Новый черновик</button>
</div>
</header>
<div class="version-status" id="version-status" role="status" aria-live="polite">Загружаем контур версий…</div>
<section class="version-current" id="version-current" aria-label="Опубликованная версия"></section>
<div class="version-workbench">
<section class="version-drafts-panel">
<div class="version-section-head">
<div>
<span class="version-eyebrow">Подготовка</span>
<h3>Черновики</h3>
</div>
<span id="version-draft-count">0</span>
</div>
<div class="version-draft-list" id="version-drafts"></div>
</section>
<aside class="version-diff-panel" id="version-diff">
<div class="version-empty">
<span>Δ</span>
<h3>Сравнение версий</h3>
<p>Выберите «Сравнить» у черновика, чтобы увидеть изменения правил и сформированных занятий.</p>
</div>
</aside>
</div>
<section class="version-archive-panel">
<div class="version-section-head">
<div>
<span class="version-eyebrow">Возврат без паники</span>
<h3>Архив публикаций</h3>
</div>
</div>
<div class="version-archive-list" id="version-archive"></div>
</section>
<section class="version-history-panel">
<div class="version-section-head">
<div>
<span class="version-eyebrow">Неизменяемый след</span>
<h3>Журнал действий</h3>
</div>
</div>
<div class="version-history" id="version-history"></div>
</section>
<div class="version-modal" id="version-modal" hidden>
<div class="version-modal-card" role="dialog" aria-modal="true" aria-labelledby="version-modal-title">
<button type="button" class="btn btn-icon-md btn-ghost version-modal-close" id="version-modal-close" aria-label="Закрыть окно">×</button>
<span class="version-eyebrow" id="version-modal-kicker">Новая операция</span>
<h3 id="version-modal-title">Версия расписания</h3>
<p id="version-modal-copy"></p>
<form id="version-modal-form">
<label for="version-modal-value" id="version-modal-label">Название</label>
<textarea id="version-modal-value" rows="3" maxlength="2000" required></textarea>
<div class="version-modal-summary" id="version-modal-summary" hidden></div>
<div class="version-modal-actions">
<button type="button" class="btn btn-md btn-ghost" id="version-modal-cancel">Отмена</button>
<button type="submit" class="btn btn-md btn-primary" id="version-modal-submit">Продолжить</button>
</div>
</form>
</div>
</div>
</section>

View File

@@ -1,233 +0,0 @@
<section class="schedule-view-shell">
<div class="card schedule-view-control-card">
<div class="schedule-view-control-header">
<div>
<h2>Расписания</h2>
<span class="muted" id="schedule-view-period-label">Период не выбран</span>
</div>
<button type="button" class="btn btn-md btn-secondary" id="schedule-view-load">Показать</button>
</div>
<div class="schedule-view-control-grid">
<div class="form-group">
<label for="schedule-view-target">Что смотреть</label>
<select id="schedule-view-target">
<option value="groups">Группа</option>
<option value="teachers">Преподаватель</option>
<option value="classrooms">Аудитория</option>
<option value="departments">Кафедра</option>
</select>
</div>
<div class="form-group schedule-view-entity-field" data-schedule-view-target-field="groups">
<label for="schedule-view-group">Группа</label>
<select id="schedule-view-group" data-native-select="true">
<option value="">Все группы</option>
</select>
</div>
<div class="form-group schedule-view-entity-field" data-schedule-view-target-field="teachers" hidden>
<label for="schedule-view-teacher">Преподаватель</label>
<select id="schedule-view-teacher" data-native-select="true">
<option value="">Все преподаватели</option>
</select>
</div>
<div class="form-group schedule-view-entity-field" data-schedule-view-target-field="classrooms" hidden>
<label for="schedule-view-classroom">Аудитория</label>
<select id="schedule-view-classroom" data-native-select="true">
<option value="">Все аудитории</option>
</select>
</div>
<div class="form-group schedule-view-entity-field" data-schedule-view-target-field="departments" hidden>
<label for="schedule-view-department">Кафедра</label>
<select id="schedule-view-department">
<option value="">Все кафедры</option>
</select>
</div>
<input type="date" id="schedule-view-date" hidden>
</div>
<details class="schedule-view-extra-filters">
<summary>Дополнительные фильтры</summary>
<div class="schedule-view-extra-grid">
<div class="form-group">
<label for="schedule-view-year">Учебный год</label>
<select id="schedule-view-year">
<option value="">Загрузка годов…</option>
</select>
</div>
<div class="form-group">
<label for="schedule-view-semester">Семестр</label>
<select id="schedule-view-semester">
<option value="">Сначала выберите год</option>
</select>
</div>
<div class="form-group">
<label for="schedule-view-subject">Дисциплина</label>
<select id="schedule-view-subject">
<option value="">Все дисциплины</option>
</select>
</div>
<div class="form-group">
<label for="schedule-view-lesson-type">Тип занятия</label>
<select id="schedule-view-lesson-type">
<option value="">Все типы</option>
</select>
</div>
<div class="form-group">
<label for="schedule-view-parity">Неделя</label>
<select id="schedule-view-parity">
<option value="">Любая</option>
<option value="BOTH">Каждая</option>
<option value="ODD">Нечётная</option>
<option value="EVEN">Чётная</option>
</select>
</div>
</div>
</details>
<div class="form-alert" id="schedule-view-alert" role="alert"></div>
</div>
<div class="card schedule-view-results-card">
<div class="schedule-view-results-header">
<div>
<h2>Найденные расписания</h2>
<span class="muted" id="schedule-view-count">0 занятий</span>
</div>
<div class="schedule-view-result-actions">
<button type="button" class="btn btn-md btn-secondary" id="schedule-view-overrides-open" hidden>
Изменения за период
<span class="schedule-override-count" id="schedule-view-overrides-count">0</span>
</button>
<button type="button" class="btn btn-icon-md btn-secondary" id="schedule-view-result-prev" aria-label="Предыдущее расписание">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="15 18 9 12 15 6"></polyline>
</svg>
</button>
<span id="schedule-view-active-label">Нет выбранного расписания</span>
<button type="button" class="btn btn-icon-md btn-secondary" id="schedule-view-result-next" aria-label="Следующее расписание">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="9 18 15 12 9 6"></polyline>
</svg>
</button>
</div>
</div>
<div class="schedule-view-result-tabs" id="schedule-view-result-tabs"></div>
</div>
<div class="card schedule-view-table-card">
<div class="schedule-view-table-header">
<div>
<h2 id="schedule-view-table-title">Расписание</h2>
<span class="muted" id="schedule-view-table-caption">Выберите параметры и нажмите «Показать»</span>
</div>
<div class="schedule-view-day-tabs" id="schedule-view-day-tabs" hidden></div>
</div>
<div class="schedule-view-table-stage" id="schedule-view-tables">
<div class="loading-row schedule-view-state">Загрузка...</div>
</div>
</div>
</section>
<div class="schedule-override-drawer" id="schedule-override-drawer" hidden aria-hidden="true">
<button type="button" class="schedule-override-backdrop" data-override-close aria-label="Закрыть панель"></button>
<aside class="schedule-override-panel" role="dialog" aria-modal="true" aria-labelledby="schedule-override-title">
<header class="schedule-override-header">
<div>
<span class="schedule-override-eyebrow">Точечное изменение</span>
<h2 id="schedule-override-title">Конкретное занятие</h2>
</div>
<button type="button" class="btn btn-icon-md btn-secondary" data-override-close aria-label="Закрыть">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
<path d="M6 6l12 12M18 6L6 18"></path>
</svg>
</button>
</header>
<nav class="schedule-override-tabs" aria-label="Разовые изменения">
<button type="button" class="schedule-override-tab active" data-override-tab="edit">Редактирование</button>
<button type="button" class="schedule-override-tab" data-override-tab="registry">Изменения за период</button>
</nav>
<div class="schedule-override-body">
<section data-override-section="edit">
<div class="schedule-override-empty" id="schedule-override-empty">
Выберите занятие в расписании или откройте его из реестра изменений.
</div>
<form id="schedule-override-form" hidden>
<div class="schedule-override-comparison">
<article>
<span>Было по правилу</span>
<strong id="schedule-override-before-title">—</strong>
<p id="schedule-override-before-meta">—</p>
</article>
<article class="schedule-override-after">
<span>Станет</span>
<strong id="schedule-override-after-title">—</strong>
<p id="schedule-override-after-meta">—</p>
</article>
</div>
<fieldset class="schedule-override-fieldset">
<legend>Дата занятия</legend>
<div class="schedule-override-week" id="schedule-override-week"></div>
<button type="button" class="btn btn-md btn-secondary schedule-override-calendar-toggle" id="schedule-override-calendar-toggle">
Выбрать другую дату
</button>
<div class="schedule-override-calendar" id="schedule-override-calendar" hidden></div>
</fieldset>
<div class="schedule-override-fields">
<div class="form-group">
<label for="schedule-override-time">Время</label>
<select id="schedule-override-time" data-native-select="true" required></select>
</div>
<div class="form-group">
<label for="schedule-override-teacher">Преподаватель</label>
<select id="schedule-override-teacher" data-native-select="true"></select>
</div>
<div class="form-group">
<label for="schedule-override-classroom">Аудитория</label>
<select id="schedule-override-classroom" data-native-select="true"></select>
</div>
<div class="form-group">
<label for="schedule-override-format">Формат</label>
<select id="schedule-override-format" data-native-select="true">
<option value="Очно">Очно</option>
<option value="Онлайн">Онлайн</option>
</select>
</div>
</div>
<div class="form-group">
<label for="schedule-override-comment">Комментарий</label>
<textarea id="schedule-override-comment" rows="3" maxlength="1000" placeholder="Например, замена аудитории из-за мероприятия"></textarea>
</div>
<div class="form-alert" id="schedule-override-alert" role="alert"></div>
<footer class="schedule-override-actions">
<button type="submit" class="btn btn-md btn-primary" id="schedule-override-save">Сохранить</button>
<button type="button" class="btn btn-md btn-danger-subtle" id="schedule-override-cancel">Отменить занятие</button>
<button type="button" class="btn btn-md btn-secondary" id="schedule-override-restore" hidden>Вернуть по правилу</button>
</footer>
</form>
</section>
<section data-override-section="registry" hidden>
<div class="schedule-override-registry-heading">
<div>
<h3>Изменения за выбранный период</h3>
<span class="muted" id="schedule-override-registry-period">—</span>
</div>
</div>
<div class="schedule-override-registry" id="schedule-override-registry"></div>
</section>
</div>
</aside>
</div>

View File

@@ -1,313 +0,0 @@
<div class="schedule-initial-state" id="schedule-initial-state" role="status" aria-live="polite">
<span class="state-spinner" aria-hidden="true"></span>
<span>Загружаем конструктор правил…</span>
</div>
<div class="schedule-initial-content is-loading" id="schedule-initial-content" aria-busy="true">
<section class="version-constructor-bar" id="schedule-version-context" data-status="PUBLISHED">
<div>
<span class="version-eyebrow" id="schedule-version-eyebrow">Основной режим</span>
<h2 id="schedule-version-title">Опубликованное расписание</h2>
<p id="schedule-version-note">Изменения применяются сразу. Для безопасной подготовки можно создать черновик.</p>
</div>
<div class="version-constructor-controls">
<div class="version-constructor-field">
<label for="schedule-version-year">Учебный год</label>
<select id="schedule-version-year" data-native-select="true">
<option value="">Загрузка годов…</option>
</select>
</div>
<div class="version-constructor-field">
<label for="schedule-version-semester">Семестр</label>
<select id="schedule-version-semester" data-native-select="true">
<option value="">Сначала выберите год</option>
</select>
</div>
<div class="version-constructor-field version-constructor-field-wide">
<label for="schedule-version-select">Версия расписания</label>
<select id="schedule-version-select" data-native-select="true">
<option value="">Сначала выберите семестр</option>
</select>
</div>
<div class="version-constructor-actions">
<button type="button" class="btn btn-sm btn-primary" id="schedule-version-create-draft">Создать черновик</button>
<button type="button" class="btn btn-sm btn-secondary" id="schedule-version-manage">Все версии</button>
</div>
</div>
</section>
<div class="version-modal schedule-draft-modal" id="schedule-draft-modal" hidden>
<div class="version-modal-card" role="dialog" aria-modal="true" aria-labelledby="schedule-draft-title">
<button type="button" class="btn btn-icon-md btn-ghost version-modal-close" id="schedule-draft-close" aria-label="Закрыть окно">×</button>
<span class="version-eyebrow">Новая рабочая копия</span>
<h3 id="schedule-draft-title">Создать черновик</h3>
<p id="schedule-draft-copy">Все правила выбранного расписания будут скопированы.</p>
<form id="schedule-draft-form">
<label for="schedule-draft-name">Название черновика</label>
<input id="schedule-draft-name" type="text" maxlength="160" required
placeholder="Например, расписание после распределения аудиторий">
<div class="version-modal-summary" id="schedule-draft-summary"></div>
<div class="version-modal-actions">
<button type="button" class="btn btn-md btn-ghost" id="schedule-draft-cancel">Отмена</button>
<button type="submit" class="btn btn-md btn-primary" id="schedule-draft-submit">Создать и открыть</button>
</div>
</form>
</div>
</div>
<div class="card create-card" id="schedule-rule-editor">
<div class="card-header-row">
<h2 id="schedule-rule-form-title">Новое правило расписания</h2>
<button type="button" class="btn btn-sm btn-ghost" id="schedule-rule-reset">Очистить форму</button>
</div>
<form id="schedule-rule-form" novalidate>
<input type="hidden" id="schedule-rule-id">
<div class="form-row">
<div class="form-group">
<label for="schedule-rule-subject">Дисциплина</label>
<select id="schedule-rule-subject" required>
<option value="">Загрузка...</option>
</select>
</div>
<div class="form-group">
<label for="schedule-rule-semester">Семестр</label>
<select id="schedule-rule-semester" required>
<option value="">Загрузка...</option>
</select>
</div>
</div>
<div class="schedule-hours-panel">
<label class="schedule-section-label">Часы и недели начала</label>
<div class="schedule-hours-grid">
<div class="schedule-hours-row">
<div class="schedule-hours-title">Лекции</div>
<div class="form-group">
<label for="schedule-rule-lecture-hours">Часы</label>
<input type="number" id="schedule-rule-lecture-hours" min="0" step="1" value="0" required>
</div>
<div class="form-group">
<label for="schedule-rule-lecture-start-week">Неделя начала</label>
<input type="number" id="schedule-rule-lecture-start-week" min="1" step="1" value="1" required>
</div>
</div>
<div class="schedule-hours-row">
<div class="schedule-hours-title">Лабораторные</div>
<div class="form-group">
<label for="schedule-rule-laboratory-hours">Часы</label>
<input type="number" id="schedule-rule-laboratory-hours" min="0" step="1" value="0" required>
</div>
<div class="form-group">
<label for="schedule-rule-laboratory-start-week">Неделя начала</label>
<input type="number" id="schedule-rule-laboratory-start-week" min="1" step="1" value="1" required>
</div>
</div>
<div class="schedule-hours-row">
<div class="schedule-hours-title">Практики</div>
<div class="form-group">
<label for="schedule-rule-practice-hours">Часы</label>
<input type="number" id="schedule-rule-practice-hours" min="0" step="1" value="0" required>
</div>
<div class="form-group">
<label for="schedule-rule-practice-start-week">Неделя начала</label>
<input type="number" id="schedule-rule-practice-start-week" min="1" step="1" value="1" required>
</div>
</div>
</div>
</div>
<div class="schedule-groups-panel">
<label class="schedule-section-label">Группы</label>
<div id="schedule-rule-groups" class="custom-multi-select schedule-group-select">
<div class="select-box" id="schedule-rule-groups-box" role="button" tabindex="0" aria-expanded="false">
<span class="select-text" id="schedule-rule-groups-text">Выберите группы...</span>
<svg class="dropdown-icon" width="12" height="8" viewBox="0 0 12 8" fill="none"
xmlns="http://www.w3.org/2000/svg">
<path d="M1 1.5L6 6.5L11 1.5" stroke="#9ca3af" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round" />
</svg>
</div>
<div class="dropdown-menu" id="schedule-rule-groups-menu">
<input type="search" id="schedule-rule-group-search" class="schedule-group-search"
placeholder="Найти группу" aria-label="Поиск групп" autocomplete="off">
<div id="schedule-rule-group-checkboxes" class="checkbox-group-vertical schedule-group-checkboxes"></div>
</div>
</div>
</div>
<div class="schedule-slots-panel">
<div class="card-header-row">
<h2>Слоты правила</h2>
<button type="button" class="btn btn-sm btn-secondary" id="schedule-slot-add">Добавить слот</button>
</div>
<div id="schedule-rule-slots" class="schedule-slots-list"></div>
</div>
<div class="form-row schedule-rule-actions">
<button type="submit" class="btn btn-md btn-primary">Сохранить правило</button>
</div>
<div class="form-alert" id="schedule-rule-alert" role="alert"></div>
</form>
</div>
<div class="modal-overlay schedule-conflict-modal" id="schedule-conflict-modal" role="dialog" aria-modal="true" aria-hidden="true">
<div class="modal-content schedule-conflict-card">
<button type="button" class="btn btn-icon-md btn-ghost modal-close" id="schedule-conflict-close" aria-label="Закрыть окно">&times;</button>
<div class="schedule-conflict-head">
<span class="schedule-conflict-kicker">Конфликт слота</span>
<h2>Освободите место для нового правила</h2>
<p id="schedule-conflict-summary">Ранее созданное правило занимает выбранное время, преподавателя или аудиторию.</p>
<div class="schedule-conflict-reasons" id="schedule-conflict-reasons" hidden></div>
</div>
<div class="schedule-conflict-rules">
<section class="schedule-conflict-rule-box">
<span>Новое правило</span>
<strong id="schedule-conflict-new-rule">-</strong>
<small id="schedule-conflict-new-rule-meta">-</small>
</section>
<section class="schedule-conflict-rule-box schedule-conflict-rule-box-active">
<span>Нужно изменить</span>
<strong id="schedule-conflict-existing-rule">-</strong>
<small id="schedule-conflict-existing-rule-meta">-</small>
</section>
</div>
<form id="schedule-conflict-form" class="schedule-conflict-form" novalidate>
<div class="schedule-conflict-slots" id="schedule-conflict-slots"></div>
<div class="form-alert" id="schedule-conflict-alert" role="alert"></div>
<div class="schedule-conflict-actions">
<button type="button" class="btn btn-md btn-ghost" id="schedule-conflict-cancel">Отмена</button>
<button type="submit" class="btn btn-md btn-primary" id="schedule-conflict-submit">Сохранить и повторить</button>
</div>
</form>
</div>
</div>
<div class="schedule-visual-context-menu" id="schedule-visual-context-menu" role="menu" hidden>
<button type="button" role="menuitem" data-action="edit-rule">Открыть правило</button>
<button type="button" role="menuitem" data-action="move-slot">Изменить день и пару</button>
<button type="button" role="menuitem" data-action="delete-rule" class="danger">Удалить правило</button>
</div>
<div class="modal-overlay schedule-move-slot-modal" id="schedule-move-slot-modal" role="dialog" aria-modal="true" aria-hidden="true">
<div class="modal-content schedule-move-slot-card">
<button type="button" class="btn btn-icon-md btn-ghost modal-close" id="schedule-move-slot-close" aria-label="Закрыть окно">&times;</button>
<div class="schedule-conflict-head">
<span class="schedule-conflict-kicker">Перенос слота</span>
<h2 id="schedule-move-slot-title">Изменить день и пару</h2>
<p id="schedule-move-slot-summary">Будут изменены только день недели и базовая пара выбранного слота.</p>
</div>
<form id="schedule-move-slot-form" class="schedule-move-slot-form" novalidate>
<div class="form-row">
<div class="form-group">
<label for="schedule-move-slot-day">День</label>
<select id="schedule-move-slot-day" required></select>
</div>
<div class="form-group">
<label for="schedule-move-slot-time">Пара</label>
<select id="schedule-move-slot-time" required></select>
</div>
</div>
<div class="form-alert" id="schedule-move-slot-alert" role="alert"></div>
<div class="schedule-conflict-actions">
<button type="button" class="btn btn-md btn-ghost" id="schedule-move-slot-cancel">Отмена</button>
<button type="submit" class="btn btn-md btn-primary" id="schedule-move-slot-submit">Сохранить перенос</button>
</div>
</form>
</div>
</div>
<div class="card">
<div class="card-header-row">
<h2>Правила динамического расписания</h2>
<button class="btn btn-md btn-secondary" id="schedule-refresh">Обновить</button>
</div>
<div class="form-group">
<label for="schedule-rules-search">Поиск правил</label>
<input type="search" id="schedule-rules-search" placeholder="Дисциплина, группа или ID правила">
</div>
<div class="table-wrap">
<table id="schedule-table">
<colgroup>
<col class="schedule-rule-col-id">
<col class="schedule-rule-col-subject">
<col class="schedule-rule-col-semester">
<col class="schedule-rule-col-groups">
<col class="schedule-rule-col-load">
<col class="schedule-rule-col-slots">
<col class="schedule-rule-col-actions">
</colgroup>
<thead>
<tr>
<th>ID</th>
<th>Дисциплина</th>
<th>Семестр</th>
<th>Группы</th>
<th>Нагрузка</th>
<th>Слоты</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="schedule-tbody">
<tr>
<td colspan="7" class="loading-row">Загрузка...</td>
</tr>
</tbody>
</table>
</div>
<div class="pagination" id="schedule-rules-pagination"></div>
</div>
<button type="button"
class="btn btn-icon-md btn-secondary schedule-visual-handle"
id="schedule-visual-toggle"
aria-label="Показать визуальное расписание"
aria-expanded="false">
<svg class="schedule-visual-handle-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="15 18 9 12 15 6"></polyline>
</svg>
</button>
<div class="schedule-visual-backdrop" id="schedule-visual-backdrop" hidden></div>
<aside class="schedule-visual-drawer" id="schedule-visual-drawer" aria-hidden="true">
<div class="schedule-visual-header">
<div class="schedule-visual-header-main">
<div>
<h2>Визуальное расписание групп</h2>
<span class="muted" id="schedule-visual-summary">Правила ещё не загружены</span>
</div>
<button type="button" class="btn btn-sm btn-ghost" id="schedule-visual-close">Свернуть</button>
</div>
<div class="schedule-visual-toolbar">
<div class="schedule-visual-period-grid">
<div class="form-group">
<label for="schedule-visual-year">Учебный год</label>
<select id="schedule-visual-year">
<option value="">Загрузка...</option>
</select>
</div>
<div class="form-group">
<label for="schedule-visual-semester">Семестр</label>
<select id="schedule-visual-semester">
<option value="">Загрузка...</option>
</select>
</div>
</div>
<div class="schedule-visual-filter-head">
<span>Группы</span>
<div>
<button type="button" class="btn btn-sm btn-ghost" id="schedule-visual-use-form">Из формы</button>
<button type="button" class="btn btn-sm btn-ghost" id="schedule-visual-select-all">Все</button>
</div>
</div>
<div class="schedule-visual-group-filter" id="schedule-visual-group-filter">
<span class="muted">Группы появятся после загрузки правил</span>
</div>
</div>
</div>
<div class="schedule-visual-stage" id="schedule-visual-stage">
<div class="loading-row schedule-visual-state">Загрузка...</div>
</div>
</aside>
</div>

View File

@@ -1,116 +0,0 @@
<!-- ===== Subjects Tab ===== -->
<div class="card create-card">
<h2>Новая дисциплина</h2>
<form id="create-subject-form">
<div class="form-row">
<div class="form-group">
<label for="new-subject-name">Название дисциплины</label>
<input type="text" id="new-subject-name" placeholder="Высшая математика" required>
</div>
<div class="form-group">
<label for="new-subject-code">Код предмета</label>
<input type="text" id="new-subject-code" placeholder="Например: MATH101" required>
</div>
<div class="form-group">
<label for="new-subject-department">Кафедра</label>
<select id="new-subject-department" required>
<option value="">Загрузка...</option>
</select>
</div>
<button type="submit" class="btn btn-md btn-primary">Добавить</button>
</div>
<div class="form-alert" id="create-subject-alert" role="alert"></div>
</form>
</div>
<div class="card">
<h2>Все дисциплины</h2>
<div class="registry-toolbar">
<div class="form-group registry-search">
<label for="subjects-search">Поиск</label>
<input type="search" id="subjects-search" placeholder="Название или код дисциплины">
</div>
<div class="form-group">
<label for="subjects-department-filter">Кафедра</label>
<select id="subjects-department-filter" data-native-select="true"><option value="">Все кафедры</option></select>
</div>
<div class="form-group">
<label for="subjects-sort">Сортировка</label>
<select id="subjects-sort" data-native-select="true">
<option value="name,asc">По названию А–Я</option>
<option value="name,desc">По названию Я–А</option>
<option value="code,asc">По коду</option>
<option value="departmentId,asc">По кафедре</option>
</select>
</div>
</div>
<div class="form-alert" id="subjects-alert" role="alert"></div>
<div class="table-wrap">
<table id="subjects-table">
<thead>
<tr>
<th>ID</th>
<th>Название</th>
<th>Код предмета</th>
<th>Кафедра</th>
<th>Преподаватели</th>
<th style="text-align: right;">Действия</th>
</tr>
</thead>
<tbody id="subjects-tbody">
<tr>
<td colspan="6" class="loading-row">Загрузка...</td>
</tr>
</tbody>
</table>
</div>
<div class="pagination" id="subjects-pagination"></div>
</div>
<!-- Модальное окно привязки Преподавателей -->
<div class="modal-overlay" id="modal-manage-teachers">
<div class="modal-content card" style="max-width: 600px; width: 90%;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
<div>
<h2>Преподаватели дисциплины</h2>
<div id="subject-teachers-context" style="color: var(--text-secondary); font-size: 0.9rem; margin-top: 0.25rem;"></div>
</div>
<button type="button" class="btn btn-icon-md btn-ghost modal-close modal-close--static" id="modal-manage-teachers-close" aria-label="Закрыть окно">&times;</button>
</div>
<!-- Форма -->
<div class="card create-card" style="margin-bottom: 1.5rem; background: var(--bg-secondary);">
<h3 style="margin-top: 0;">Добавить привязку</h3>
<form id="manage-teacher-subject-form">
<input type="hidden" id="manage-ts-subject-id">
<div class="form-row" style="align-items: flex-end;">
<div class="form-group" style="margin-bottom: 0;">
<label for="manage-ts-teacher-select">Преподаватель</label>
<select id="manage-ts-teacher-select" data-native-select="true" required>
<option value="">Загрузка...</option>
</select>
</div>
<button type="submit" class="btn btn-md btn-primary" style="height: fit-content; margin-bottom: 0;">Привязать</button>
</div>
<div class="form-alert" id="manage-ts-alert" role="alert"></div>
</form>
</div>
<!-- Таблица -->
<div class="table-wrap">
<table id="manage-ts-table">
<thead>
<tr>
<th>Преподаватель</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="manage-ts-tbody">
<tr>
<td colspan="2" class="loading-row">Загрузка...</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>

View File

@@ -1,146 +0,0 @@
<section class="absence-command-card">
<div class="absence-command-copy">
<span class="absence-kicker">Оперативный контур расписания</span>
<h2>От регистрации инцидента до готовой замены</h2>
<p>Зафиксируйте период отсутствия, проверьте затронутые пары и примените только выбранные решения.</p>
</div>
<div class="absence-command-stats" aria-label="Сводка отсутствий">
<article><strong id="absence-stat-active">0</strong><span>требуют внимания</span></article>
<article><strong id="absence-stat-pending">0</strong><span>ждут подтверждения</span></article>
<article><strong id="absence-stat-resolved">0</strong><span>разрешены</span></article>
</div>
</section>
<section class="card absence-create-card">
<div class="card-header-row">
<div>
<span class="absence-section-number">01</span>
<h2>Зарегистрировать отсутствие</h2>
</div>
<span class="muted">Ответственный сотрудник создаёт согласованную запись сразу</span>
</div>
<form id="teacher-absence-form" novalidate>
<div class="absence-form-grid">
<div class="form-group absence-teacher-field">
<label for="teacher-absence-teacher">Преподаватель</label>
<select id="teacher-absence-teacher" data-native-select="true" required>
<option value="">Загрузка...</option>
</select>
</div>
<div class="form-group">
<label for="teacher-absence-start">Начало</label>
<input type="date" id="teacher-absence-start" required>
</div>
<div class="form-group">
<label for="teacher-absence-end">Окончание</label>
<input type="date" id="teacher-absence-end" required>
</div>
<div class="form-group absence-reason-field">
<label for="teacher-absence-reason">Причина</label>
<input type="text" id="teacher-absence-reason" maxlength="2000"
placeholder="Болезнь, командировка или другая причина" required>
</div>
<button type="submit" class="btn btn-md btn-primary absence-create-submit">Зарегистрировать</button>
</div>
<div class="form-alert" id="teacher-absence-form-alert" role="alert"></div>
</form>
</section>
<section class="card absence-registry-card">
<div class="card-header-row absence-registry-head">
<div>
<span class="absence-section-number">02</span>
<h2>Реестр инцидентов</h2>
</div>
<div class="absence-registry-actions">
<select id="teacher-absence-status-filter" aria-label="Фильтр по статусу">
<option value="">Все статусы</option>
<option value="PENDING">На подтверждении</option>
<option value="APPROVED">Согласованы</option>
<option value="RESOLVED">Разрешены</option>
<option value="REJECTED">Отклонены</option>
<option value="CANCELLED">Отменены</option>
</select>
<button type="button" class="btn btn-md btn-secondary" id="teacher-absence-refresh">Обновить</button>
</div>
</div>
<div class="absence-registry" id="teacher-absence-registry" aria-live="polite">
<div class="loading-row">Загрузка...</div>
</div>
</section>
<section class="teacher-dialogue-grid">
<article class="card teacher-dialogue-card">
<header class="teacher-dialogue-head">
<div>
<span class="absence-section-number">03</span>
<h2>Пожелания на семестр</h2>
<p>Строгие ограничения после согласования участвуют в проверке правил. Мягкие отметки остаются видимой подсказкой.</p>
</div>
<div class="teacher-dialogue-counter"><strong id="teacher-preference-pending-count">0</strong><span>ожидают</span></div>
</header>
<div class="teacher-dialogue-toolbar">
<select id="teacher-preference-semester-filter" aria-label="Семестр пожеланий"></select>
<select id="teacher-preference-status-filter" aria-label="Статус пожеланий">
<option value="PENDING">На рассмотрении</option>
<option value="APPROVED">Согласованные</option>
<option value="">Все статусы</option>
</select>
<button class="btn btn-md btn-secondary" id="teacher-preference-refresh" type="button">Обновить</button>
</div>
<div class="teacher-dialogue-list" id="teacher-preference-review-list" aria-live="polite"></div>
</article>
<article class="card teacher-dialogue-card change-review-card">
<header class="teacher-dialogue-head">
<div>
<span class="absence-section-number">04</span>
<h2>Заявки на изменение</h2>
<p>Каждое одобрение проходит повторную проверку конфликтов и применяется через точечное изменение расписания.</p>
</div>
<div class="teacher-dialogue-counter"><strong id="teacher-change-pending-count">0</strong><span>в очереди</span></div>
</header>
<div class="teacher-dialogue-toolbar">
<select id="teacher-change-status-filter" aria-label="Статус заявок">
<option value="PENDING">Ожидают решения</option>
<option value="APPROVED">Одобренные</option>
<option value="REJECTED">Отклонённые</option>
<option value="">Все статусы</option>
</select>
<button class="btn btn-md btn-secondary" id="teacher-change-refresh" type="button">Обновить</button>
</div>
<div class="teacher-dialogue-list" id="teacher-change-review-list" aria-live="polite"></div>
</article>
</section>
<div class="absence-wizard-overlay" id="absence-wizard-overlay" hidden>
<section class="absence-wizard" role="dialog" aria-modal="true" aria-labelledby="absence-wizard-title">
<header class="absence-wizard-header">
<div>
<span class="absence-kicker">Мастер замены</span>
<h2 id="absence-wizard-title">Затронутые занятия</h2>
<p id="absence-wizard-summary">Загрузка...</p>
</div>
<button type="button" class="btn btn-icon-md btn-ghost" id="absence-wizard-close" aria-label="Закрыть">×</button>
</header>
<div class="absence-wizard-progress" aria-hidden="true">
<span class="done">Инцидент</span><i></i><span class="done">Занятия</span><i></i><span>Решения</span>
</div>
<div class="form-alert" id="absence-wizard-alert" role="alert"></div>
<div class="absence-wizard-lessons" id="absence-wizard-lessons"></div>
<section class="absence-journal">
<div class="card-header-row">
<h3>Журнал решений</h3>
<span class="muted" id="absence-journal-count">0 записей</span>
</div>
<div id="absence-journal-list" class="absence-journal-list"></div>
</section>
<footer class="absence-wizard-footer">
<span class="muted">Пустые строки останутся без изменений</span>
<div>
<button type="button" class="btn btn-md btn-ghost" id="absence-wizard-cancel">Закрыть</button>
<button type="button" class="btn btn-md btn-primary" id="absence-wizard-apply">Применить выбранные</button>
</div>
</footer>
</section>
</div>

View File

@@ -1,44 +0,0 @@
<div class="card">
<div class="card-header-row">
<h2>Заявки на преподавателей</h2>
<div class="teacher-request-toolbar">
<span class="muted" id="teacher-requests-count">0 заявок</span>
<button type="button" class="btn btn-sm btn-secondary" id="teacher-requests-refresh">Обновить</button>
</div>
</div>
<div class="form-alert" id="teacher-requests-alert" role="alert"></div>
<div class="registry-toolbar">
<div class="form-group registry-search">
<label for="teacher-requests-search">Поиск</label>
<input type="search" id="teacher-requests-search" placeholder="ФИО, логин, должность или кафедра">
</div>
<div class="form-group">
<label for="teacher-requests-status">Статус</label>
<select id="teacher-requests-status" data-native-select="true">
<option value="PENDING">Ожидают рассмотрения</option>
<option value="APPROVED">Одобрены</option>
<option value="REJECTED">Отклонены</option>
<option value="">Все статусы</option>
</select>
</div>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Кафедра</th>
<th>Логин</th>
<th>ФИО</th>
<th>Должность</th>
<th>Пароль</th>
<th>Комментарий</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="teacher-requests-tbody">
<tr><td colspan="7" class="loading-row">Загрузка...</td></tr>
</tbody>
</table>
</div>
<div class="pagination" id="teacher-requests-pagination"></div>
</div>

View File

@@ -1,268 +0,0 @@
<!-- ===== University Structure Tab (Departments, Specialties & Profiles) ===== -->
<div class="tab-container">
<div class="university-structure-tabs" role="tablist" aria-label="Разделы структуры вуза">
<button type="button" class="university-structure-tab active" id="btn-tab-departments" role="tab" aria-selected="true" aria-controls="tab-content-departments">Кафедры</button>
<button type="button" class="university-structure-tab" id="btn-tab-specialties" role="tab" aria-selected="false" aria-controls="tab-content-specialties">Специальности</button>
<button type="button" class="university-structure-tab" id="btn-tab-profiles" role="tab" aria-selected="false" aria-controls="tab-content-profiles">Профили</button>
</div>
<!-- Раздел Кафедр -->
<div id="tab-content-departments" class="university-structure-tab-panel active" role="tabpanel" aria-labelledby="btn-tab-departments">
<div class="card create-card">
<h2>Создание кафедры</h2>
<form id="create-department-form">
<div class="form-row">
<div class="form-group">
<label for="dept-name">Название кафедры</label>
<input type="text" id="dept-name" placeholder="Например: Кафедра ИБ" required>
</div>
<div class="form-group">
<label for="dept-code">Код кафедры</label>
<input type="number" id="dept-code" placeholder="Например: 1" required>
</div>
<button type="submit" class="btn btn-md btn-primary">Создать</button>
</div>
<div class="form-alert" id="create-dept-alert" role="alert"></div>
</form>
</div>
<div class="card">
<div class="card-header-row">
<h2>Кафедры</h2>
</div>
<div class="form-group">
<label for="departments-search">Поиск по названию или коду кафедры</label>
<input type="search" id="departments-search" placeholder="Введите название или код">
</div>
<div class="table-wrap">
<table id="departments-table">
<thead>
<tr>
<th>ID</th>
<th>Название</th>
<th>Код</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="departments-tbody">
<tr>
<td colspan="4" class="loading-row">Загрузка...</td>
</tr>
</tbody>
</table>
</div>
<div class="pagination" id="departments-pagination"></div>
</div>
</div>
<!-- Раздел Специальностей -->
<div id="tab-content-specialties" class="university-structure-tab-panel" role="tabpanel" aria-labelledby="btn-tab-specialties" hidden>
<div class="card create-card">
<h2>Создание специальности</h2>
<form id="create-specialty-form">
<div class="form-row">
<div class="form-group">
<label for="spec-name">Название специальности</label>
<input type="text" id="spec-name" placeholder="Например: Программная инженерия" required>
</div>
<div class="form-group">
<label for="spec-code">Код специальности</label>
<input type="text" id="spec-code" placeholder="Например: 09.03.04" required>
</div>
<button type="submit" class="btn btn-md btn-primary">Создать</button>
</div>
<div class="form-alert" id="create-spec-alert" role="alert"></div>
</form>
</div>
<div class="card">
<div class="card-header-row">
<h2>Специальности</h2>
</div>
<div class="form-group">
<label for="specialties-search">Поиск по названию или коду специальности</label>
<input type="search" id="specialties-search" placeholder="Введите название или код">
</div>
<div class="table-wrap">
<table id="specialties-table">
<thead>
<tr>
<th>ID</th>
<th>Название</th>
<th>Код специальности</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="specialties-tbody">
<tr>
<td colspan="4" class="loading-row">Загрузка...</td>
</tr>
</tbody>
</table>
</div>
<div class="pagination" id="specialties-pagination"></div>
</div>
</div>
<!-- Раздел Профилей -->
<div id="tab-content-profiles" class="university-structure-tab-panel" role="tabpanel" aria-labelledby="btn-tab-profiles" hidden>
<div class="card create-card">
<h2 id="main-profile-form-title">Создание профиля</h2>
<form id="create-profile-tab-form">
<input type="hidden" id="tab-profile-id">
<div class="form-row">
<div class="form-group" style="flex: 1; min-width: 200px;">
<label for="tab-profile-spec">Специальность</label>
<select id="tab-profile-spec" required>
<option value="">Выберите специальность</option>
</select>
</div>
<div class="form-group" style="flex: 1.5; min-width: 250px;">
<label for="tab-profile-name">Название профиля</label>
<input type="text" id="tab-profile-name" placeholder="Например: Искусственный интеллект" required>
</div>
<div class="form-group" style="flex: 2; min-width: 250px;">
<label for="tab-profile-description">Описание</label>
<input type="text" id="tab-profile-description" placeholder="Необязательно">
</div>
<div style="display: flex; gap: 0.5rem; align-items: flex-end;">
<button type="submit" class="btn btn-md btn-primary" id="btn-tab-save-profile">Создать</button>
<button type="button" class="btn btn-md btn-ghost" id="btn-tab-cancel-profile-edit" style="display: none;">Отмена</button>
</div>
</div>
<div class="form-alert" id="tab-profile-alert" role="alert"></div>
</form>
</div>
<div class="card">
<div class="card-header-row">
<h2>Профили обучения</h2>
</div>
<div class="form-group">
<label for="profiles-search">Поиск по профилю, специальности или описанию</label>
<input type="search" id="profiles-search" placeholder="Введите название или код">
</div>
<div class="table-wrap">
<table id="profiles-table">
<thead>
<tr>
<th>ID</th>
<th>Специальность</th>
<th>Название профиля</th>
<th>Описание</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="profiles-tbody">
<tr>
<td colspan="5" class="loading-row">Загрузка...</td>
</tr>
</tbody>
</table>
</div>
<div class="pagination" id="profiles-pagination"></div>
</div>
</div>
</div>
<!-- Модальное окно редактирования кафедры -->
<div class="modal-overlay" id="modal-edit-department">
<div class="modal-content card">
<h2>Редактирование кафедры</h2>
<button type="button" class="btn btn-icon-md btn-ghost modal-close" id="modal-edit-department-close" aria-label="Закрыть окно">&times;</button>
<form id="edit-department-form">
<input type="hidden" id="edit-dept-id">
<div class="form-row">
<div class="form-group">
<label for="edit-dept-name">Название кафедры</label>
<input type="text" id="edit-dept-name" required>
</div>
<div class="form-group">
<label for="edit-dept-code">Код кафедры</label>
<input type="number" id="edit-dept-code" required>
</div>
<button type="submit" class="btn btn-md btn-primary">Сохранить</button>
</div>
<div class="form-alert" id="edit-dept-alert" role="alert"></div>
</form>
</div>
</div>
<!-- Модальное окно редактирования специальности -->
<div class="modal-overlay" id="modal-edit-specialty">
<div class="modal-content card">
<h2>Редактирование специальности</h2>
<button type="button" class="btn btn-icon-md btn-ghost modal-close" id="modal-edit-specialty-close" aria-label="Закрыть окно">&times;</button>
<form id="edit-specialty-form">
<input type="hidden" id="edit-spec-id">
<div class="form-row">
<div class="form-group">
<label for="edit-spec-name">Название специальности</label>
<input type="text" id="edit-spec-name" required>
</div>
<div class="form-group">
<label for="edit-spec-code">Код специальности</label>
<input type="text" id="edit-spec-code" required>
</div>
<button type="submit" class="btn btn-md btn-primary">Сохранить</button>
</div>
<div class="form-alert" id="edit-spec-alert" role="alert"></div>
</form>
</div>
</div>
<!-- Модальное окно управления Профилями специальности -->
<div class="modal-overlay" id="modal-manage-profiles">
<div class="modal-content card" style="max-width: 800px; width: 90%;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
<div>
<h2>Профили обучения</h2>
<div id="profiles-spec-context" style="color: var(--text-secondary); font-size: 0.9rem; margin-top: 0.25rem;"></div>
</div>
<button type="button" class="btn btn-icon-md btn-ghost modal-close modal-close--static" id="modal-manage-profiles-close" aria-label="Закрыть окно">&times;</button>
</div>
<!-- Форма создания/редактирования профиля -->
<div class="card create-card" style="margin-bottom: 1.5rem; background: var(--bg-secondary);">
<h3 id="profile-form-title">Создание профиля</h3>
<form id="manage-profile-form">
<input type="hidden" id="manage-profile-id">
<input type="hidden" id="manage-profile-spec-id">
<div class="form-row">
<div class="form-group">
<label for="manage-profile-name">Название профиля</label>
<input type="text" id="manage-profile-name" placeholder="Например: Искусственный интеллект" required>
</div>
<div class="form-group">
<label for="manage-profile-description">Описание</label>
<input type="text" id="manage-profile-description" placeholder="Необязательно">
</div>
<div style="display: flex; gap: 0.5rem; align-items: flex-end;">
<button type="submit" class="btn btn-md btn-primary" id="btn-save-profile">Создать</button>
<button type="button" class="btn btn-md btn-ghost" id="btn-cancel-profile-edit" style="display: none;">Отмена</button>
</div>
</div>
<div class="form-alert" id="manage-profile-alert" role="alert"></div>
</form>
</div>
<!-- Таблица профилей -->
<div class="table-wrap">
<table id="manage-profiles-table">
<thead>
<tr>
<th>ID</th>
<th>Название</th>
<th>Описание</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="manage-profiles-tbody">
<tr>
<td colspan="4" class="loading-row">Загрузка...</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>

View File

@@ -1,95 +0,0 @@
<!-- ===== Users Tab ===== -->
<div class="card create-card">
<h2>Новый пользователь</h2>
<form id="create-form">
<div class="form-row">
<div class="form-group">
<label for="new-username">Имя пользователя</label>
<input type="text" id="new-username" placeholder="Имя пользователя" autocomplete="username" required>
</div>
<div class="form-group">
<label for="new-password">Пароль</label>
<input type="password" id="new-password" placeholder="Минимум 8 символов" autocomplete="new-password" required>
</div>
<div class="form-group">
<label for="new-role">Роль</label>
<select id="new-role">
<option value="STUDENT">Студент</option>
<option value="TEACHER">Преподаватель</option>
<option value="DEPARTMENT">Кафедра</option>
<option value="EDUCATION_OFFICE">Учебный отдел</option>
<option value="SCHEDULE_VIEWER">Просмотр расписаний</option>
<option value="ADMIN">Администратор</option>
</select>
</div>
<div class="form-group">
<label for="new-fullname">ФИО пользователя</label>
<input type="text" id="new-fullname" placeholder="Иванов Иван Иванович" required>
</div>
<div class="form-group">
<label for="new-jobtitle">Должность</label>
<input type="text" id="new-jobtitle" placeholder="Студент / Доцент" required>
</div>
<div class="form-group">
<label for="new-department">ID Кафедры</label>
<input type="number" id="new-department" placeholder="ID" required>
</div>
<button type="submit" class="btn btn-md btn-primary">Создать</button>
</div>
<div class="form-alert" id="create-alert" role="alert"></div>
</form>
</div>
<div class="card">
<h2>Все пользователи</h2>
<div class="registry-toolbar">
<div class="form-group registry-search">
<label for="users-search">Поиск</label>
<input type="search" id="users-search" placeholder="Логин, ФИО или должность">
</div>
<div class="form-group">
<label for="users-role-filter">Роль</label>
<select id="users-role-filter" data-native-select="true">
<option value="">Все роли</option>
<option value="ADMIN">Администратор</option>
<option value="EDUCATION_OFFICE">Учебный отдел</option>
<option value="DEPARTMENT">Кафедра</option>
<option value="SCHEDULE_VIEWER">Просмотр расписаний</option>
<option value="TEACHER">Преподаватель</option>
<option value="STUDENT">Студент</option>
</select>
</div>
<div class="form-group">
<label for="users-sort">Сортировка</label>
<select id="users-sort" data-native-select="true">
<option value="fullName,asc">По ФИО А–Я</option>
<option value="fullName,desc">По ФИО Я–А</option>
<option value="username,asc">По логину</option>
<option value="role,asc">По роли</option>
<option value="status,asc">По статусу</option>
</select>
</div>
</div>
<div class="form-alert" id="users-alert" role="alert"></div>
<div class="table-wrap">
<table id="users-table">
<thead>
<tr>
<th>ID</th>
<th>Имя пользователя</th>
<th>ФИО</th>
<th>Должность</th>
<th>Кафедра</th>
<th>Роль</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="users-tbody">
<tr>
<td colspan="7" class="loading-row">Загрузка...</td>
</tr>
</tbody>
</table>
</div>
<div class="pagination" id="users-pagination"></div>
</div>

View File

@@ -106,6 +106,10 @@ export async function logoutSession() {
}
function purgeLegacyAuthStorage() {
if (typeof localStorage === 'undefined' || typeof sessionStorage === 'undefined') {
return;
}
try {
LEGACY_AUTH_KEYS.forEach(key => localStorage.removeItem(key));
LEGACY_AUTH_KEYS.forEach(key => sessionStorage.removeItem(key));

View File

@@ -9,77 +9,9 @@
<link rel="stylesheet" href="ui-foundation.css">
</head>
<body>
<div class="background">
<div class="shape shape-1"></div>
<div class="shape shape-2"></div>
<div class="shape shape-3"></div>
</div>
<main class="login-container">
<div class="login-card">
<div class="login-header">
<div class="logo">
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="40" height="40" rx="12" fill="url(#logo-gradient)"/>
<path d="M12 20L18 26L28 14" stroke="white" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
<defs>
<linearGradient id="logo-gradient" x1="0" y1="0" x2="40" y2="40">
<stop stop-color="#6366f1"/>
<stop offset="1" stop-color="#8b5cf6"/>
</linearGradient>
</defs>
</svg>
</div>
<h1>Добро пожаловать</h1>
<p class="subtitle">Войдите в свой аккаунт</p>
</div>
<form id="login-form" novalidate>
<div class="form-group stagger-1">
<label for="username">Имя пользователя</label>
<div class="input-wrapper">
<svg class="input-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
<circle cx="12" cy="7" r="4"/>
</svg>
<input type="text" id="username" name="username" placeholder="Введите имя пользователя" autocomplete="username" required>
</div>
<span class="error-message" id="username-error"></span>
</div>
<div class="form-group stagger-2">
<label for="password">Пароль</label>
<div class="input-wrapper">
<svg class="input-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</svg>
<input type="password" id="password" name="password" placeholder="Введите пароль" autocomplete="current-password" required>
<button type="button" class="btn btn-icon-md btn-secondary toggle-password" id="toggle-password" aria-label="Показать пароль">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
</button>
</div>
<span class="error-message" id="password-error"></span>
</div>
<div class="form-alert stagger-3" id="form-alert" role="alert"></div>
<button type="submit" class="btn btn-lg btn-primary btn-submit stagger-4" id="btn-submit">
<span class="btn-text">Войти</span>
<span class="btn-loader" hidden>
<svg class="spinner" width="20" height="20" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3" fill="none" stroke-dasharray="31.4 31.4" stroke-linecap="round"/>
</svg>
</span>
</button>
</form>
</div>
</main>
<div id="login-root"></div>
<script src="theme-toggle.js"></script>
<script type="module" src="script.js"></script>
<script type="module" src="/react/login.js"></script>
</body>
</html>

View File

@@ -5,6 +5,10 @@
"packages": {
"": {
"name": "magistr-frontend-checks",
"dependencies": {
"react": "19.3.0",
"react-dom": "19.3.0"
},
"devDependencies": {
"@opentelemetry/api": "1.9.0",
"@opentelemetry/auto-instrumentations-web": "0.65.0",
@@ -924,6 +928,27 @@
"dev": true,
"license": "MIT"
},
"node_modules/react": {
"version": "19.3.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz",
"integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "19.3.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz",
"integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==",
"license": "MIT",
"dependencies": {
"scheduler": "^0.28.0"
},
"peerDependencies": {
"react": "^19.3.0"
}
},
"node_modules/require-in-the-middle": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-8.0.1.tgz",
@@ -938,6 +963,12 @@
"node": ">=9.3.0 || >=8.10.0 <9.0.0"
}
},
"node_modules/scheduler": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz",
"integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==",
"license": "MIT"
},
"node_modules/zone.js": {
"version": "0.16.0",
"resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.16.0.tgz",

View File

@@ -6,10 +6,12 @@
"node": ">=18"
},
"scripts": {
"build": "npm run build:vendor",
"build": "npm run build:vendor && npm run build:react",
"build:react": "node scripts/build-react.mjs",
"build:react-tests": "node scripts/build-react-tests.mjs",
"build:vendor": "node scripts/build-vendor.mjs",
"test": "node --test tests/*.test.mjs",
"check": "node --check auth-session.js && node --check telemetry.js && node --check schedule-overview.js && node --check script.js && node --check admin/js/api.js && node --check admin/js/main.js && node --check admin/js/academic-period.js && node --check admin/js/async-combobox.js && node --check admin/js/dialog.js && node --check admin/js/dirty-state.js && node --check admin/js/pagination.js && node --check admin/js/url-state.js && node --check admin/js/view-state.js && node --check admin/js/views/groups.js && node --check admin/js/views/users.js && node --check admin/js/views/subjects.js && node --check admin/js/views/teacher-requests.js && node --check admin/js/views/academic-calendar.js && node --check admin/js/views/schedule.js && node --check admin/js/views/schedule-view.js && node --check admin/js/views/schedule-override-panel.js && node --check admin/js/views/schedule-versions.js && node --check admin/js/views/teacher-absences.js && node --check admin/js/views/schedule-quality.js && node --check teacher/app.js && node --check student/app.js && 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"
},
"devDependencies": {
"@opentelemetry/api": "1.9.0",
@@ -23,5 +25,9 @@
"@opentelemetry/semantic-conventions": "1.43.0",
"esbuild": "0.25.12",
"zone.js": "0.16.0"
},
"dependencies": {
"react": "19.3.0",
"react-dom": "19.3.0"
}
}

15
frontend/react-cache.conf Normal file
View File

@@ -0,0 +1,15 @@
<LocationMatch "^/react/shared/chunks/.+-[A-Z0-9]+\.js$">
Header always set Cache-Control "public, max-age=31536000, immutable"
</LocationMatch>
<LocationMatch "^/react/(?:login|student|teacher|settings|admin|shared/runtime)\.js$">
Header always set Cache-Control "no-cache"
</LocationMatch>
# Стили и HTML не имеют отпечатков в именах файлов: без явного
# no-cache браузеры держат их в heuristic-кэше, и обновления
# (например, правки layout.css) не доходят до пользователей
# до истечения срока годности. ETag делает ревалидацию дешёвой (304).
<LocationMatch "\.(?:css|html)$">
Header always set Cache-Control "no-cache"
</LocationMatch>

View File

@@ -0,0 +1,458 @@
import { Suspense, useEffect, useRef, useState } from 'react';
import { ADMIN_APP_ROLES, SETTINGS_ROLES } from '../../admin/js/role-capabilities.js';
import { applyRippleEffect, closeAllDropdownsOnOutsideClick } from '../../admin/js/utils.js';
import { initAllCustomDropdowns, startDropdownAutoObserver } from '../../admin/js/dropdown.js';
import { confirmAbandonDirtyChanges } from '../../admin/js/dirty-state.js';
import { trackUxEvent } from '../../telemetry.js';
import { logoutAndRedirect } from '../shared/auth-client.js';
import { SESSION_PHASES } from '../shared/session-policy.js';
import { useAuthorizedSession } from '../shared/use-authorized-session.js';
import { AsyncState } from '../shared/ui/AsyncState.jsx';
import { REACT_TABS } from './react-tabs.js';
import {
ADMIN_ROUTES,
allowedAdminTabs,
badgeVisibility,
defaultAdminTabForRole,
fetchPendingRequestsCount,
logoTitleForRole,
navBadgeLabel,
navSectionsForRole,
requestCountLabel,
tabFromHash
} from './admin-service.js';
const THEME_STORAGE_KEY = 'theme';
const SIDEBAR_STORAGE_KEY = 'sidebar-collapsed';
const ICONS = Object.freeze({
dashboard: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="7" height="9" />
<rect x="14" y="3" width="7" height="5" />
<rect x="14" y="12" width="7" height="9" />
<rect x="3" y="16" width="7" height="5" />
</svg>
),
'teacher-requests': (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" />
<rect x="8" y="2" width="8" height="4" rx="1" />
<path d="M8 11h8" />
<path d="M8 16h5" />
</svg>
),
'department-workspace': (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 21h18" />
<path d="M5 21V7l7-4 7 4v14" />
<path d="M9 21v-6h6v6" />
<path d="M9 9h.01" />
<path d="M15 9h.01" />
</svg>
),
schedule: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
),
'schedule-versions': (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 7h12a4 4 0 0 1 4 4v7" />
<path d="M16 3l4 4-4 4" />
<path d="M20 17H8a4 4 0 0 1-4-4V6" />
<path d="M8 21l-4-4 4-4" />
</svg>
),
'schedule-quality': (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 19V9" />
<path d="M10 19V5" />
<path d="M16 19v-7" />
<path d="M22 19V3" />
<path d="M2 19h20" />
</svg>
),
'academic-calendar': (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 3h18v18H3z" />
<path d="M3 9h18" />
<path d="M9 3v18" />
<path d="M15 3v18" />
<path d="M3 15h18" />
</svg>
),
'auditorium-workload': (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<line x1="3" y1="9" x2="21" y2="9" />
<line x1="9" y1="21" x2="9" y2="9" />
</svg>
),
'schedule-view': (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z" />
<circle cx="12" cy="12" r="3" />
</svg>
),
'teacher-absences': (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 3a4 4 0 1 0 0 8 4 4 0 0 0 0-8z" />
<path d="M5 21v-2a7 7 0 0 1 14 0v2" />
<path d="M18 8l3 3" />
<path d="M21 8l-3 3" />
</svg>
),
classrooms: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 3h18v18H3zM9 3v18M15 3v18M3 9h18M3 15h18" />
</svg>
),
groups: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" />
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" />
</svg>
),
'university-structure': (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" />
<rect x="8" y="2" width="8" height="4" rx="1" ry="1" />
</svg>
),
subjects: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 20h9" />
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" />
</svg>
),
users: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
)
});
export function AdminApp() {
const sessionState = useAuthorizedSession(ADMIN_APP_ROLES);
if (sessionState.phase === SESSION_PHASES.loading) {
return (
<AdminFrame>
<AsyncState kind="loading" title="Админ-панель" message="Проверяем сессию…" />
</AdminFrame>
);
}
if (sessionState.phase !== SESSION_PHASES.ready) {
return (
<AdminFrame>
<AsyncState kind="error" title="Раздел недоступен" message={sessionState.message} />
</AdminFrame>
);
}
return <AdminWorkspace role={sessionState.session.role} />;
}
function AdminFrame({ children }) {
return (
<main className="main">
<header className="topbar">
<div className="topbar-title-group">
<h1>Админ-панель</h1>
</div>
</header>
<section className="content">
{children}
</section>
</main>
);
}
export function AdminWorkspace({ role }) {
const allowedTabs = allowedAdminTabs(role);
const [activeTab, setActiveTab] = useState(() => (
typeof window === 'undefined'
? defaultAdminTabForRole(role)
: tabFromHash(window.location.hash, role)
));
const activeTabRef = useRef(activeTab);
const [collapsed, setCollapsed] = useState(() => (
typeof window !== 'undefined'
&& window.innerWidth > 768
&& localStorage.getItem(SIDEBAR_STORAGE_KEY) === 'true'
));
const [settingsOpen, setSettingsOpen] = useState(false);
const [pendingCount, setPendingCount] = useState(null);
const settingsRef = useRef(null);
useEffect(() => {
applyRippleEffect();
closeAllDropdownsOnOutsideClick();
initAllCustomDropdowns(document.body);
startDropdownAutoObserver();
}, []);
useEffect(() => {
document.documentElement.dataset.theme = localStorage.getItem(THEME_STORAGE_KEY) || 'light';
}, []);
useEffect(() => {
function handleHashChange() {
void requestTabSwitch(tabFromHash(window.location.hash, role));
}
window.addEventListener('hashchange', handleHashChange);
return () => {
window.removeEventListener('hashchange', handleHashChange);
};
});
useEffect(() => {
function handleClickOutside(event) {
if (settingsRef.current && !settingsRef.current.contains(event.target)) {
setSettingsOpen(false);
}
}
document.addEventListener('click', handleClickOutside);
return () => {
document.removeEventListener('click', handleClickOutside);
};
}, []);
useEffect(() => {
if (role !== 'ADMIN') return;
let active = true;
async function refresh(countOverride = null) {
try {
const count = countOverride === null
? await fetchPendingRequestsCount()
: countOverride;
if (active) setPendingCount(count);
} catch {
if (active) setPendingCount(null);
console.warn('Не удалось загрузить счётчик заявок');
}
}
void refresh();
function handleRequestsChanged(event) {
void refresh(Number.isFinite(event.detail?.count) ? event.detail.count : null);
}
document.addEventListener('teacherRequestsChanged', handleRequestsChanged);
return () => {
active = false;
document.removeEventListener('teacherRequestsChanged', handleRequestsChanged);
};
}, [role]);
async function requestTabSwitch(tab) {
if (tab === activeTabRef.current || !allowedTabs.has(tab)) return;
if (activeTabRef.current && !(await confirmAbandonDirtyChanges())) return;
activeTabRef.current = tab;
setActiveTab(tab);
trackUxEvent('раздел_открыт', { tab, role });
if (window.location.hash !== `#${tab}`) {
window.location.hash = tab;
}
}
function toggleSidebar() {
setCollapsed(current => {
const next = !current;
localStorage.setItem(SIDEBAR_STORAGE_KEY, next);
return next;
});
}
function toggleTheme() {
const next = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';
document.documentElement.dataset.theme = next;
localStorage.setItem(THEME_STORAGE_KEY, next);
}
function handleLogout(event) {
event.preventDefault();
void logoutAndRedirect();
}
const sections = navSectionsForRole(role);
const title = activeTab ? ADMIN_ROUTES[activeTab].title : 'Админ-панель';
const badge = pendingCount === null ? null : badgeVisibility(pendingCount);
return (
<>
<aside className={`sidebar${collapsed ? ' collapsed' : ''}`}>
<div className="sidebar-header">
<div className="logo">
<svg width="32" height="32" viewBox="0 0 40 40" fill="none">
<rect width="40" height="40" rx="12" fill="url(#lg)" />
<path d="M12 20L18 26L28 14" stroke="#fff" strokeWidth="3" strokeLinecap="round"
strokeLinejoin="round" />
<defs>
<linearGradient id="lg" x1="0" y1="0" x2="40" y2="40">
<stop stopColor="#6366f1" />
<stop offset="1" stopColor="#8b5cf6" />
</linearGradient>
</defs>
</svg>
<span>{logoTitleForRole(role)}</span>
</div>
<button
type="button"
className="btn btn-icon-sm btn-ghost sidebar-close-btn"
aria-label="Скрыть панель"
onClick={toggleSidebar}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
strokeLinecap="round" strokeLinejoin="round">
<polyline points="15 18 9 12 15 6" />
</svg>
</button>
</div>
<nav className="sidebar-nav">
{sections.map(section => (
<div key={section.title || 'main'}>
{section.title && (
<>
<div className="sidebar-section-divider" />
<div className="sidebar-section-title">{section.title}</div>
</>
)}
{section.items.map(item => (
<a
key={item.id}
href={`#${item.id}`}
className={`nav-item${item.id === activeTab ? ' active' : ''}`}
data-tab={item.id}
onClick={event => {
event.preventDefault();
void requestTabSwitch(item.id);
}}
>
{ICONS[item.id]}
<span>{item.label}</span>
{item.id === 'teacher-requests' && badge && !badge.hidden && (
<span
className="nav-badge"
id="teacher-requests-nav-badge"
aria-label={badge.ariaLabel}
>
{navBadgeLabel(pendingCount)}
</span>
)}
</a>
))}
</div>
))}
</nav>
<div className="sidebar-footer">
<div className={`settings-dropdown${settingsOpen ? ' open' : ''}`} ref={settingsRef}>
<button
type="button"
className="btn-settings"
onClick={event => {
event.stopPropagation();
setSettingsOpen(current => !current);
}}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
<span>Настройки</span>
<svg className="settings-chevron" width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
<div className="settings-menu">
{SETTINGS_ROLES.includes(role) && (
<a href="/admin/settings/" className="settings-menu-item">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
Общие настройки
</a>
)}
<div className="settings-menu-divider" />
<button
type="button"
className="btn btn-md btn-danger-subtle btn-logout settings-menu-item settings-menu-item--danger"
onClick={handleLogout}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<polyline points="16 17 21 12 16 7" />
<line x1="21" y1="12" x2="9" y2="12" />
</svg>
Выйти
</button>
</div>
</div>
</div>
</aside>
<div className="sidebar-overlay" />
<main className={`main${collapsed ? ' sidebar-collapsed' : ''}`}>
<header className="topbar">
<div className="topbar-title-group">
<h1 id="page-title">{title}</h1>
{badge && !badge.hidden && (
<button
type="button"
className="topbar-request-badge"
id="teacher-requests-title-badge"
aria-label={badge.ariaLabel}
onClick={() => void requestTabSwitch('teacher-requests')}
>
{requestCountLabel(pendingCount)}
</button>
)}
</div>
<button
type="button"
className="btn btn-icon-md btn-secondary theme-toggle"
aria-label="Переключить тему"
onClick={toggleTheme}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M21 12.79A9 9 0 1 1 11.21 3a7 7 0 0 0 9.79 9.79z" />
</svg>
</button>
</header>
<section className="content" id="app-content">
{activeTab && <TabContent tab={activeTab} />}
</section>
</main>
</>
);
}
function TabContent({ tab }) {
const reactTab = REACT_TABS[tab];
const TabComponent = reactTab?.Component;
if (!TabComponent) {
return null;
}
return (
<Suspense fallback={<div className="tab-loading"><span className="state-spinner" aria-hidden="true" />Загрузка...</div>}>
<TabComponent />
</Suspense>
);
}

View File

@@ -0,0 +1,641 @@
import { apiClient } from '../shared/api-client.js';
import { dateInputValueToIso, isoDateToInputValue } from '../../admin/js/date-input.js';
import { academicCalendarWeekNumber } from '../../admin/js/views/academic-calendar-grid.js';
import { buildAcademicCalendarTitle } from '../../admin/js/views/academic-calendar-title.js';
export const DAY_OPTIONS = Object.freeze([
Object.freeze({ value: 1, label: 'Понедельник', shortLabel: 'Пн' }),
Object.freeze({ value: 2, label: 'Вторник', shortLabel: 'Вт' }),
Object.freeze({ value: 3, label: 'Среда', shortLabel: 'Ср' }),
Object.freeze({ value: 4, label: 'Четверг', shortLabel: 'Чт' }),
Object.freeze({ value: 5, label: 'Пятница', shortLabel: 'Пт' }),
Object.freeze({ value: 6, label: 'Суббота', shortLabel: 'Сб' }),
Object.freeze({ value: 7, label: 'Воскресенье', shortLabel: 'Вс' })
]);
export const SEMESTER_LABELS = Object.freeze({
autumn: 'осенний',
spring: 'весенний'
});
const CALENDAR_TOOLTIP_OFFSET = 14;
const CALENDAR_TOOLTIP_VIEWPORT_PADDING = 12;
// --- Позиция подсказки ячейки ---
export function calculateCalendarTooltipPosition({
pointerX,
pointerY,
tooltipWidth,
tooltipHeight,
viewportWidth,
viewportHeight
}) {
const maxLeft = Math.max(
CALENDAR_TOOLTIP_VIEWPORT_PADDING,
viewportWidth - tooltipWidth - CALENDAR_TOOLTIP_VIEWPORT_PADDING
);
const maxTop = Math.max(
CALENDAR_TOOLTIP_VIEWPORT_PADDING,
viewportHeight - tooltipHeight - CALENDAR_TOOLTIP_VIEWPORT_PADDING
);
let left = pointerX + CALENDAR_TOOLTIP_OFFSET;
let top = pointerY + CALENDAR_TOOLTIP_OFFSET;
if (left > maxLeft) {
left = pointerX - tooltipWidth - CALENDAR_TOOLTIP_OFFSET;
}
if (top > maxTop) {
top = pointerY - tooltipHeight - CALENDAR_TOOLTIP_OFFSET;
}
return {
left: Math.round(Math.max(CALENDAR_TOOLTIP_VIEWPORT_PADDING, Math.min(left, maxLeft))),
top: Math.round(Math.max(CALENDAR_TOOLTIP_VIEWPORT_PADDING, Math.min(top, maxTop)))
};
}
// --- Учебный год ---
export function academicYearDefaults(startYearValue) {
const value = String(startYearValue || '').trim();
if (!/^\d{4}$/.test(value)) {
throw new TypeError('Год начала учебного года должен состоять из четырёх цифр');
}
const startYear = Number(value);
if (startYear < 1000 || startYear > 9998) {
throw new TypeError('Год начала учебного года должен быть в диапазоне от 1000 до 9998');
}
const endYear = startYear + 1;
const start = new Date(Date.UTC(startYear, 8, 1));
const autumnEnd = new Date(Date.UTC(endYear, 0, 31));
const springStart = new Date(Date.UTC(endYear, 1, 1));
const end = new Date(Date.UTC(endYear, 5, 30));
return {
startYear,
endYear,
title: `${startYear}-${endYear}`,
startDate: toIsoDateUtc(start),
endDate: toIsoDateUtc(end),
semesters: [
{
semesterType: 'autumn',
startDate: toIsoDateUtc(start),
endDate: toIsoDateUtc(autumnEnd)
},
{
semesterType: 'spring',
startDate: toIsoDateUtc(springStart),
endDate: toIsoDateUtc(end)
}
]
};
}
export function validateAcademicYearSemesters(semesters, yearStart, yearEnd) {
if (!Array.isArray(semesters) || semesters.length === 0) {
throw new Error('Учебный год нельзя создать без семестров');
}
const sorted = [...semesters].sort((a, b) => String(a.startDate).localeCompare(String(b.startDate)));
const types = new Set();
sorted.forEach((semester, index) => {
if (!semester?.semesterType || !semester.startDate || !semester.endDate) {
throw new Error('Заполните тип и даты каждого семестра');
}
if (types.has(semester.semesterType)) {
throw new Error('Типы семестров не должны повторяться');
}
types.add(semester.semesterType);
if (semester.endDate < semester.startDate) {
throw new Error('Дата окончания семестра не может быть раньше даты начала');
}
if (semester.startDate < yearStart || semester.endDate > yearEnd) {
throw new Error('Семестры должны находиться в границах учебного года');
}
if (index > 0 && sorted[index - 1].endDate >= semester.startDate) {
throw new Error('Периоды семестров не должны пересекаться');
}
});
}
function toIsoDateUtc(date) {
return [
date.getUTCFullYear(),
String(date.getUTCMonth() + 1).padStart(2, '0'),
String(date.getUTCDate()).padStart(2, '0')
].join('-');
}
// Ввод года начала: нормализует значение и пересчитывает год окончания
// вместе с датами семестров. Как в legacy, ручной ввод года сбрасывает даты
// семестров к рассчитанным значениям (semesterDates === null — не трогать).
export function startYearInputResult(rawValue) {
const startYear = String(rawValue || '').replace(/\D/g, '').slice(0, 4);
if (startYear.length !== 4) {
return { startYear, endYear: '', semesterDates: null };
}
try {
const defaults = academicYearDefaults(startYear);
return {
startYear,
endYear: String(defaults.endYear),
semesterDates: {
autumn: {
start: isoDateToInputValue(defaults.semesters[0].startDate),
end: isoDateToInputValue(defaults.semesters[0].endDate)
},
spring: {
start: isoDateToInputValue(defaults.semesters[1].startDate),
end: isoDateToInputValue(defaults.semesters[1].endDate)
}
}
};
} catch {
return { startYear, endYear: '', semesterDates: null };
}
}
// Чтение даты из поля ДД.ММ.ГГГГ с подписью поля в сообщении об ошибке
// (реплика legacy readDateInputIso).
export function readDisplayDateIso(displayValue, label = 'Дата') {
try {
return dateInputValueToIso(displayValue);
} catch (error) {
throw new Error(`${label}: ${lowercaseFirst(error.message)}`);
}
}
// --- Типы активности ---
export function activityColorCode(activity) {
const color = String(activity?.colorCode || '').trim();
return /^#[0-9a-f]{6}$/i.test(color) ? color : '#64748b';
}
export function activityColorClass(activity) {
return `calendar-activity-color-${activityColorCode(activity).slice(1).toLowerCase()}`;
}
export function activityById(id, activityTypes) {
return (activityTypes || []).find(type => String(type.id) === String(id));
}
export function activityIdByCode(code, activityTypes) {
return (activityTypes || []).find(type => type.code === code)?.id || null;
}
export function theoryActivityId(activityTypes) {
return (activityTypes || []).find(type => type.code === 'Т')?.id || activityTypes?.[0]?.id || '';
}
// Эффективный код активности ячейки: сохранённое значение, затем код по
// справочнику, затем теория (реплика legacy renderCalendarDay).
export function effectiveActivityTypeId(row, activityTypes) {
return row.activityTypeId || activityIdByCode(row.activityCode, activityTypes) || theoryActivityId(activityTypes);
}
// --- Контракты API ---
export function fetchSpecialties(client = apiClient) {
return client.get('/api/specialties', { errorMessage: 'Ошибка загрузки специальностей' });
}
export function specialtyProfilesEndpoint(specialtyId) {
return `/api/specialties/${specialtyId}/profiles`;
}
export function fetchSpecialtyProfiles(specialtyId, client = apiClient) {
return client.get(specialtyProfilesEndpoint(specialtyId), {
errorMessage: 'Ошибка загрузки профилей специальности'
});
}
export function fetchAcademicYearsList(client = apiClient) {
return client.get('/api/admin/calendar/years', { errorMessage: 'Ошибка загрузки учебных годов' });
}
export function fetchSubjectsList(client = apiClient) {
return client.get('/api/subjects', { errorMessage: 'Ошибка загрузки дисциплин' });
}
export function fetchEducationForms(client = apiClient) {
return client.get('/api/education-forms', { errorMessage: 'Ошибка загрузки форм обучения' });
}
export function fetchActivityTypes(client = apiClient) {
return client.get('/api/admin/calendar/activity-types', {
errorMessage: 'Ошибка загрузки типов активности'
});
}
export function fetchTimeSlotScopes(client = apiClient) {
return client.get('/api/admin/time-slots/scopes', { errorMessage: 'Ошибка загрузки сеток времени' });
}
export function fetchTimeSlotDateAssignments(client = apiClient) {
return client.get('/api/admin/time-slots/date-assignments', {
errorMessage: 'Ошибка загрузки ручных сеток'
});
}
export function calendarsPageEndpoint({ page, size, query, academicYearId } = {}) {
const params = new URLSearchParams({
page: String(page ?? 0),
size: String(size ?? 25),
query: String(query || '').trim()
});
if (academicYearId) params.set('academicYearId', String(academicYearId));
return `/api/admin/academic-calendars/page?${params}`;
}
export function fetchCalendarsPage({ page, size, query, academicYearId }, client = apiClient) {
return client.get(calendarsPageEndpoint({ page, size, query, academicYearId }), {
errorMessage: 'Ошибка загрузки календарных графиков'
});
}
export function academicCalendarsOptionsEndpoint(query, selectedId) {
const params = new URLSearchParams({ query: String(query || ''), limit: '20' });
if (selectedId) params.set('selectedId', String(selectedId));
return `/api/admin/academic-calendars/options?${params}`;
}
export function fetchCalendarOptions(query, selectedId, client = apiClient) {
return client.get(academicCalendarsOptionsEndpoint(query, selectedId), {
errorMessage: 'Ошибка поиска календарных графиков'
});
}
export function saveAcademicYearRequest(payload, id, client = apiClient) {
return id
? client.put(`/api/admin/calendar/years/${id}`, payload, { errorMessage: 'Ошибка сохранения учебного года' })
: client.post('/api/admin/calendar/years', payload, { errorMessage: 'Ошибка сохранения учебного года' });
}
export function deleteAcademicYearRequest(id, client = apiClient) {
return client.delete(`/api/admin/calendar/years/${id}`, {
errorMessage: 'Ошибка удаления учебного года'
});
}
export function saveCalendarRequest(payload, id, client = apiClient) {
return id
? client.put(`/api/admin/academic-calendars/${id}`, payload, { errorMessage: 'Ошибка сохранения календарного графика' })
: client.post('/api/admin/academic-calendars', payload, { errorMessage: 'Ошибка сохранения календарного графика' });
}
export function deleteCalendarRequest(id, client = apiClient) {
return client.delete(`/api/admin/academic-calendars/${id}`, {
errorMessage: 'Ошибка удаления календарного графика'
});
}
export function calendarSubjectsEndpoint(calendarId) {
return `/api/admin/academic-calendars/${calendarId}/subjects`;
}
export function fetchCalendarSubjects(calendarId, client = apiClient) {
return client.get(calendarSubjectsEndpoint(calendarId), {
errorMessage: 'Ошибка загрузки дисциплин графика'
});
}
export function saveCalendarSubjectsRequest(calendarId, payload, client = apiClient) {
return client.put(calendarSubjectsEndpoint(calendarId), payload, {
errorMessage: 'Ошибка сохранения дисциплин графика'
});
}
export function calendarGridEndpoint(calendarId) {
return `/api/admin/academic-calendars/${calendarId}/grid`;
}
export function fetchCalendarGrid(calendarId, client = apiClient) {
return client.get(calendarGridEndpoint(calendarId), { errorMessage: 'Ошибка загрузки сетки' });
}
export function saveCalendarGridRequest(calendarId, rows, client = apiClient) {
return client.put(calendarGridEndpoint(calendarId), rows, {
errorMessage: 'Ошибка сохранения сетки'
});
}
export function deleteTimeSlotDateAssignmentRequest(id, client = apiClient) {
return client.delete(`/api/admin/time-slots/date-assignments/${id}`, {
errorMessage: 'Ошибка удаления ручной сетки'
});
}
// --- Подписи ---
export function calendarOptionLabel(calendar) {
return buildAcademicCalendarTitle(calendar) || calendar.title;
}
export function calendarOptionDescription(calendar) {
return calendar.specialtyName || '';
}
export function specialityLabel(speciality) {
const code = specialityCode(speciality) || speciality.id;
const name = speciality.specialityName || speciality.name || '';
return name ? `${code} - ${name}` : String(code);
}
export function specialityCode(speciality) {
return speciality?.specialityCode
|| speciality?.specialtyCode
|| speciality?.speciality_code
|| speciality?.specialty_code
|| '';
}
export function dayLabel(value) {
return DAY_OPTIONS.find(day => String(day.value) === String(value))?.label || '-';
}
export function shortDayLabel(value) {
return DAY_OPTIONS.find(day => String(day.value) === String(value))?.shortLabel || '-';
}
export function formatShortDate(value) {
const [, month, day] = String(value).split('-');
return `${day}.${month}`;
}
export function formatLongDate(value) {
const [year, month, day] = String(value).split('-');
return `${day}.${month}.${year}`;
}
export function formatDisplayDate(value) {
const [year, month, day] = String(value || '').split('-');
if (!year || !month || !day) return String(value || '');
return `${day}-${month}-${year}`;
}
export function formatDisplayDateRange(startDate, endDate) {
return `${formatDisplayDate(startDate)} — ${formatDisplayDate(endDate)}`;
}
export function dateRangeCaption(rows) {
if (!rows.length) return '';
const dates = rows.map(row => row.date).sort();
return `${formatLongDate(dates[0])} - ${formatLongDate(dates[dates.length - 1])}`;
}
export function semesterTypeTitle(semester) {
const type = normalizeSemesterType(semester.semesterType);
const label = SEMESTER_LABELS[type] || type || 'семестр';
return label.charAt(0).toUpperCase() + label.slice(1);
}
export function normalizeSemesterType(type) {
return String(type || '').toLowerCase();
}
export function semesterOptionLabel(semesterNumber) {
const course = Math.ceil(Number(semesterNumber) / 2);
const type = Number(semesterNumber) % 2 === 1 ? 'осенний' : 'весенний';
return `${semesterNumber} семестр · ${course} курс, ${type}`;
}
export function subjectLabel(subject) {
const code = subject.code ? `${subject.code} · ` : '';
return `${code}${subject.name || 'Без названия'}`;
}
export function subjectRowLabel(row) {
const code = row.subjectCode ? `${row.subjectCode} · ` : '';
return `${code}${row.subjectName || row.subjectId || 'Без названия'}`;
}
export function sortCalendarSubjectRows(rows) {
return [...(rows || [])].sort((a, b) => {
const semesterDiff = Number(a.semesterNumber) - Number(b.semesterNumber);
if (semesterDiff !== 0) return semesterDiff;
return subjectRowLabel(a).localeCompare(subjectRowLabel(b), 'ru');
});
}
export function calendarSemesterCount(calendar) {
return Math.max(1, Number(calendar?.courseCount || 0) * 2);
}
// --- Сетка годового графика ---
export function groupBy(items, keyFn) {
const grouped = new Map();
(items || []).forEach(item => {
const key = keyFn(item);
if (!grouped.has(key)) grouped.set(key, []);
grouped.get(key).push(item);
});
return grouped;
}
export function calendarRowKey(row) {
return `${row.courseNumber}:${row.date}`;
}
export function uniqueWeekNumbers(rows) {
return Array.from(new Set((rows || []).map(row => row.weekNumber)));
}
export function minIso(a, b) {
return String(a) <= String(b) ? a : b;
}
export function maxIso(a, b) {
return String(a) >= String(b) ? a : b;
}
export function previousIsoDate(value) {
const date = parseLocalIsoDate(value);
date.setDate(date.getDate() - 1);
return formatLocalDate(date);
}
export function enumerateDates(startIso, endIso) {
const result = [];
const current = parseLocalIsoDate(startIso);
const end = parseLocalIsoDate(endIso);
while (current <= end) {
const iso = formatLocalDate(current);
const jsDay = current.getDay();
result.push({ iso, dayOfWeek: jsDay === 0 ? 7 : jsDay });
current.setDate(current.getDate() + 1);
}
return result;
}
function parseLocalIsoDate(value) {
const [year, month, day] = String(value).split('-').map(Number);
const date = new Date(year, month - 1, day, 12);
if (!year || !month || !day || Number.isNaN(date.getTime())
|| date.getFullYear() !== year
|| date.getMonth() !== month - 1
|| date.getDate() !== day) {
throw new TypeError('Ожидалась дата в формате ГГГГ-ММ-ДД');
}
return date;
}
function formatLocalDate(date) {
return [
date.getFullYear(),
String(date.getMonth() + 1).padStart(2, '0'),
String(date.getDate()).padStart(2, '0')
].join('-');
}
export function buildDefaultGrid(calendar, activityTypes) {
const rows = [];
const dates = enumerateDates(calendar.academicYearStartDate, calendar.academicYearEndDate);
for (let course = 1; course <= calendar.courseCount; course += 1) {
dates.forEach(date => {
rows.push({
id: null,
calendarId: calendar.id,
courseNumber: course,
date: date.iso,
weekNumber: academicCalendarWeekNumber(calendar.academicYearStartDate, date.iso),
dayOfWeek: date.dayOfWeek,
activityTypeId: theoryActivityId(activityTypes),
activityCode: 'Т'
});
});
}
return rows;
}
export function semesterBlocksForCourse(calendar, course, courseRows, academicYears) {
const sortedRows = [...(courseRows || [])].sort((a, b) => String(a.date).localeCompare(String(b.date)));
const year = (academicYears || []).find(item => String(item.id) === String(calendar?.academicYearId));
const yearSemesters = [...(year?.semesters || [])]
.sort((a, b) => String(a.startDate).localeCompare(String(b.startDate)));
if (!yearSemesters.length) {
return [{
semesterNumber: `${course * 2 - 1}-${course * 2}`,
caption: dateRangeCaption(sortedRows),
rows: sortedRows
}];
}
return yearSemesters.map((semester, index) => {
const nextSemester = yearSemesters[index + 1];
const startDate = index === 0
? minIso(calendar.academicYearStartDate, semester.startDate)
: semester.startDate;
const endDate = nextSemester
? previousIsoDate(nextSemester.startDate)
: maxIso(calendar.academicYearEndDate, semester.endDate);
const rows = sortedRows.filter(row => row.date >= startDate && row.date <= endDate);
return {
semesterNumber: course * 2 - 1 + index,
caption: dateRangeCaption(rows) || `${formatLongDate(startDate)} - ${formatLongDate(endDate)}`,
rows
};
});
}
// Итоговые чипы сетки: счётчики по кодам активности, сортировка по коду.
export function gridTotalsEntries(rows, activityTypes) {
const counts = new Map();
(rows || []).forEach(row => {
const activity = activityById(row.activityTypeId, activityTypes);
const key = activity ? activity.code : '-';
const current = counts.get(key) || { count: 0, colorClass: activityColorClass(activity) };
current.count += 1;
counts.set(key, current);
});
return Array.from(counts.entries())
.sort(([a], [b]) => a.localeCompare(b, 'ru'))
.map(([code, entry]) => ({ code, count: entry.count, colorClass: entry.colorClass }));
}
// Общий код активности набора ячеек: одно значение — оно само, иначе пусто.
export function commonActivityValue(rows) {
const values = Array.from(new Set((rows || []).map(row => String(row.activityTypeId ?? ''))));
return values.length === 1 ? values[0] : '';
}
// --- Ручные сетки дат ---
export function timeAssignmentForDate(assignments, date) {
return (assignments || []).find(item => item.date === date);
}
export function automaticTimeScopeLabel(date, scopes) {
const jsDate = new Date(`${date}T00:00:00`);
const jsDay = jsDate.getDay();
const isoDay = jsDay === 0 ? 7 : jsDay;
const weekdayScope = (scopes || []).find(scope => scope.applyMode === 'WEEKDAY' && Number(scope.dayOfWeek) === isoDay);
const defaultScope = (scopes || []).find(scope => scope.applyMode === 'DEFAULT');
const scopeName = weekdayScope?.name || defaultScope?.name || 'базовая сетка';
return `По правилу даты (${scopeName})`;
}
// --- Тексты диалога ячейки ---
export function dayDialogTexts(rows) {
const dates = rows.map(row => row.date).sort();
const courses = new Set(rows.map(row => String(row.courseNumber)));
const weeks = new Set(rows.map(row => String(row.weekNumber)));
const weekDays = new Set(rows.map(row => String(row.dayOfWeek)));
const isSingle = rows.length === 1;
const first = rows[0];
return {
date: isSingle
? `${formatLongDate(first.date)} (${shortDayLabel(first.dayOfWeek)})`
: `${formatLongDate(dates[0])} - ${formatLongDate(dates[dates.length - 1])}`,
course: courses.size === 1 ? `${first.courseNumber} курс` : `${courses.size} курса`,
week: weeks.size === 1 ? `${first.weekNumber} неделя` : `${weeks.size} недель`,
day: weekDays.size === 1 ? dayLabel(first.dayOfWeek) : `${rows.length} ячеек`
};
}
export function dayDialogSummaryParts(rows, activityTypes) {
if (rows.length <= 1) {
const activity = rows[0] ? activityById(rows[0].activityTypeId, activityTypes) : null;
return activity
? { kind: 'single', activity }
: { kind: 'empty' };
}
const common = commonActivityValue(rows);
const activity = common ? activityById(common, activityTypes) : null;
return activity
? { kind: 'multi', activity, count: rows.length }
: { kind: 'multi-empty', count: rows.length };
}
export function dayTooltipParts(row, activityTypes, scopes, assignments) {
const activity = activityById(row.activityTypeId, activityTypes);
// Код ячейки берётся с учётом запасного типа активности (реплика legacy:
// код ячейки — activityById(...) || activityTypes[0] || {}).
const cellActivity = activity || activityTypes[0] || {};
const code = activity?.code || cellActivity.code || row.activityCode || '-';
const assignment = timeAssignmentForDate(assignments, row.date);
const timeLabel = assignment
? ((scopes || []).find(scope => String(scope.id) === String(assignment.scopeId))?.name || 'ручная сетка')
: automaticTimeScopeLabel(row.date, scopes).replace('По правилу даты ', '');
return {
code,
longDate: formatLongDate(row.date),
shortDay: shortDayLabel(row.dayOfWeek),
week: row.weekNumber,
course: row.courseNumber,
activityName: activity?.name || 'Не задана',
timeLabel,
colorClass: activity ? activityColorClass(activity) : ''
};
}
function lowercaseFirst(value) {
const text = String(value || '');
return text ? text[0].toLowerCase() + text.slice(1) : text;
}

View File

@@ -0,0 +1,131 @@
import { apiClient } from '../shared/api-client.js';
import { capabilitiesForRole } from '../../admin/js/role-capabilities.js';
// Вкладки, перенесённые на React (см. react-tabs.js), хранят только заголовок:
// их содержимое рендерят React-компоненты, HTML-фрагменты не загружаются.
export const ADMIN_ROUTES = Object.freeze({
dashboard: { title: 'Дашборд' },
users: { title: 'Управление пользователями' },
'teacher-requests': { title: 'Заявки' },
groups: { title: 'Управление группами' },
classrooms: { title: 'Аудиторный фонд' },
subjects: { title: 'Дисциплины' },
'university-structure': { title: 'Структура вуза' },
'department-workspace': { title: 'Кабинет кафедры' },
'schedule-view': { title: 'Просмотр расписаний' },
schedule: { title: 'Конструктор правил' },
'schedule-versions': { title: 'Версии расписания' },
'schedule-quality': { title: 'Анализ качества расписания' },
'academic-calendar': { title: 'Календарный учебный график' },
'auditorium-workload': { title: 'Загруженность' },
'teacher-absences': { title: 'Запросы преподавателей' }
});
const NAV_ITEMS = Object.freeze({
dashboard: { navLabel: 'Дашборд' },
'teacher-requests': { navLabel: 'Заявки' },
'department-workspace': { navLabel: 'Кабинет кафедры' },
schedule: { navLabel: 'Конструктор правил' },
'schedule-versions': { navLabel: 'Версии расписания' },
'schedule-quality': { navLabel: 'Качество расписания' },
'academic-calendar': { navLabel: 'Календарный график' },
'auditorium-workload': { navLabel: 'Загруженность' },
'schedule-view': { navLabel: 'Просмотр расписаний' },
'teacher-absences': { navLabel: 'Запросы преподавателей' },
classrooms: { navLabel: 'Аудиторный фонд' },
groups: { navLabel: 'Группы обучения' },
'university-structure': { navLabel: 'Структура вуза' },
subjects: { navLabel: 'Дисциплины' },
users: { navLabel: 'Пользователи' }
});
const NAV_SECTIONS = Object.freeze([
{ title: '', items: Object.freeze(['dashboard', 'teacher-requests', 'department-workspace']) },
{
title: 'Расписание',
items: Object.freeze([
'schedule',
'schedule-versions',
'schedule-quality',
'academic-calendar',
'auditorium-workload',
'schedule-view',
'teacher-absences'
])
},
{
title: 'Ресурсы',
items: Object.freeze(['classrooms', 'groups', 'university-structure', 'subjects'])
},
{ title: 'Система', items: Object.freeze(['users']) }
]);
const LOGO_TITLES = Object.freeze({
ADMIN: 'Magistr',
EDUCATION_OFFICE: 'Учебный отдел',
DEPARTMENT: 'Кафедра',
SCHEDULE_VIEWER: 'Расписания'
});
export function allowedAdminTabs(role) {
const capabilities = capabilitiesForRole(role);
return new Set(capabilities ? capabilities.adminTabs : []);
}
export function navSectionsForRole(role) {
const allowed = allowedAdminTabs(role);
return NAV_SECTIONS
.map(section => ({
title: section.title,
items: section.items
.filter(tab => allowed.has(tab))
.map(tab => ({ id: tab, label: NAV_ITEMS[tab].navLabel }))
}))
.filter(section => section.items.length > 0);
}
export function logoTitleForRole(role) {
return LOGO_TITLES[role] || 'Magistr';
}
export function defaultAdminTabForRole(role) {
const capabilities = capabilitiesForRole(role);
return capabilities ? capabilities.defaultAdminTab : null;
}
export function tabFromHash(hash, role) {
const allowed = allowedAdminTabs(role);
const candidate = String(hash || '').replace('#', '');
if (candidate && allowed.has(candidate)) return candidate;
return defaultAdminTabForRole(role);
}
export function requestCountLabel(count) {
const value = Number(count) || 0;
const mod10 = value % 10;
const mod100 = value % 100;
if (mod10 === 1 && mod100 !== 11) return `${value} заявка`;
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return `${value} заявки`;
return `${value} заявок`;
}
export function navBadgeLabel(count) {
const value = Number(count) || 0;
return value > 99 ? '99+' : String(value);
}
export function badgeVisibility(count) {
const value = Number(count) || 0;
return {
hidden: value <= 0,
ariaLabel: value > 0 ? `Заявок в очереди: ${value}` : 'Заявок в очереди нет'
};
}
export async function fetchPendingRequestsCount(client = apiClient) {
const page = await client.get(
'/api/teacher-requests/page?status=PENDING&page=0&size=1',
{ errorMessage: 'Не удалось загрузить счётчик заявок' }
);
return Number(page?.totalItems || 0);
}

View File

@@ -0,0 +1,681 @@
import { apiClient } from '../shared/api-client.js';
import { addDays, formatIsoDate, parseIsoDate, toIsoDate, trimTime } from '../shared/dates.js';
export const NO_BUILDING = '__no_building__';
export const DISPLAY_ALL = 'all';
export const TARGET_CLASSROOMS = 'classrooms';
export const TARGET_TEACHERS = 'teachers';
export const TARGET_DEPARTMENTS = 'departments';
const MS_PER_DAY = 24 * 60 * 60 * 1000;
export const WORKLOAD_TARGETS = Object.freeze({
[TARGET_CLASSROOMS]: Object.freeze({
allLabel: 'Все аудитории',
emptyLabel: 'Нет аудиторий по выбранным фильтрам',
rowLabel: 'Аудитория',
summaryLabel: 'Аудиторий',
periodTitle: 'Нагрузка аудитории по неделям',
selectError: 'Выберите аудиторию',
prefix: 'room:'
}),
[TARGET_TEACHERS]: Object.freeze({
allLabel: 'Все преподаватели',
emptyLabel: 'Нет преподавателей',
rowLabel: 'Преподаватель',
summaryLabel: 'Преподавателей',
periodTitle: 'Нагрузка преподавателя по неделям',
selectError: 'Выберите преподавателя',
prefix: 'teacher:'
}),
[TARGET_DEPARTMENTS]: Object.freeze({
allLabel: 'Все кафедры',
emptyLabel: 'Нет кафедр',
rowLabel: 'Кафедра',
summaryLabel: 'Кафедр',
periodTitle: 'Нагрузка кафедры по неделям',
selectError: 'Выберите кафедру',
prefix: 'department:'
})
});
export const DAY_SHORT_LABELS = Object.freeze({
1: 'Пн',
2: 'Вт',
3: 'Ср',
4: 'Чт',
5: 'Пт',
6: 'Сб',
7: 'Вс'
});
export const DAY_FULL_LABELS = Object.freeze({
1: 'Понедельник',
2: 'Вторник',
3: 'Среда',
4: 'Четверг',
5: 'Пятница',
6: 'Суббота',
7: 'Воскресенье'
});
export const DAY_ORDER = Object.freeze([1, 2, 3, 4, 5, 6, 7]);
export const CELL_PARITY_LAYOUT = Object.freeze([
Object.freeze({ parity: 'ODD', label: 'Нечётная' }),
Object.freeze({ parity: 'EVEN', label: 'Чётная' })
]);
// --- Контракты API ---
export function fetchWorkloadClassrooms(client = apiClient) {
return client.get('/api/classrooms', { errorMessage: 'Ошибка загрузки аудиторий' });
}
export function fetchWorkloadEquipments(client = apiClient) {
return client.get('/api/equipments', { errorMessage: 'Ошибка загрузки оборудования' });
}
export function fetchWorkloadGroups(client = apiClient) {
return client.get('/api/groups', { errorMessage: 'Ошибка загрузки групп' });
}
// Справочник учебных годов не критичен для расчёта: сбой молча даёт пустой список.
export async function fetchAcademicYears(client = apiClient) {
try {
return await client.get('/api/admin/calendar/years', {
errorMessage: 'Ошибка загрузки учебных годов'
});
} catch {
return [];
}
}
export function fetchWorkloadTeachers(client = apiClient) {
return client.get('/api/users/teachers', { errorMessage: 'Ошибка загрузки преподавателей' });
}
export function fetchWorkloadDepartments(client = apiClient) {
return client.get('/api/departments', { errorMessage: 'Ошибка загрузки кафедр' });
}
export function effectiveTimeSlotsEndpoint(date) {
return `/api/admin/time-slots/effective?date=${encodeURIComponent(date)}`;
}
export function fetchEffectiveTimeSlots(date, client = apiClient) {
return client.get(effectiveTimeSlotsEndpoint(date), {
errorMessage: 'Ошибка загрузки сетки времени'
});
}
export function groupScheduleEndpoint(group, startDate, endDate) {
return `/api/schedule?${new URLSearchParams({
groupId: String(group.id),
startDate,
endDate
})}`;
}
export function fetchGroupSchedule(group, startDate, endDate, client = apiClient) {
return client.get(groupScheduleEndpoint(group, startDate, endDate), {
errorMessage: 'Ошибка загрузки расписания'
});
}
// --- Справочники ---
export function sortClassrooms(classrooms) {
return [...(classrooms || [])].sort((a, b) => naturalCompare(a.name, b.name));
}
export function sortTeachers(teachers) {
return [...(teachers || [])].sort((a, b) => naturalCompare(userName(a), userName(b)));
}
export function sortDepartments(departments) {
return [...(departments || [])].sort((a, b) => naturalCompare(departmentName(a), departmentName(b)));
}
export function semestersFromYears(academicYears) {
return (academicYears || []).flatMap(year => year.semesters || []);
}
export function userName(user) {
return user?.fullName || user?.username || 'Преподаватель без имени';
}
export function departmentName(department) {
return department?.departmentName || department?.name || 'Кафедра без названия';
}
export function buildingKey(room) {
return room.building ? String(room.building) : NO_BUILDING;
}
export function buildingOptions(classrooms) {
const values = new Map();
(classrooms || []).forEach(room => {
const key = buildingKey(room);
values.set(key, key === NO_BUILDING ? 'Корпус не указан' : room.building);
});
return Array.from(values.entries())
.map(([value, label]) => ({ value, label }))
.sort((a, b) => naturalCompare(a.label, b.label));
}
export function equipmentOptions(equipments, classrooms) {
const source = (equipments || []).length
? equipments
: (classrooms || []).flatMap(room => room.equipments || []);
const values = new Map();
source.forEach(item => {
if (item?.id && item?.name) values.set(String(item.id), item.name);
});
return Array.from(values.entries())
.map(([id, name]) => ({ id, name }))
.sort((a, b) => naturalCompare(a.name, b.name));
}
export function entityOptionLabel(target, entity) {
if (target === TARGET_TEACHERS) {
const details = [entity.jobTitle, entity.departmentName].filter(Boolean).join(', ');
return details ? `${userName(entity)} · ${details}` : userName(entity);
}
if (target === TARGET_DEPARTMENTS) {
const code = entity.departmentCode ? `код ${entity.departmentCode}` : '';
return code ? `${departmentName(entity)} · ${code}` : departmentName(entity);
}
const details = [entity.building, entity.floor ? `${entity.floor} этаж` : null].filter(Boolean).join(', ');
return details ? `${entity.name} · ${details}` : entity.name;
}
// Части подписи оси таблицы: заголовок, подзаголовок и примечание сущности.
export function entityAxisParts(target, entity) {
if (target === TARGET_TEACHERS) {
return {
title: userName(entity),
subtitle: entity.jobTitle || 'Должность не указана',
note: entity.departmentName || ''
};
}
if (target === TARGET_DEPARTMENTS) {
return {
title: departmentName(entity),
subtitle: entity.departmentCode ? `Код: ${entity.departmentCode}` : 'Код не указан',
note: ''
};
}
return {
title: entity.name,
subtitle: entity.capacity ? `${entity.capacity} мест` : 'Вместимость не указана',
note: entity.building
? `${entity.building}${entity.floor ? `, ${entity.floor} этаж` : ''}`
: ''
};
}
export function entityName(target, entity) {
if (target === TARGET_TEACHERS) return userName(entity);
if (target === TARGET_DEPARTMENTS) return departmentName(entity);
return entity?.name || 'Аудитория без названия';
}
export function entitySummaryDetails(target, entity) {
if (target === TARGET_TEACHERS) {
return [entity.jobTitle, entity.departmentName].filter(Boolean).join(', ');
}
if (target === TARGET_DEPARTMENTS) {
return entity.departmentCode ? `код ${entity.departmentCode}` : '';
}
return [
entity.capacity ? `${entity.capacity} мест` : null,
entity.building || null,
entity.floor ? `${entity.floor} этаж` : null
].filter(Boolean).join(', ');
}
// --- Фильтры аудиторий ---
export function parseMinCapacity(value) {
return parseInt(value || '0', 10) || 0;
}
export function filteredClassrooms(classrooms, { buildings = [], minCapacity = 0, equipment = [] } = {}) {
return (classrooms || []).filter(room => {
const buildingMatches = !buildings.length || buildings.includes(buildingKey(room));
const capacityMatches = !minCapacity || Number(room.capacity || 0) >= minCapacity;
const equipmentMatches = !equipment.length || equipment.every(id =>
(room.equipments || []).some(item => String(item.id) === String(id))
);
return buildingMatches && capacityMatches && equipmentMatches;
});
}
// --- Занятия ---
export function uniqueLessons(lessons) {
const unique = new Map();
(lessons || []).forEach(lesson => {
if (!lesson.timeSlotId) return;
const key = [
lesson.date,
lesson.scheduleRuleSlotId || lesson.scheduleRuleId || '',
lesson.classroomId,
lesson.timeSlotId,
lesson.subjectId || '',
lesson.teacherId || ''
].join(':');
const existing = unique.get(key);
if (existing) {
existing.groupNames = mergeNames(existing.groupNames, lesson.groupNames);
existing.groupIds = mergeIds(existing.groupIds, lesson.groupIds);
existing.subgroupNames = mergeNames(existing.subgroupNames, lesson.subgroupNames);
existing.subgroupIds = mergeIds(existing.subgroupIds, lesson.subgroupIds);
return;
}
unique.set(key, {
...lesson,
groupNames: [...(lesson.groupNames || [])],
groupIds: [...(lesson.groupIds || [])],
subgroupNames: [...(lesson.subgroupNames || [])],
subgroupIds: [...(lesson.subgroupIds || [])]
});
});
return Array.from(unique.values());
}
export function teacherDepartmentId(teacherId, teachers, departments) {
const teacher = (teachers || []).find(item => sameId(item.id, teacherId));
if (!teacher) return null;
if (teacher.departmentId != null) return teacher.departmentId;
if (!teacher.departmentName) return null;
return (departments || []).find(department => departmentName(department) === teacher.departmentName)?.id ?? null;
}
export function entityIdsForLesson(lesson, target, teachers, departments) {
if (target === TARGET_TEACHERS) {
return lesson.teacherId ? [lesson.teacherId] : [];
}
if (target === TARGET_DEPARTMENTS) {
const departmentId = teacherDepartmentId(lesson.teacherId, teachers, departments);
return departmentId == null ? [] : [departmentId];
}
return lesson.classroomId ? [lesson.classroomId] : [];
}
export function groupLessonsByEntitySlot(lessons, target, teachers, departments) {
const grouped = new Map();
uniqueLessons(lessons).forEach(lesson => {
entityIdsForLesson(lesson, target, teachers, departments).forEach(entityId => {
const key = entitySlotKey(entityId, lesson.timeSlotId);
if (!grouped.has(key)) grouped.set(key, []);
grouped.get(key).push(lesson);
});
});
grouped.forEach(items => {
items.sort((a, b) => naturalCompare(a.subjectName, b.subjectName));
});
return grouped;
}
// --- Асинхронные загрузки (повторяют legacy allSettled-стратегию) ---
export async function loadSlotsForDates(dates, client = apiClient) {
const responses = await Promise.allSettled(
dates.map(date => fetchEffectiveTimeSlots(date, client))
);
const slotsByDate = new Map();
responses.forEach((response, index) => {
const slots = response.status === 'fulfilled' ? response.value || [] : [];
slotsByDate.set(dates[index], [...slots].sort(compareSlots));
});
return {
slotsByDate,
failedLoads: responses.filter(response => response.status === 'rejected').length
};
}
export async function loadOverviewLessons({ groups, date, target, teachers, departments }, client = apiClient) {
if (!(groups || []).length) {
return { lessonsByEntitySlot: new Map(), failedLoads: 0 };
}
const responses = await Promise.allSettled(
groups.map(group => fetchGroupSchedule(group, date, date, client))
);
const lessons = responses
.filter(response => response.status === 'fulfilled')
.flatMap(response => response.value || []);
return {
lessonsByEntitySlot: groupLessonsByEntitySlot(lessons, target, teachers, departments),
failedLoads: responses.filter(response => response.status === 'rejected').length
};
}
export async function loadEntityLessons({ groups, startDate, endDate, entityId, target, teachers, departments }, client = apiClient) {
if (!(groups || []).length) {
return {
lessons: [],
lessonsByDateSlot: new Map(),
parityLessons: [],
failedLoads: 0
};
}
const responses = await Promise.allSettled(
groups.map(group => fetchGroupSchedule(group, startDate, endDate, client))
);
const parityLessons = responses
.filter(response => response.status === 'fulfilled')
.flatMap(response => response.value || []);
const lessons = uniqueLessons(parityLessons)
.filter(lesson => entityIdsForLesson(lesson, target, teachers, departments).some(id => sameId(id, entityId)));
const lessonsByDateSlot = new Map();
lessons.forEach(lesson => {
const slotKey = lessonSlotKey(lesson);
if (!lesson.date || slotKey == null) return;
const key = dateSlotKey(lesson.date, slotKey);
if (!lessonsByDateSlot.has(key)) lessonsByDateSlot.set(key, []);
lessonsByDateSlot.get(key).push(lesson);
});
lessonsByDateSlot.forEach(items => {
items.sort((a, b) => naturalCompare(a.subjectName, b.subjectName));
});
return {
lessons,
lessonsByDateSlot,
parityLessons,
failedLoads: responses.filter(response => response.status === 'rejected').length
};
}
// --- Ключи и метки слотов ---
export function entitySlotKey(entityId, timeSlotId) {
return `${entityId}:${timeSlotId}`;
}
export function dateSlotKey(date, slotKey) {
return `${date}:${slotKey}`;
}
export function slotOrderKey(slot) {
const value = slot?.orderNumber ?? slot?.timeSlotOrder ?? slot?.id;
return value == null ? null : String(value);
}
export function lessonSlotKey(lesson) {
const value = lesson?.timeSlotOrder ?? lesson?.timeSlotId;
return value == null ? null : String(value);
}
export function timeSlotLabel(slot) {
return `${trimTime(slot.startTime)}-${trimTime(slot.endTime)}`;
}
export function scopeShort(slot) {
if (slot.scopeApplyMode === 'WEEKDAY') {
return dayShort(slot.scopeDayOfWeek);
}
return 'Ручн.';
}
export function dayShort(dayOfWeek) {
return DAY_SHORT_LABELS[Number(dayOfWeek)] || '-';
}
export function isoDay(value) {
const day = parseIsoDate(value).getDay();
return day === 0 ? 7 : day;
}
// --- Двухнедельный период ---
export function twoWeekRange(value) {
const selected = parseIsoDate(value);
const start = addDays(selected, 1 - isoDay(value));
const end = addDays(start, 13);
return {
startDate: toIsoDate(start),
endDate: toIsoDate(end)
};
}
export function datesBetween(startDate, endDate) {
const dates = [];
for (let date = parseIsoDate(startDate); date <= parseIsoDate(endDate); date = addDays(date, 1)) {
dates.push(toIsoDate(date));
}
return dates;
}
// --- Чётность недель ---
export function weekNumberForDate(date, semesters) {
const semester = (semesters || []).find(item => item.startDate <= date && item.endDate >= date);
if (!semester) return null;
const days = Math.floor((parseIsoDate(date) - parseIsoDate(semester.startDate)) / MS_PER_DAY);
return Math.floor(days / 7) + 1;
}
export function parityForDate(date, lessons, semesters) {
const lessonParity = (lessons || []).find(lesson =>
lesson.date === date && ['ODD', 'EVEN'].includes(lesson.parity)
)?.parity;
if (lessonParity) return lessonParity;
const weekNumber = weekNumberForDate(date, semesters);
if (!weekNumber) return null;
return weekNumber % 2 === 0 ? 'EVEN' : 'ODD';
}
export function buildDateGridByDay(dates, lessons, semesters) {
const datesByDay = {};
let unknownParityDates = 0;
dates.forEach((date, index) => {
let parity = parityForDate(date, lessons, semesters);
if (!parity) {
unknownParityDates += 1;
parity = index < 7 ? 'ODD' : 'EVEN';
}
const day = isoDay(date);
if (!datesByDay[day]) datesByDay[day] = {};
datesByDay[day][parity] = date;
});
return { datesByDay, unknownParityDates };
}
export function uniqueWeekNumbers(dates, semesters) {
return Array.from(new Set(dates.map(date => weekNumberForDate(date, semesters)).filter(Boolean)))
.sort((a, b) => a - b);
}
export function roomWeekCaption(datesByDay, semesters) {
return CELL_PARITY_LAYOUT
.map(({ parity, label }) => {
const dates = DAY_ORDER.map(day => datesByDay[day]?.[parity]).filter(Boolean);
const weeks = uniqueWeekNumbers(dates, semesters);
const weekText = weeks.length ? `учебная неделя ${weeks.join(', ')}` : 'неделя вне семестра';
const positionText = parity === 'ODD' ? 'сверху' : 'снизу';
return `${label} ${positionText}, ${weekText}`;
})
.join('; ');
}
// --- Строки слотов сводной недели ---
export function buildSlotRows(dates, slotsByDate, lessons) {
const rows = new Map();
dates.forEach(date => {
(slotsByDate.get(date) || []).forEach(slot => {
const key = slotOrderKey(slot);
if (key == null) return;
putSlotRow(rows, key, slot.orderNumber, slot.startTime, slot.endTime);
});
});
(lessons || []).forEach(lesson => {
const key = lessonSlotKey(lesson);
if (key == null) return;
putSlotRow(rows, key, lesson.timeSlotOrder, lesson.startTime, lesson.endTime);
});
return Array.from(rows.values()).sort(compareSlotRows);
}
function putSlotRow(rows, key, orderNumber, startTime, endTime) {
const existing = rows.get(key);
const next = {
key,
orderNumber,
startTime,
endTime,
orderLabel: orderNumber ? `${orderNumber} пара` : 'Пара',
timeLabel: startTime || endTime ? `${trimTime(startTime)}-${trimTime(endTime)}` : 'Время не указано'
};
if (!existing || existing.timeLabel === 'Время не указано') {
rows.set(key, next);
}
}
export function hasSlotForDate(date, key, slotsByDate) {
return (slotsByDate.get(date) || []).some(slot => slotOrderKey(slot) === key);
}
// --- Состояния ячеек сводной недели ---
export function roomSlotState(date, row, slotsByDate, lessonsByDateSlot) {
if (!date) {
return { kind: 'empty', lessons: [] };
}
const key = dateSlotKey(date, row.key);
const lessons = lessonsByDateSlot.get(key) || [];
const slotExists = hasSlotForDate(date, row.key, slotsByDate) || lessons.length > 0;
if (lessons.length) {
return { kind: 'busy', lessons };
}
if (!slotExists) {
return { kind: 'missing', lessons: [] };
}
return { kind: 'free', lessons: [] };
}
export function roomSlotStatesEqual(first, second) {
if (first.kind !== second.kind) return false;
if (first.kind !== 'busy') return true;
return lessonSetSignature(first.lessons) === lessonSetSignature(second.lessons);
}
function lessonSetSignature(lessons) {
return lessons
.map(lessonContentSignature)
.sort((a, b) => naturalCompare(a, b))
.join('|');
}
function lessonContentSignature(lesson) {
return [
lesson.scheduleRuleSlotId || lesson.scheduleRuleId || '',
lesson.subjectId || lesson.subjectName || '',
lesson.teacherId || lesson.teacherName || '',
lesson.lessonTypeId || lesson.lessonTypeName || '',
lesson.lessonFormat || '',
valueListSignature(lesson.groupIds, lesson.groupNames),
valueListSignature(lesson.subgroupIds, lesson.subgroupNames)
].join(':');
}
function valueListSignature(primary = [], fallback = []) {
const primaryList = Array.isArray(primary) ? primary : [];
const fallbackList = Array.isArray(fallback) ? fallback : [];
const source = primaryList.length ? primaryList : fallbackList;
return [...source]
.map(value => String(value))
.sort((a, b) => naturalCompare(a, b))
.join(',');
}
// --- Счётчики ---
export function countOccupiedCells(entities, timeSlots, lessonsByEntitySlot) {
return (entities || []).reduce((count, entity) => count + (timeSlots || []).filter(slot =>
(lessonsByEntitySlot.get(entitySlotKey(entity.id, slot.id)) || []).length > 0
).length, 0);
}
export function countAvailableRoomCells(dates, slotsByDate, lessonsByDateSlot) {
return (dates || []).reduce((count, date) => {
const keys = new Set((slotsByDate.get(date) || []).map(slotOrderKey).filter(key => key != null));
lessonsByDateSlot.forEach((items, key) => {
if (items.length && key.startsWith(`${date}:`)) {
keys.add(key.slice(date.length + 1));
}
});
return count + keys.size;
}, 0);
}
export function countOccupiedRoomCells(lessonsByDateSlot) {
return Array.from(lessonsByDateSlot.values()).filter(items => items.length > 0).length;
}
// --- Сводки ---
export function overviewSummaryText({ target, dateValue, entityCount, occupiedCells, totalCells, failedGroupLoads, selectedEntityId }) {
const config = WORKLOAD_TARGETS[target] || WORKLOAD_TARGETS[TARGET_CLASSROOMS];
const percent = totalCells ? Math.round((occupiedCells / totalCells) * 100) : 0;
const date = dateValue ? formatIsoDate(dateValue) : '-';
const day = dateValue ? dayShort(isoDay(dateValue)) : '-';
const failed = failedGroupLoads ? `, ошибок загрузки групп: ${failedGroupLoads}` : '';
const selected = selectedEntityId ? ', выбран один объект' : '';
return `Дата: ${date}, ${day}. ${config.summaryLabel}: ${entityCount}${selected}. Занятых слотов: ${occupiedCells} из ${totalCells} (${percent}%)${failed}.`;
}
export function roomSummaryParts({ target, entity, range, totalCells, occupiedCells, failedGroupLoads, failedSlotLoads, unknownParityDates }) {
const percent = totalCells ? Math.round((occupiedCells / totalCells) * 100) : 0;
const warnings = [];
if (failedGroupLoads) warnings.push(`ошибок групп: ${failedGroupLoads}`);
if (failedSlotLoads) warnings.push(`ошибок сеток: ${failedSlotLoads}`);
if (unknownParityDates) warnings.push('часть дат вне семестра');
return {
name: entityName(target, entity),
details: entitySummaryDetails(target, entity),
rangeLabel: `${formatIsoDate(range.startDate)} - ${formatIsoDate(range.endDate)}`,
occupancyLabel: `Занято: ${occupiedCells} из ${totalCells} (${percent}%)`,
warnings: warnings.join(', ')
};
}
export function formatDateShort(value) {
const [, month, day] = String(value).split('-');
return `${day}.${month}`;
}
// --- Сравнения ---
export function compareSlots(a, b) {
return Number(a.orderNumber || 0) - Number(b.orderNumber || 0)
|| naturalCompare(a.startTime, b.startTime);
}
export function compareSlotRows(a, b) {
const first = Number(a.orderNumber || a.key);
const second = Number(b.orderNumber || b.key);
if (!Number.isNaN(first) && !Number.isNaN(second) && first !== second) {
return first - second;
}
return naturalCompare(a.startTime, b.startTime)
|| naturalCompare(a.key, b.key);
}
export function sameId(first, second) {
return String(first) === String(second);
}
export function naturalCompare(a, b) {
return String(a || '').localeCompare(String(b || ''), 'ru', { numeric: true, sensitivity: 'base' });
}
function mergeNames(first = [], second = []) {
return Array.from(new Set([...first, ...second].filter(Boolean))).sort((a, b) => naturalCompare(a, b));
}
function mergeIds(first = [], second = []) {
return Array.from(new Set([...first, ...second].filter(id => id != null)));
}

View File

@@ -0,0 +1,87 @@
import { apiClient } from '../shared/api-client.js';
export async function fetchEquipments(client = apiClient) {
try {
return await client.get('/api/equipments');
} catch {
console.error('Не удалось загрузить список оборудования');
return [];
}
}
export function fetchClassrooms(client = apiClient) {
return client.get(
'/api/classrooms?includeArchived=true',
{ errorMessage: 'Не удалось загрузить список аудиторий' }
);
}
export function createClassroom({ name, capacity, equipmentIds }, client = apiClient) {
return client.post(
'/api/classrooms',
{ name, capacity, equipmentIds, isAvailable: true },
{ errorMessage: 'Ошибка создания' }
);
}
export function updateClassroom(id, payload, client = apiClient) {
return client.put(`/api/classrooms/${id}`, payload, { errorMessage: 'Ошибка обновления' });
}
export function toggleClassroomAvailability(id, isAvailable, client = apiClient) {
return updateClassroom(id, { isAvailable }, client);
}
export function archiveClassroom(id, client = apiClient) {
return client.delete(`/api/classrooms/${id}`, { errorMessage: 'Не удалось архивировать аудиторию' });
}
export function restoreClassroom(id, client = apiClient) {
return client.post(
`/api/classrooms/${id}/restore`,
{},
{ errorMessage: 'Не удалось восстановить аудиторию' }
);
}
export function createEquipment(name, client = apiClient) {
return client.post('/api/equipments', { name }, { errorMessage: 'Ошибка добавления' });
}
export function deleteEquipment(id, client = apiClient) {
return client.delete(`/api/equipments/${id}`, { errorMessage: 'Не удалось удалить оборудование' });
}
export function parseCapacity(value) {
return parseInt(value, 10);
}
export function activeClassrooms(classrooms) {
return (classrooms || []).filter(classroom => classroom.isAvailable && classroom.status !== 'ARCHIVED');
}
export function classroomStatusLabel(classroom) {
if (classroom.status === 'ARCHIVED') return 'Архив';
return classroom.isAvailable ? 'Доступна' : 'Не доступна';
}
export function equipmentNamesText(classroom) {
const names = (classroom.equipments || []).map(equipment => equipment.name);
return names.length ? names.join(', ') : null;
}
export function equipmentSelectLabel(selectedNames) {
const names = selectedNames || [];
if (names.length === 0) return 'Выберите оборудование...';
if (names.length === 1) return names[0];
return `Выбрано: ${names.length}`;
}
export function equipmentIdSelection(equipments, selectedIds) {
const selected = new Set((selectedIds || []).map(String));
return (equipments || []).map(equipment => ({
id: equipment.id,
name: equipment.name,
checked: selected.has(String(equipment.id))
}));
}

View File

@@ -0,0 +1,232 @@
import { apiClient } from '../shared/api-client.js';
import {
CONFLICT_CHECK_STATE,
conflictCheckPresentation,
currentWeekDateRange,
formatLocalDate,
loadDepartmentSchedules
} from '../../admin/js/dashboard-conflicts.js';
export { CONFLICT_CHECK_STATE, conflictCheckPresentation, currentWeekDateRange, formatLocalDate };
export async function fetchDashboardReferenceData(client = apiClient) {
const [departments, classrooms, groups, teachers] = await Promise.all([
client.get('/api/departments', { errorMessage: 'Не удалось загрузить кафедры' }),
client.get('/api/classrooms?includeArchived=false', { errorMessage: 'Не удалось загрузить аудитории' }),
client.get('/api/groups?includeArchived=false', { errorMessage: 'Не удалось загрузить группы' }),
client.get('/api/users/teachers', { errorMessage: 'Не удалось загрузить преподавателей' })
]);
return { departments, classrooms, groups, teachers };
}
export function todayScheduleEndpoint(departmentId, dateStr) {
return `/api/schedule/search?departmentId=${departmentId}&startDate=${dateStr}&endDate=${dateStr}`;
}
export async function fetchTodayLessons(departments, dateStr, client = apiClient) {
const loads = (departments || []).map(department =>
client.get(todayScheduleEndpoint(department.id, dateStr)).catch(() => [])
);
const perDepartment = await Promise.all(loads);
return perDepartment.flat();
}
export function weekScheduleEndpoint(departmentId, startDate, endDate) {
return `/api/schedule/search?departmentId=${departmentId}&startDate=${startDate}&endDate=${endDate}`;
}
export async function loadWeekDepartmentSchedules(departments, range, client = apiClient) {
return loadDepartmentSchedules(
departments,
department => client.get(weekScheduleEndpoint(department.id, range.startDate, range.endDate))
);
}
export async function fetchEffectiveTimeSlots(dateStr, client = apiClient) {
try {
return await client.get(`/api/admin/time-slots/effective?date=${dateStr}`);
} catch {
const baseSlots = await client.get('/api/admin/time-slots').catch(() => []);
return (baseSlots || []).filter(slot => slot.scopeId === 1 || !slot.scopeId);
}
}
export function sortTimeSlots(timeSlots) {
return [...(timeSlots || [])].sort((a, b) => (a.orderNumber || 0) - (b.orderNumber || 0));
}
export function totalStudents(groups) {
return (groups || []).reduce((total, group) => total + (group.groupSize || 0), 0);
}
export function activeClassrooms(classrooms) {
return (classrooms || []).filter(classroom => classroom.isAvailable && classroom.status !== 'ARCHIVED');
}
export function classroomOccupancyLabel(classrooms, todayLessons) {
const occupied = new Set((todayLessons || []).map(lesson => lesson.classroomId).filter(Boolean));
const active = activeClassrooms(classrooms);
const percent = active.length ? Math.round((occupied.size / active.length) * 100) : 0;
return `${percent}% (${occupied.size}/${active.length})`;
}
export function currentTimeString(date = new Date()) {
return date.toTimeString().split(' ')[0];
}
export function findCurrentSlot(timeSlots, date = new Date()) {
const time = currentTimeString(date);
return (timeSlots || []).find(slot => time >= slot.startTime && time <= slot.endTime) || null;
}
export function currentSlotLabel(slot) {
if (!slot) return 'Вне сетки пар';
return `Пара №${slot.orderNumber} (${slot.startTime.substring(0, 5)} – ${slot.endTime.substring(0, 5)})`;
}
export function lessonsForSlot(lessons, slotOrder) {
return (lessons || []).filter(lesson => lesson.timeSlotOrder === slotOrder);
}
export function freeClassroomsForSlot(classrooms, lessons, slotOrder) {
const active = activeClassrooms(classrooms);
if (slotOrder === null || slotOrder === undefined) return active;
const occupiedIds = new Set(
lessonsForSlot(lessons, slotOrder).map(lesson => lesson.classroomId).filter(Boolean)
);
return active.filter(classroom => !occupiedIds.has(classroom.id));
}
/**
* Выявляет конфликты расписания недели: накладки преподавателей, накладки
* аудиторий и превышение вместимости. Описания возвращаются сегментами,
* где { strong } выделяется полужирным при рендере.
*/
export function detectScheduleConflicts(weekLessons, groups, classrooms) {
const lessons = weekLessons || [];
const groupSizeMap = new Map((groups || []).map(group => [group.id, group.groupSize || 0]));
const classroomCapacityMap = new Map((classrooms || []).map(classroom => [classroom.id, classroom.capacity || 0]));
const conflicts = [];
collectTeacherCollisions(lessons, conflicts);
collectClassroomCollisions(lessons, conflicts);
collectCapacityOverflows(lessons, groupSizeMap, classroomCapacityMap, conflicts);
return conflicts;
}
function collectTeacherCollisions(lessons, conflicts) {
const slots = groupLessons(lessons, lesson => lesson.teacherId && `${lesson.date}_${lesson.timeSlotOrder}_${lesson.teacherId}`);
slots.forEach(collisions => {
if (collisions.length < 2) return;
const classroomIds = new Set(collisions.map(lesson => lesson.classroomId));
if (classroomIds.size <= 1) return;
const first = collisions[0];
const list = collisions
.map(lesson => `ауд. ${lesson.classroomName} (группа ${lesson.groupNames.join(', ')})`)
.join(', ');
conflicts.push({
type: 'TEACHER_COLLISION',
title: 'Накладка в расписании преподавателя',
severity: 'error',
segments: [
'Преподаватель ',
{ strong: first.teacherName },
' назначен одновременно в разные места: ',
list,
' на ',
{ strong: `${first.date} (${first.dayName}), пара №${first.timeSlotOrder}` },
'.'
]
});
});
}
function collectClassroomCollisions(lessons, conflicts) {
const slots = groupLessons(lessons, lesson => lesson.classroomId && `${lesson.date}_${lesson.timeSlotOrder}_${lesson.classroomId}`);
slots.forEach(collisions => {
if (collisions.length < 2) return;
const teacherIds = new Set(collisions.map(lesson => lesson.teacherId));
const subjectIds = new Set(collisions.map(lesson => lesson.subjectId));
if (teacherIds.size <= 1 && subjectIds.size <= 1) return;
const first = collisions[0];
const details = collisions
.map(lesson => `препод. ${lesson.teacherName} (предмет: ${lesson.subjectName}, группа: ${lesson.groupNames.join(', ')})`)
.join(' И ');
conflicts.push({
type: 'CLASSROOM_COLLISION',
title: 'Накладка в аудитории',
severity: 'error',
segments: [
'Аудитория ',
{ strong: first.classroomName },
' занята одновременно разными занятиями: ',
details,
' на ',
{ strong: `${first.date} (${first.dayName}), пара №${first.timeSlotOrder}` },
'.'
]
});
});
}
function collectCapacityOverflows(lessons, groupSizeMap, classroomCapacityMap, conflicts) {
const slots = groupLessons(lessons, lesson => lesson.classroomId && `${lesson.date}_${lesson.timeSlotOrder}_${lesson.classroomId}`);
slots.forEach(collisions => {
if (!collisions.length) return;
const first = collisions[0];
const capacity = classroomCapacityMap.get(first.classroomId) || 0;
const groupIds = new Set();
collisions.forEach(lesson => {
(lesson.groupIds || []).forEach(id => groupIds.add(id));
});
let students = 0;
groupIds.forEach(id => {
students += groupSizeMap.get(id) || 0;
});
if (students <= capacity) return;
const groupNamesList = collisions.map(lesson => lesson.groupNames.join(', ')).join(', ');
conflicts.push({
type: 'CAPACITY_OVERFLOW',
title: 'Превышение вместимости аудитории',
severity: 'warning',
segments: [
'Вместимость аудитории ',
{ strong: first.classroomName },
' составляет ',
{ strong: `${capacity} чел.` },
', однако на занятие назначено ',
{ strong: `${students} человек` },
` (группы: ${groupNamesList}) на `,
{ strong: `${first.date} (${first.dayName}), пара №${first.timeSlotOrder}` },
'.'
]
});
});
}
function groupLessons(lessons, keyOf) {
const slots = new Map();
lessons.forEach(lesson => {
const key = keyOf(lesson);
if (!key) return;
if (!slots.has(key)) slots.set(key, []);
slots.get(key).push(lesson);
});
return slots;
}
export function conflictDescriptionText(conflict) {
return (conflict.segments || [])
.map(segment => (typeof segment === 'string' ? segment : segment.strong))
.join('');
}

View File

@@ -0,0 +1,262 @@
import { apiClient } from '../shared/api-client.js';
import { defaultSchedulePeriod } from '../../admin/js/schedule-period.js';
import { toIsoDate } from '../shared/dates.js';
export const SUBJECT_TABLE_COLUMNS = 4;
export const DEPARTMENT_REQUEST_TABLE_COLUMNS = 6;
// --- Справочники и период ---
export function fetchDepartments(client = apiClient) {
return client.get('/api/departments', { errorMessage: 'Не удалось загрузить список кафедр' });
}
// Селектор кафедры кабинета подписывает варианты только названием кафедры.
export function departmentOptionLabel(department) {
return department.departmentName || department.name || department.id;
}
export function fetchSemesters(client = apiClient) {
return client.get('/api/schedule/semesters', { errorMessage: 'Не удалось загрузить семестры' });
}
export function defaultWorkloadPeriod(semesters, date = new Date()) {
return defaultSchedulePeriod(semesters, toIsoDate(date));
}
export function fallbackWorkloadPeriod(date = new Date()) {
const end = new Date(date);
end.setDate(date.getDate() + 7);
return { startDate: toIsoDate(date), endDate: toIsoDate(end) };
}
// --- Дисциплины кафедры ---
export function departmentSubjectsEndpoint(departmentId) {
return `/api/department/subjects?departmentId=${encodeURIComponent(departmentId || '')}`;
}
export function fetchDepartmentSubjects(departmentId, client = apiClient) {
return client.get(departmentSubjectsEndpoint(departmentId), {
errorMessage: 'Ошибка загрузки дисциплин'
});
}
export function importSubjectsPayload(rows, departmentId) {
if (!departmentId) return { error: 'Выберите кафедру' };
const payload = (rows || [])
.filter(item => item.name)
.map(item => ({
code: item.code,
name: item.name,
departmentId: Number(departmentId)
}));
if (!payload.length) return { error: 'Заполните название хотя бы одной дисциплины' };
return { payload };
}
export function importSubjects(rows, client = apiClient) {
return client.post('/api/department/subjects/import', rows, {
errorMessage: 'Ошибка загрузки дисциплин'
});
}
// --- Комментарии к дисциплинам ---
export function fetchSubjectComments(subjectId, client = apiClient) {
return client.get(`/api/department/subjects/${encodeURIComponent(subjectId)}/comments`, {
errorMessage: 'Ошибка загрузки комментариев'
});
}
export function saveSubjectComment(subjectId, comment, client = apiClient) {
return client.post(
`/api/department/subjects/${encodeURIComponent(subjectId)}/comments`,
{ comment },
{ errorMessage: 'Ошибка сохранения комментария' }
);
}
// --- Преподаватели и нагрузка ---
export function departmentTeachersEndpoint(departmentId) {
return `/api/department/teachers?departmentId=${encodeURIComponent(departmentId || '')}`;
}
export function fetchDepartmentTeachers(departmentId, client = apiClient) {
return client.get(departmentTeachersEndpoint(departmentId), {
errorMessage: 'Ошибка загрузки преподавателей'
});
}
export function fetchAllTeachers(client = apiClient) {
return client.get('/api/users/teachers', {
errorMessage: 'Ошибка загрузки преподавателей'
});
}
export function availableTeachers(allTeachers, assignedTeachers) {
const assignedIds = new Set((assignedTeachers || []).map(teacher => String(teacher.id)));
return (allTeachers || []).filter(teacher => !assignedIds.has(String(teacher.id)));
}
export function teacherOptionLabel(teacher) {
return teacher.fullName || teacher.username || `ID ${teacher.id}`;
}
export function assignTeacherError(departmentId, teacherId) {
if (!departmentId) return 'Выберите кафедру';
if (!teacherId) return 'Выберите преподавателя';
return null;
}
export function assignTeacher(teacherId, departmentId, comment, client = apiClient) {
return client.post(
`/api/department/teachers/${encodeURIComponent(teacherId)}/assignments`,
{
departmentId: Number(departmentId),
comment: comment || null
},
{ errorMessage: 'Ошибка привязки преподавателя' }
);
}
export function teacherWorkloadEndpoint({ startDate, endDate, departmentId }) {
const params = new URLSearchParams({ startDate, endDate });
if (departmentId) params.set('departmentId', departmentId);
return `/api/workload/teachers?${params}`;
}
export function fetchTeacherWorkload(params, client = apiClient) {
return client.get(teacherWorkloadEndpoint(params), {
errorMessage: 'Ошибка расчёта нагрузки'
});
}
// --- Заявки кафедры ---
export function departmentTeacherRequestsEndpoint(departmentId) {
return `/api/department/teacher-requests?departmentId=${encodeURIComponent(departmentId || '')}`;
}
export function fetchDepartmentTeacherRequests(departmentId, client = apiClient) {
return client.get(departmentTeacherRequestsEndpoint(departmentId), {
errorMessage: 'Ошибка загрузки заявок'
});
}
export function teacherRequestPayload(fields, departmentId) {
if (!departmentId) return { error: 'Выберите кафедру' };
const username = (fields.username || '').trim();
const fullName = (fields.fullName || '').trim();
if (!username || !fullName) return { error: 'Заполните логин и ФИО преподавателя' };
return {
payload: {
departmentId: Number(departmentId),
username,
fullName,
jobTitle: (fields.jobTitle || '').trim(),
comment: (fields.comment || '').trim()
}
};
}
export function submitTeacherRequest(payload, client = apiClient) {
return client.post('/api/department/teacher-requests', payload, {
errorMessage: 'Ошибка отправки заявки'
});
}
// --- Метки ---
export function subjectCountLabel(count) {
const tail = count % 100;
if (tail >= 11 && tail <= 14) return `${count} дисциплин`;
if (count % 10 === 1) return `${count} дисциплина`;
if ([2, 3, 4].includes(count % 10)) return `${count} дисциплины`;
return `${count} дисциплин`;
}
export function requestStatusLabel(status) {
return {
PENDING: 'На рассмотрении',
APPROVED: 'Одобрена',
REJECTED: 'Отклонена'
}[status] || status || '-';
}
export function requestStatusClass(status) {
return {
PENDING: 'badge-ef',
APPROVED: 'badge-student',
REJECTED: 'badge-admin'
}[status] || 'badge-ef';
}
export function formatDateTime(value) {
if (!value) return '-';
return String(value).replace('T', ' ').slice(0, 16);
}
// --- Сопоставление преподавателей и нагрузки ---
export function normalizeTeacherName(value) {
return value ? `name:${String(value).trim().toLowerCase()}` : null;
}
export function workloadKeys(item) {
return [
item.id == null ? null : `id:${item.id}`,
normalizeTeacherName(item.fullName || item.name || item.username)
].filter(Boolean);
}
export function workloadLabel(item, errorMessage) {
if (errorMessage) return errorMessage;
if (!item) return '0 ак. ч., 0 занятий за период';
return `${item.academicHours ?? 0} ак. ч., ${item.lessonCount ?? 0} занятий за период`;
}
// Воспроизводит legacy-логику сводки: преподаватели кафедры, затем записи
// нагрузки, не привязанные ни к одному преподавателю (есть занятия в расписании).
export function teacherMetrics(teachers, workload) {
const workloadByTeacher = new Map();
const usedWorkloadKeys = new Set();
(workload || [])
.filter(item => item && (item.id || item.name))
.forEach(item => {
workloadKeys(item).forEach(key => {
if (!workloadByTeacher.has(key)) {
workloadByTeacher.set(key, item);
}
});
});
const rows = (teachers || []).map(teacher => {
const workloadItem = workloadKeys(teacher)
.map(key => workloadByTeacher.get(key))
.find(Boolean);
if (workloadItem) {
workloadKeys(workloadItem).forEach(key => usedWorkloadKeys.add(key));
}
return {
name: teacher.fullName || teacher.username || '-',
meta: teacher.jobTitle || 'Должность не указана',
workload: workloadItem
};
});
(workload || [])
.filter(item => item && (item.id || item.name))
.filter(item => workloadKeys(item).every(key => !usedWorkloadKeys.has(key)))
.forEach(item => {
rows.push({
name: item.name || '-',
meta: 'Есть занятия в расписании кафедры',
workload: item
});
});
return rows;
}

View File

@@ -0,0 +1,354 @@
import { apiClient } from '../shared/api-client.js';
import { buildAcademicCalendarTitle } from '../../admin/js/views/academic-calendar-title.js';
export const GROUP_TABLE_COLUMNS = 10;
export const GROUP_PAGE_SIZES = [25, 50, 100];
export const GROUP_SORT_OPTIONS = Object.freeze([
{ value: 'name,asc', label: 'По названию А–Я' },
{ value: 'name,desc', label: 'По названию Я–А' },
{ value: 'yearStartStudy,desc', label: 'Сначала новые наборы' },
{ value: 'groupSize,desc', label: 'Сначала крупные' },
{ value: 'status,asc', label: 'По статусу' }
]);
export const SUBGROUP_COUNT_OPTIONS = Object.freeze([
{ value: '0', label: 'Без разделения на подгруппы' },
{ value: '2', label: 'Две подгруппы' },
{ value: '3', label: 'Три подгруппы' }
]);
export const FILTER_EF_EMPTY_TEXT = 'Выберите форму обучения';
export function normalizeGroupsState(restored = {}) {
const size = Number(restored.groupSize);
return {
query: restored.groupQuery || '',
page: Math.max(Number(restored.groupPage) || 0, 0),
size: GROUP_PAGE_SIZES.includes(size) ? size : 25,
sort: GROUP_SORT_OPTIONS.some(option => option.value === restored.groupSort)
? restored.groupSort
: 'name,asc',
educationFormIds: Array.isArray(restored.groupEducationForm)
? restored.groupEducationForm.map(String)
: []
};
}
export function initialGroupsState(restored = {}) {
return normalizeGroupsState(restored);
}
export function groupsQueryParams(state) {
const [sort, direction] = String(state.sort || 'name,asc').split(',');
const params = new URLSearchParams({
query: state.query || '',
page: String(state.page || 0),
size: String(state.size || 25),
sort,
direction,
includeArchived: 'true'
});
(state.educationFormIds || []).forEach(id => params.append('educationFormId', id));
return `/api/groups/page?${params}`;
}
export function groupsUrlState(state) {
return {
groupQuery: state.query || '',
groupPage: state.page || '',
groupSize: state.size === 25 ? '' : state.size,
groupSort: state.sort === 'name,asc' ? '' : state.sort,
groupEducationForm: state.educationFormIds || []
};
}
// --- Справочники ---
export function fetchEducationForms(client = apiClient) {
// legacy молча подставляет пустой список при сбое загрузки форм обучения
return client.get('/api/education-forms').catch(() => []);
}
export function fetchDepartments(client = apiClient) {
return client.get('/api/departments', { errorMessage: 'Не удалось загрузить список кафедр' });
}
export function fetchSpecialties(client = apiClient) {
return client.get('/api/specialties', { errorMessage: 'Не удалось загрузить список специальностей' });
}
export function fetchAcademicYears(client = apiClient) {
return client.get('/api/admin/calendar/years', { errorMessage: 'Не удалось загрузить учебные годы' });
}
export async function fetchProfilesForSpecialties(specialties, client = apiClient) {
const pairs = await Promise.all((specialties || []).map(async (speciality) => {
const profiles = await client.get(
`/api/specialties/${speciality.id}/profiles`,
{ errorMessage: 'Не удалось загрузить профили обучения' }
);
return [String(speciality.id), profiles || []];
}));
return new Map(pairs);
}
// --- Реестр групп ---
export function fetchGroupsPage(state, client = apiClient) {
return client.get(groupsQueryParams(state), { errorMessage: 'Не удалось загрузить группы' });
}
export function groupPayloadFromFields(fields) {
const checks = [
['name', 'Введите название группы'],
['groupSize', 'Введите размер группы'],
['educationFormId', 'Выберите форму обучения'],
['departmentId', 'Выберите кафедру'],
['yearStartStudy', 'Введите год начала обучения'],
['specialtyId', 'Выберите специальность'],
['specialtyProfileId', 'Выберите профиль обучения']
];
for (const [field, message] of checks) {
if (!fields[field]) return { error: message };
}
return {
payload: {
name: fields.name,
groupSize: Number(fields.groupSize),
educationFormId: Number(fields.educationFormId),
departmentId: Number(fields.departmentId),
yearStartStudy: Number(fields.yearStartStudy),
specialtyId: Number(fields.specialtyId),
specialtyProfileId: Number(fields.specialtyProfileId)
}
};
}
export function createGroup(payload, client = apiClient) {
return client.post('/api/groups', payload, { errorMessage: 'Ошибка создания' });
}
export function updateGroup(id, payload, client = apiClient) {
return client.put(`/api/groups/${id}`, payload, { errorMessage: 'Ошибка обновления' });
}
export function archiveGroup(id, client = apiClient) {
return client.delete(`/api/groups/${id}`, { errorMessage: 'Ошибка архивирования' });
}
export function restoreGroup(id, client = apiClient) {
return client.post(`/api/groups/${id}/restore`, {}, { errorMessage: 'Ошибка восстановления' });
}
// --- Подгруппы ---
export function fetchSubgroups(groupId, client = apiClient) {
return client.get(`/api/groups/${groupId}/subgroups`, { errorMessage: 'Не удалось загрузить подгруппы' });
}
export function createSubgroup(groupId, payload, client = apiClient) {
return client.post(`/api/groups/${groupId}/subgroups`, payload, { errorMessage: 'Ошибка сохранения подгруппы' });
}
export function updateSubgroup(groupId, id, payload, client = apiClient) {
return client.put(`/api/groups/${groupId}/subgroups/${id}`, payload, { errorMessage: 'Ошибка сохранения подгруппы' });
}
export function deleteSubgroup(groupId, id, client = apiClient) {
return client.delete(`/api/groups/${groupId}/subgroups/${id}`, { errorMessage: 'Ошибка удаления подгруппы' });
}
export function naturalSubgroupOrder(name) {
const number = String(name || '').match(/\d+/)?.[0];
return number ? String(number).padStart(3, '0') : String(name || '');
}
export function sortSubgroups(subgroups) {
return [...(subgroups || [])].sort((left, right) =>
naturalSubgroupOrder(left.name).localeCompare(naturalSubgroupOrder(right.name), 'ru'));
}
export function suggestedSubgroupCapacities(groupSize, count) {
if (!groupSize || !count) return [];
const base = Math.floor(groupSize / count);
const remainder = groupSize % count;
return Array.from({ length: count }, (_, index) => base + (index < remainder ? 1 : 0));
}
export function validateSubgroupCapacities(capacities, groupSize, count) {
if (capacities.length !== count || capacities.some(value => !Number.isInteger(value) || value <= 0)) {
return 'Укажите положительную численность для каждой подгруппы';
}
const total = capacities.reduce((sum, value) => sum + value, 0);
if (total !== groupSize) {
return `Сумма численностей подгрупп должна быть равна численности группы (${groupSize} чел.)`;
}
return null;
}
// Синхронизация подгрупп воспроизводит legacy-порядок: сначала удаляем лишние,
// затем обновляем существующие (сначала уменьшающие вместимость), затем создаём новые.
export async function syncSubgroups(groupId, existingSubgroups, capacities, client = apiClient) {
const targetCount = capacities.length;
const extraSubgroups = existingSubgroups.slice(targetCount);
for (const subgroup of extraSubgroups) {
await deleteSubgroup(groupId, subgroup.id, client);
}
const updates = [];
const creates = [];
for (let index = 0; index < targetCount; index += 1) {
const payload = {
name: `Подгруппа ${index + 1}`,
studentCapacity: capacities[index]
};
const existing = existingSubgroups[index];
if (existing) {
updates.push({ existing, payload });
} else {
creates.push(payload);
}
}
updates.sort((left, right) => {
const leftIncreases = left.payload.studentCapacity > Number(left.existing.studentCapacity || 0);
const rightIncreases = right.payload.studentCapacity > Number(right.existing.studentCapacity || 0);
return Number(leftIncreases) - Number(rightIncreases);
});
for (const update of updates) {
await updateSubgroup(groupId, update.existing.id, update.payload, client);
}
for (const payload of creates) {
await createSubgroup(groupId, payload, client);
}
}
// --- Календарные графики ---
export function calendarsOptionsEndpoint({ query, academicYearId, specialtyId, profileId, studyFormId, selectedId }) {
const params = new URLSearchParams({
query,
academicYearId,
specialtyId,
profileId,
studyFormId,
limit: '30'
});
if (selectedId) params.set('selectedId', selectedId);
return `/api/admin/academic-calendars/options?${params}`;
}
export function fetchCalendarsOptions(params, client = apiClient) {
return client.get(calendarsOptionsEndpoint(params), { errorMessage: 'Не удалось загрузить календарные графики' });
}
export function fetchCalendarAssignments(groupId, client = apiClient) {
return client.get(`/api/groups/${groupId}/calendar-assignments`, { errorMessage: 'Не удалось загрузить назначения графиков' });
}
export function saveCalendarAssignment(groupId, { academicYearId, calendarId }, client = apiClient) {
return client.put(
`/api/groups/${groupId}/calendar-assignments`,
{ academicYearId: Number(academicYearId), calendarId: Number(calendarId) },
{ errorMessage: 'Ошибка назначения графика' }
);
}
export function deleteCalendarAssignment(groupId, id, client = apiClient) {
return client.delete(`/api/groups/${groupId}/calendar-assignments/${id}`, { errorMessage: 'Ошибка удаления назначения' });
}
// --- Фильтр форм обучения ---
export function efFilterLabel(educationForms, selectedIds) {
const ids = selectedIds || [];
if (!ids.length) return 'Все';
if (ids.length === 1) {
const form = (educationForms || []).find(item => String(item.id) === ids[0]);
return form?.name || FILTER_EF_EMPTY_TEXT;
}
return `Выбрано: ${ids.length}`;
}
// --- Метки ---
export function departmentLabel(departments, departmentId) {
const department = (departments || []).find(item => String(item.id) === String(departmentId));
if (!department) return departmentId || '-';
return department.departmentName || department.name || departmentId;
}
export function departmentOptionLabel(department) {
const name = department.departmentName || department.name || 'Без названия';
const code = department.departmentCode || department.code || department.id;
return `${name} (${code})`;
}
export function specialityLabel(specialties, specialityId) {
const speciality = (specialties || []).find(item => String(item.id) === String(specialityId));
if (!speciality) return specialityId || '-';
const code = speciality.specialityCode || speciality.specialtyCode || speciality.specialty_code || speciality.id;
const name = speciality.specialityName || speciality.name || '';
return name ? `${code} — ${name}` : code;
}
export function specialityOptionLabel(speciality) {
const code = speciality.specialityCode || speciality.specialtyCode || speciality.specialty_code;
const name = speciality.specialityName || speciality.name;
return `${code} · ${name}`;
}
export function subjectLabel(subject) {
const code = subject.subjectCode ? `${subject.subjectCode} · ` : '';
return `${code}${subject.subjectName || subject.subjectId || 'Без названия'}`;
}
export function statusLabel(group) {
if (group.status === 'ARCHIVED') return 'Архив';
return group.studyStateName || (group.active ? 'Активна' : 'Неактивна');
}
export function statusBadgeClass(group) {
if (group.status === 'ARCHIVED' || group.studyState === 'GRADUATED' || group.studyState === 'INACTIVE') {
return 'badge-unavailable';
}
if (group.studyState === 'NOT_STARTED') {
return 'badge-ef';
}
return 'badge-available';
}
export function calendarOptionLabel(calendar) {
return buildAcademicCalendarTitle(calendar) || calendar.title || `График ${calendar.id}`;
}
export function calendarOptionDescription(calendar) {
return `${calendar.academicYearTitle || ''} · ${calendar.studyFormName || ''}`;
}
export function assignmentCalendarTitle(assignment) {
return assignment.calendarTitle || '-';
}
export function assignmentSubjectsGroups(subjects) {
const grouped = new Map();
(subjects || []).forEach(subject => {
const key = String(subject.semesterNumber || '-');
if (!grouped.has(key)) grouped.set(key, []);
grouped.get(key).push(subject);
});
return Array.from(grouped.entries())
.sort(([a], [b]) => Number(a) - Number(b))
.map(([semesterNumber, items]) => ({ semesterNumber, items }));
}
export function groupsEmptyMessage(state) {
return state.query || (state.educationFormIds || []).length
? 'По заданным условиям группы не найдены'
: 'Групп пока нет';
}

View File

@@ -0,0 +1,22 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { initializeTelemetry } from '../../telemetry.js';
import { AppErrorBoundary } from '../shared/ui/AppErrorBoundary.jsx';
import { AdminApp } from './AdminApp.jsx';
void initializeTelemetry();
const rootElement = document.getElementById('admin-root');
if (!rootElement) {
throw new Error('Не найден корневой элемент админ-панели');
}
createRoot(rootElement).render(
<StrictMode>
<AppErrorBoundary>
<AdminApp />
</AppErrorBoundary>
</StrictMode>
);

25
frontend/react/admin/react-tabs.js vendored Normal file
View File

@@ -0,0 +1,25 @@
import { lazy } from 'react';
// Реестр вкладок админ-панели (этап 5). Каждая вкладка загружается
// отдельным lazy-chunk; переходный LegacyTabView удалён
// после завершения волны 6.
//
// Компоненты вкладок используют именованные экспорты, а React.lazy
// требует default-экспорт, поэтому import() явно отображается на default.
export const REACT_TABS = Object.freeze({
dashboard: { Component: lazy(() => import('./tabs/DashboardTab.jsx').then(m => ({ default: m.DashboardTab }))) },
classrooms: { Component: lazy(() => import('./tabs/ClassroomsTab.jsx').then(m => ({ default: m.ClassroomsTab }))) },
subjects: { Component: lazy(() => import('./tabs/SubjectsTab.jsx').then(m => ({ default: m.SubjectsTab }))) },
users: { Component: lazy(() => import('./tabs/UsersTab.jsx').then(m => ({ default: m.UsersTab }))) },
groups: { Component: lazy(() => import('./tabs/GroupsTab.jsx').then(m => ({ default: m.GroupsTab }))) },
'university-structure': { Component: lazy(() => import('./tabs/UniversityStructureTab.jsx').then(m => ({ default: m.UniversityStructureTab }))) },
'teacher-requests': { Component: lazy(() => import('./tabs/TeacherRequestsTab.jsx').then(m => ({ default: m.TeacherRequestsTab }))) },
'department-workspace': { Component: lazy(() => import('./tabs/DepartmentWorkspaceTab.jsx').then(m => ({ default: m.DepartmentWorkspaceTab }))) },
'auditorium-workload': { Component: lazy(() => import('./tabs/AuditoriumWorkloadTab.jsx').then(m => ({ default: m.AuditoriumWorkloadTab }))) },
'academic-calendar': { Component: lazy(() => import('./tabs/AcademicCalendarTab.jsx').then(m => ({ default: m.AcademicCalendarTab }))) },
'schedule-view': { Component: lazy(() => import('./tabs/ScheduleViewTab.jsx').then(m => ({ default: m.ScheduleViewTab }))) },
schedule: { Component: lazy(() => import('./tabs/ScheduleTab.jsx').then(m => ({ default: m.ScheduleTab }))) },
'schedule-versions': { Component: lazy(() => import('./tabs/ScheduleVersionsTab.jsx').then(m => ({ default: m.ScheduleVersionsTab }))) },
'schedule-quality': { Component: lazy(() => import('./tabs/ScheduleQualityTab.jsx').then(m => ({ default: m.ScheduleQualityTab }))) },
'teacher-absences': { Component: lazy(() => import('./tabs/TeacherAbsencesTab.jsx').then(m => ({ default: m.TeacherAbsencesTab }))) }
});

View File

@@ -0,0 +1,333 @@
import { apiClient } from '../shared/api-client.js';
import {
addDays,
formatDate,
formatDateShort,
parseIsoDate,
toIsoDate,
trimTime
} from './schedule-view-service.js';
// Волна 6 (этап 5): чистая логика и контракты API разовых правок расписания,
// перенесённые из legacy admin/js/views/schedule-override-panel.js без изменения
// поведения. Императивный модуль панели заменён React-состоянием во вкладке
// просмотра расписаний.
export const OVERRIDE_EDIT_ROLES = new Set(['ADMIN', 'EDUCATION_OFFICE']);
export const OVERRIDE_ACTION_LABELS = {
MOVE: 'Перенос',
REPLACE: 'Замена',
CANCEL: 'Отмена'
};
export const OVERRIDE_DAY_LABELS = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
export const OVERRIDE_MONTH_LABELS = [
'Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь',
'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'
];
// --- Права и решения ---
export function canEditScheduleOverrides(role) {
return OVERRIDE_EDIT_ROLES.has(role);
}
export function overrideActionForChanges(changes) {
if (changes.dateChanged || changes.timeChanged) return 'MOVE';
if (changes.teacherChanged || changes.classroomChanged || changes.formatChanged) return 'REPLACE';
return null;
}
// --- Даты и время ---
export function weekDates(value) {
const selected = parseIsoDate(value);
const day = selected.getDay() || 7;
const monday = addDays(selected, 1 - day);
return Array.from({ length: 7 }, (_, index) => toIsoDate(addDays(monday, index)));
}
export function pickMatchingTimeSlot(slots, preferredId, startTime, endTime) {
const safeSlots = Array.isArray(slots) ? slots : [];
const byId = preferredId == null
? null
: safeSlots.find(slot => String(slot.id) === String(preferredId));
if (byId) return byId;
const normalizedStart = trimTime(startTime);
const normalizedEnd = trimTime(endTime);
if (!normalizedStart || !normalizedEnd) return null;
return safeSlots.find(slot => trimTime(slot.startTime) === normalizedStart
&& trimTime(slot.endTime) === normalizedEnd) || null;
}
export function overrideIntervalLabel(start, end) {
return `${trimTime(start)}–${trimTime(end)}`;
}
export function overrideTimeSlotLabel(slot) {
const order = slot.orderNumber ? `${slot.orderNumber} пара · ` : '';
return `${order}${overrideIntervalLabel(slot.startTime, slot.endTime)}`;
}
// --- Источник правки ---
export function overrideSourceFrom(lesson, existing) {
return {
baseRuleSlotId: lesson.scheduleRuleSlotId,
lessonDate: lesson.originalLessonDate || existing?.lessonDate || lesson.date,
subjectName: existing?.subjectName || lesson.subjectName || 'Без дисциплины',
lessonTypeName: existing?.lessonTypeName || lesson.lessonTypeName || 'Тип не указан',
groupNames: existing?.groupNames?.length ? existing.groupNames : (lesson.groupNames || []),
timeSlotId: existing?.sourceTimeSlotId || lesson.timeSlotId,
timeSlotOrder: existing?.sourceTimeSlotOrder || lesson.timeSlotOrder,
startTime: existing?.sourceStartTime || lesson.startTime,
endTime: existing?.sourceEndTime || lesson.endTime,
teacherId: existing?.sourceTeacherId || lesson.teacherId,
teacherName: existing?.sourceTeacherName || lesson.teacherName,
classroomId: existing?.sourceClassroomId || lesson.classroomId,
classroomName: existing?.sourceClassroomName || lesson.classroomName,
lessonFormat: existing?.sourceLessonFormat || lesson.lessonFormat || 'Очно'
};
}
export function initialTimePreference(existing, source) {
return {
id: existing?.newTimeSlotId || source.timeSlotId,
startTime: existing?.newTimeSlotId ? null : source.startTime,
endTime: existing?.newTimeSlotId ? null : source.endTime
};
}
// Синтетическое занятие для открытия существующей правки из реестра.
export function lessonFromOverride(override, dictionaries) {
return {
scheduleRuleSlotId: override.baseRuleSlotId,
date: override.targetLessonDate || override.lessonDate,
originalLessonDate: override.lessonDate,
scheduleOverrideId: override.id,
subjectName: override.subjectName,
lessonTypeName: override.lessonTypeName,
groupNames: override.groupNames,
timeSlotId: override.newTimeSlotId || override.sourceTimeSlotId,
timeSlotOrder: override.sourceTimeSlotOrder,
startTime: override.sourceStartTime,
endTime: override.sourceEndTime,
teacherId: override.newTeacherId || override.sourceTeacherId,
teacherName: overrideTeacherName(dictionaries, override.newTeacherId) || override.sourceTeacherName,
classroomId: override.newClassroomId || override.sourceClassroomId,
classroomName: overrideClassroomName(dictionaries, override.newClassroomId) || override.sourceClassroomName,
lessonFormat: override.newLessonFormat || override.sourceLessonFormat
};
}
export function overrideTeacherName(dictionaries, id) {
if (id == null || id === '') return '';
const teacher = (dictionaries.teachers || []).find(item => String(item.id) === String(id));
return teacher?.fullName || teacher?.username || '';
}
export function overrideClassroomName(dictionaries, id) {
if (id == null || id === '') return '';
const classroom = (dictionaries.classrooms || []).find(item => String(item.id) === String(id));
return classroom ? classroomOptionLabel(classroom) : '';
}
export function classroomOptionLabel(room) {
return room?.name || '';
}
// --- Сравнение «было / станет» ---
export function overrideBeforeMeta(source) {
return [
formatDate(source.lessonDate),
overrideIntervalLabel(source.startTime, source.endTime),
source.teacherName,
source.classroomName,
source.lessonFormat,
source.groupNames.join(', ')
].filter(Boolean).join(' · ');
}
export function overrideAfterMeta({ source, targetDate, selectedTime, teacherName, classroomName, lessonFormat }) {
return [
formatDate(targetDate),
selectedTime ? overrideIntervalLabel(selectedTime.startTime, selectedTime.endTime) : 'Время не выбрано',
teacherName,
classroomName,
lessonFormat,
source.groupNames.join(', ')
].filter(Boolean).join(' · ');
}
// --- Календарь семестра ---
export function semesterCalendarMonths(startDate, endDate, available, selectedDate) {
if (!startDate || !endDate) return [];
const start = parseIsoDate(startDate);
const end = parseIsoDate(endDate);
const months = [];
for (let cursor = new Date(start.getFullYear(), start.getMonth(), 1);
cursor <= end;
cursor = new Date(cursor.getFullYear(), cursor.getMonth() + 1, 1)) {
months.push(overrideMonth(cursor, startDate, endDate, available, selectedDate));
}
return months;
}
function overrideMonth(month, semesterStart, semesterEnd, available, selectedDate) {
const year = month.getFullYear();
const monthIndex = month.getMonth();
const firstDay = new Date(year, monthIndex, 1).getDay() || 7;
const dayCount = new Date(year, monthIndex + 1, 0).getDate();
const blanks = Array.from({ length: firstDay - 1 }, (_, index) => ({ blank: true, key: `blank-${index}` }));
const days = Array.from({ length: dayCount }, (_, index) => {
const date = toIsoDate(new Date(year, monthIndex, index + 1));
const enabled = date >= semesterStart && date <= semesterEnd && available.has(date);
return {
key: date,
date,
day: index + 1,
enabled,
active: date === selectedDate
};
});
return {
title: `${OVERRIDE_MONTH_LABELS[monthIndex]} ${year}`,
blanks,
days
};
}
// --- Payload правки ---
export function buildOverridePayload({
source,
selectedOverride,
targetDate,
timeSlotId,
teacherId,
classroomId,
lessonFormat,
comment
}) {
if (!timeSlotId) {
return { error: 'Выберите время занятия для выбранной даты' };
}
const dateChanged = targetDate !== source.lessonDate;
const timeChanged = String(timeSlotId) !== String(source.timeSlotId);
const teacherChanged = String(teacherId) !== String(source.teacherId);
const classroomChanged = String(classroomId) !== String(source.classroomId);
const formatChanged = lessonFormat !== source.lessonFormat;
const action = overrideActionForChanges({
dateChanged,
timeChanged,
teacherChanged,
classroomChanged,
formatChanged
});
if (!action) {
return {
error: selectedOverride
? 'Параметры совпадают с правилом. Используйте «Вернуть по правилу»'
: 'Измените дату, время, преподавателя, аудиторию или формат'
};
}
return {
payload: {
baseRuleSlotId: source.baseRuleSlotId,
lessonDate: source.lessonDate,
targetLessonDate: dateChanged ? targetDate : null,
action,
newTimeSlotId: (dateChanged || timeChanged) ? Number(timeSlotId) : null,
newTeacherId: teacherChanged ? Number(teacherId) : null,
newClassroomId: classroomChanged ? Number(classroomId) : null,
newLessonFormat: formatChanged ? lessonFormat : null,
comment: comment.trim() || null
}
};
}
export function buildCancelOverridePayload(source, comment) {
return {
baseRuleSlotId: source.baseRuleSlotId,
lessonDate: source.lessonDate,
targetLessonDate: null,
action: 'CANCEL',
newTimeSlotId: null,
newTeacherId: null,
newClassroomId: null,
newLessonFormat: null,
comment: comment.trim() || null
};
}
// --- Реестр правок ---
export function overrideRegistryItem(override, dictionaries) {
const target = override.targetLessonDate
? ` → ${formatDate(override.targetLessonDate)}`
: '';
const result = override.action === 'CANCEL'
? 'Занятие отменено'
: [
override.newTimeSlotId ? `слот ${override.newTimeSlotId}` : null,
overrideTeacherName(dictionaries, override.newTeacherId),
overrideClassroomName(dictionaries, override.newClassroomId),
override.newLessonFormat
].filter(Boolean).join(' · ');
return {
id: override.id,
actionLabel: OVERRIDE_ACTION_LABELS[override.action] || override.action,
actionClass: `action-${String(override.action).toLowerCase()}`,
timeText: `${formatDate(override.lessonDate)}${target}`,
subject: override.subjectName || 'Занятие',
description: [
override.lessonTypeName,
(override.groupNames || []).join(', '),
result || 'Параметры изменены'
].filter(Boolean).join(' · '),
comment: override.comment || ''
};
}
// --- Контракты API ---
export function overrideAvailabilityEndpoint(baseRuleSlotId, lessonDate) {
return `/api/edu-office/schedule/overrides/availability?baseRuleSlotId=${encodeURIComponent(baseRuleSlotId)}`
+ `&lessonDate=${encodeURIComponent(lessonDate)}`;
}
export async function fetchOverrideAvailability(baseRuleSlotId, lessonDate, client = apiClient) {
return client.get(overrideAvailabilityEndpoint(baseRuleSlotId, lessonDate), {
errorMessage: 'Не удалось загрузить доступные даты занятия'
});
}
export function effectiveTimeSlotsEndpoint(date) {
return `/api/admin/time-slots/effective?date=${encodeURIComponent(date)}`;
}
export async function fetchEffectiveTimeSlots(date, client = apiClient) {
return client.get(effectiveTimeSlotsEndpoint(date), {
errorMessage: 'Не удалось загрузить время занятий'
});
}
export async function saveScheduleOverride(payload, overrideId, client = apiClient) {
return overrideId
? client.put(`/api/edu-office/schedule/overrides/${encodeURIComponent(overrideId)}`, payload, {
errorMessage: 'Не удалось сохранить разовое изменение'
})
: client.post('/api/edu-office/schedule/overrides', payload, {
errorMessage: 'Не удалось сохранить разовое изменение'
});
}
export async function deleteScheduleOverride(overrideId, client = apiClient) {
return client.delete(`/api/edu-office/schedule/overrides/${encodeURIComponent(overrideId)}`, {
errorMessage: 'Не удалось удалить разовое изменение'
});
}

View File

@@ -0,0 +1,95 @@
import { apiClient } from '../shared/api-client.js';
import { formatLocalDate } from '../../admin/js/utils.js';
import { fetchScheduleSemesters, fetchScheduleVersions } from './schedule-versions-service.js';
// Срез «Анализ качества расписания» (этап 5, волна 5): чистая логика и контракты
// API, перенесённые из legacy admin/js/views/schedule-quality.js без изменения поведения.
// --- Диапазоны оценки ---
export function qualityBand(score) {
if (score >= 90) {
return { code: 'excellent', label: 'Сильное расписание', summary: 'Найдены только локальные неудобства с небольшим влиянием.' };
}
if (score >= 75) {
return { code: 'stable', label: 'Устойчивое расписание', summary: 'Расписание работоспособно, но несколько перестановок заметно улучшат комфорт.' };
}
if (score >= 55) {
return { code: 'attention', label: 'Нужна доработка', summary: 'Мягкие ограничения и распределение нагрузки требуют внимания учебного отдела.' };
}
return { code: 'critical', label: 'Высокая цена неудобств', summary: 'Сначала разберите критические проблемы и перегруженные дни.' };
}
// --- Фильтрация проблем ---
export function filterQualityProblems(problems, severity = '', category = '') {
return (problems || []).filter(problem => {
const severityMatches = !severity || problem.severity === severity;
const problemTypes = problem.problemTypes?.length ? problem.problemTypes : [problem.type];
const categoryMatches = !category || problemTypes.some(type => problemCategory(type) === category);
return severityMatches && categoryMatches;
}).sort((a, b) => (Number(b.penalty) || 0) - (Number(a.penalty) || 0));
}
function problemCategory(type = '') {
if (type.includes('GAP') || type.includes('COMPACTNESS')) return 'GAPS';
if (type.includes('LOAD')) return 'LOAD';
if (type.includes('ROOM')) return 'ROOM';
return 'PREFERENCE';
}
// --- Подписи ---
export function versionStatusLabel(status) {
return { DRAFT: 'Черновик', PUBLISHED: 'Опубликована', ARCHIVED: 'Архив' }[status] || 'Версия';
}
export function severityLabel(severity) {
return { CRITICAL: 'Критично', HIGH: 'Высокая', MEDIUM: 'Средняя', LOW: 'Низкая' }[severity] || severity;
}
export function formatMetricValue(metric) {
const value = Number(metric?.value || 0);
const formatted = Number.isInteger(value)
? String(value)
: value.toLocaleString('ru-RU', { maximumFractionDigits: 1 });
return `${formatted}${metric?.unit === '%' ? '%' : metric?.unit ? ` ${metric.unit}` : ''}`;
}
export function qualitySemesterLabel(semester) {
const type = semester.semesterType === 'autumn' ? 'Осенний' : 'Весенний';
return `${semester.academicYearTitle} · ${type} · ${formatQualityDate(semester.startDate)}—${formatQualityDate(semester.endDate)}`;
}
export function formatQualityDate(value) {
if (!value) return 'дата не указана';
const [year, month, day] = String(value).split('-');
return `${day}.${month}.${year}`;
}
export function lessonWord(count) {
const mod100 = count % 100;
const mod10 = count % 10;
if (mod100 >= 11 && mod100 <= 14) return 'занятий';
if (mod10 === 1) return 'занятие';
if (mod10 >= 2 && mod10 <= 4) return 'занятия';
return 'занятий';
}
export function problemTotalLabel(analysis) {
return analysis.problemsTruncated
? `Показаны первые ${analysis.problems.length} из ${analysis.totalProblemCount} правил`
: `Правил с проблемами: ${analysis.totalProblemCount}`;
}
// --- Контракты API ---
export function scheduleQualityEndpoint(semesterId, versionId) {
return `/api/edu-office/schedule/quality?semesterId=${encodeURIComponent(semesterId)}&versionId=${encodeURIComponent(versionId)}`;
}
export async function fetchScheduleQualityAnalysis(semesterId, versionId, client = apiClient) {
return client.get(scheduleQualityEndpoint(semesterId, versionId), { errorMessage: 'Не удалось выполнить анализ качества.' });
}
export { fetchScheduleSemesters, fetchScheduleVersions, formatLocalDate };

View File

@@ -0,0 +1,859 @@
import { apiClient } from '../shared/api-client.js';
import { formatLocalDate } from '../../admin/js/utils.js';
import { parseIsoDate, toIsoDate, trimTime } from './schedule-view-service.js';
// Волна 6 (этап 5): чистая логика и контракты API конструктора правил
// расписания, перенесённые из legacy admin/js/views/schedule.js без изменения
// поведения.
export const DAY_OPTIONS = Object.freeze([
Object.freeze({ value: 1, label: 'Понедельник' }),
Object.freeze({ value: 2, label: 'Вторник' }),
Object.freeze({ value: 3, label: 'Среда' }),
Object.freeze({ value: 4, label: 'Четверг' }),
Object.freeze({ value: 5, label: 'Пятница' }),
Object.freeze({ value: 6, label: 'Суббота' }),
Object.freeze({ value: 7, label: 'Воскресенье' })
]);
export const PARITY_LABELS = Object.freeze({
BOTH: 'каждую неделю',
ODD: 'нечётная',
EVEN: 'чётная'
});
export const CELL_PARITY_LAYOUT = Object.freeze([
Object.freeze({ parity: 'ODD', label: 'Нечётная' }),
Object.freeze({ parity: 'EVEN', label: 'Чётная' })
]);
export const SEMESTER_LABELS = Object.freeze({
autumn: 'осенний',
spring: 'весенний'
});
export const LESSON_FORMAT_OPTIONS = Object.freeze([
Object.freeze({ value: 'Очно', label: 'Очно' }),
Object.freeze({ value: 'Онлайн', label: 'Онлайн' })
]);
export const WHOLE_GROUP_SUBGROUP_VALUE = '__whole_group__';
export const ACADEMIC_HOURS_PER_SLOT = 2;
const VISUAL_MENU_GAP = 8;
const VISUAL_MENU_VIEWPORT_PADDING = 8;
export const LESSON_TYPE_LIMITS = Object.freeze([
Object.freeze({
key: 'lecture',
label: 'Лекции',
hoursField: 'lectureAcademicHours',
startWeekField: 'lectureStartWeek'
}),
Object.freeze({
key: 'laboratory',
label: 'Лабораторные',
hoursField: 'laboratoryAcademicHours',
startWeekField: 'laboratoryStartWeek'
}),
Object.freeze({
key: 'practice',
label: 'Практики',
hoursField: 'practiceAcademicHours',
startWeekField: 'practiceStartWeek'
})
]);
// --- Позиционирование контекстного меню визуального расписания ---
export function calculateVisualContextMenuPosition({
anchorRect,
menuWidth,
menuHeight,
viewportWidth,
viewportHeight
}) {
const maxLeft = Math.max(VISUAL_MENU_VIEWPORT_PADDING, viewportWidth - menuWidth - VISUAL_MENU_VIEWPORT_PADDING);
const maxTop = Math.max(VISUAL_MENU_VIEWPORT_PADDING, viewportHeight - menuHeight - VISUAL_MENU_VIEWPORT_PADDING);
let left = anchorRect.right + VISUAL_MENU_GAP;
let top = anchorRect.top;
if (left > maxLeft) {
left = anchorRect.left - menuWidth - VISUAL_MENU_GAP;
}
if (top > maxTop) {
top = anchorRect.bottom - menuHeight;
}
return {
left: Math.round(Math.max(VISUAL_MENU_VIEWPORT_PADDING, Math.min(left, maxLeft))),
top: Math.round(Math.max(VISUAL_MENU_VIEWPORT_PADDING, Math.min(top, maxTop)))
};
}
// --- Слоты формы ---
export function scheduleSlotActionLabel(slotCount) {
return slotCount === 1 ? 'Очистить' : 'Удалить';
}
export function shouldMarkRuleFormDirty({ programmatic = false, type = '', fromAsyncCombobox = false } = {}) {
return !programmatic && !(type === 'input' && fromAsyncCombobox);
}
// --- Версии расписания ---
export function chooseScheduleVersion(versions, semesterId = '', requestedVersionId = '') {
const available = (versions || []).filter(version => ['PUBLISHED', 'DRAFT'].includes(version.status));
return available.find(version => String(version.id) === String(requestedVersionId))
|| available.find(version => version.status === 'PUBLISHED'
&& String(version.semesterId) === String(semesterId))
|| available.find(version => version.status === 'PUBLISHED')
|| available.find(version => String(version.semesterId) === String(semesterId))
|| available[0]
|| null;
}
export function scheduleVersionOptionGroups(versions) {
const published = versions.filter(version => version.status === 'PUBLISHED');
const drafts = versions.filter(version => version.status === 'DRAFT');
return [
{ label: 'Опубликованные расписания', items: published },
{ label: 'Черновики', items: drafts }
].filter(group => group.items.length);
}
export function versionContextTexts(version) {
const isDraft = version?.status === 'DRAFT';
if (!version) {
return {
status: 'PUBLISHED',
eyebrow: 'Основной режим',
title: 'Опубликованное расписание',
note: 'Расписание для редактирования не найдено.'
};
}
return {
status: isDraft ? 'DRAFT' : 'PUBLISHED',
eyebrow: isDraft ? 'Безопасная рабочая копия' : 'Основной режим',
title: isDraft ? 'Редактирование черновика' : 'Опубликованное расписание',
note: isDraft
? `Версия ${version.versionNumber} · ${version.name}. Изменения не видны пользователям до публикации.`
: `Версия ${version.versionNumber} · ${version.name}. Сохранённые изменения сразу видны пользователям.`
};
}
// --- Подписи семестров и правил ---
function normalizeSemesterType(type) {
return String(type || '').toLowerCase();
}
export function semesterLabel(semester) {
const type = normalizeSemesterType(semester.semesterType);
return `${semester.academicYearTitle || '-'}, ${SEMESTER_LABELS[type] || type} (${semester.startDate} - ${semester.endDate})`;
}
export function shortSemesterLabel(semester) {
const type = normalizeSemesterType(semester.semesterType);
return `${semester.academicYearTitle || '-'}, ${SEMESTER_LABELS[type] || type}`;
}
export function formatSemester(rule) {
const type = normalizeSemesterType(rule.semesterType);
return `${rule.academicYearTitle || '-'}, ${SEMESTER_LABELS[type] || type || '-'}`;
}
export function semesterTextById(semesterId, semesters) {
const semester = (semesters || []).find(item => String(item.id) === String(semesterId));
return semester ? shortSemesterLabel(semester) : 'семестр не выбран';
}
export function typeLoadText(rule) {
return LESSON_TYPE_LIMITS
.map(config => {
const hours = Number(rule?.[config.hoursField] || 0);
const startWeek = Number(rule?.[config.startWeekField] || 1);
return hours > 0 ? `${config.label}: ${hours} ч, с ${startWeek} недели` : '';
})
.filter(Boolean)
.join('; ');
}
export function groupNameById(id, groups) {
return (groups || []).find(group => String(group.id) === String(id))?.name || `Группа ${id}`;
}
export function ruleDraftTitle(payload, subjects) {
const subject = (subjects || []).find(item => String(item.id) === String(payload.subjectId));
return subject?.name || `Правило по дисциплине ${payload.subjectId}`;
}
export function ruleTitle(rule, subjects) {
return rule?.subjectName || ruleDraftTitle(rule || {}, subjects);
}
export function ruleDraftMeta(payload, { groups, semesters }) {
const groupNames = (payload.groupIds || []).map(id => groupNameById(id, groups)).join(', ') || 'группы не выбраны';
return [semesterTextById(payload.semesterId, semesters), groupNames, typeLoadText(payload)]
.filter(Boolean)
.join(' · ');
}
export function ruleMeta(rule, { groups, semesters }) {
const groupNames = (rule.groupNames || []).join(', ')
|| (rule.groupIds || []).map(id => groupNameById(id, groups)).join(', ')
|| 'группы не выбраны';
return [formatSemester(rule), groupNames, typeLoadText(rule)]
.filter(Boolean)
.join(' · ');
}
// --- Типы занятий ---
export function lessonTypeKey(lessonType) {
const name = String(lessonType.name || lessonType.lessonType || '').toLowerCase();
if (name.includes('лекц')) return 'lecture';
if (name.includes('лаб')) return 'laboratory';
if (name.includes('практ')) return 'practice';
return null;
}
export function lessonTypeKeyById(lessonTypeId, lessonTypes) {
const lessonType = (lessonTypes || []).find(item => String(item.id) === String(lessonTypeId));
return lessonType ? lessonTypeKey(lessonType) : null;
}
function lessonTypeRank(lessonType) {
const key = lessonTypeKey(lessonType);
if (key === 'lecture') return 0;
if (key === 'laboratory') return 1;
if (key === 'practice') return 2;
return 3;
}
export function toLessonTypeOption(lessonType) {
return { value: lessonType.id, label: lessonType.name || lessonType.lessonType || `ID ${lessonType.id}` };
}
export function orderedLessonTypes(lessonTypes) {
return [...(lessonTypes || [])].sort((first, second) => (
lessonTypeRank(first) - lessonTypeRank(second)
|| naturalCompare(toLessonTypeOption(first).label, toLessonTypeOption(second).label)
));
}
export function isLaboratorySlot(slot, lessonTypes) {
return lessonTypeKeyById(slot.lessonTypeId, lessonTypes) === 'laboratory';
}
export function toTimeSlotOption(slot) {
return { value: slot.id, label: `${slot.orderNumber} пара, ${trimTime(slot.startTime)} - ${trimTime(slot.endTime)}` };
}
// --- Подгруппы ---
export function slotSubgroupIds(slot) {
if (Array.isArray(slot.subgroupIds) && slot.subgroupIds.length) {
return slot.subgroupIds.map(Number).filter(Boolean);
}
return slot.subgroupId ? [Number(slot.subgroupId)] : [];
}
export function slotSubgroupNames(slot) {
if (Array.isArray(slot.subgroupNames) && slot.subgroupNames.length) {
return slot.subgroupNames.filter(Boolean);
}
return slot.subgroupName ? [slot.subgroupName] : [];
}
export function subgroupLabel(subgroup) {
return `${subgroup.groupName || `Группа ${subgroup.groupId}`}: ${subgroup.name}`;
}
export function matchingSubgroupsForGroups(subgroups, selectedGroupIds) {
const selectedGroups = new Set((selectedGroupIds || []).map(String));
return (subgroups || [])
.filter(subgroup => selectedGroups.has(String(subgroup.groupId)))
.sort((first, second) => subgroupLabel(first).localeCompare(subgroupLabel(second), 'ru-RU'));
}
export function slotSubgroupText(slot, subgroups) {
const selectedIds = slotSubgroupIds(slot);
if (!selectedIds.length) return 'Все выбранные группы';
if (selectedIds.length === 1) {
const subgroup = (subgroups || []).find(item => String(item.id) === String(selectedIds[0]));
return subgroup ? subgroupLabel(subgroup) : 'Выбрана 1 подгруппа';
}
return `Выбрано подгрупп: ${selectedIds.length}`;
}
// --- Payload правил ---
export function ruleSlotToPayload(slot) {
return {
id: slot.id || null,
dayOfWeek: Number(slot.dayOfWeek),
parity: slot.parity || 'BOTH',
timeSlotId: Number(slot.timeSlotId),
subgroupId: slot.subgroupId || null,
subgroupIds: slotSubgroupIds(slot),
teacherId: Number(slot.teacherId),
classroomId: Number(slot.classroomId),
lessonTypeId: Number(slot.lessonTypeId),
lessonFormat: slot.lessonFormat || 'Очно'
};
}
export function buildRulePayloadFromRule(rule, slots, fallbackVersionId = '') {
const groupIds = (rule.groupIds || []).map(Number).filter(Boolean);
if (!rule.id) throw new Error('Конфликтующее правило не найдено');
if (!rule.subjectId) throw new Error('У конфликтующего правила не указана дисциплина');
if (!rule.semesterId) throw new Error('У конфликтующего правила не указан семестр');
if (!groupIds.length) throw new Error('У конфликтующего правила нет групп');
if (!slots.length) throw new Error('У конфликтующего правила нет слотов');
return {
id: Number(rule.id),
subjectId: Number(rule.subjectId),
semesterId: Number(rule.semesterId),
scheduleVersionId: Number(rule.scheduleVersionId || fallbackVersionId),
lectureAcademicHours: Number(rule.lectureAcademicHours || 0),
laboratoryAcademicHours: Number(rule.laboratoryAcademicHours || 0),
practiceAcademicHours: Number(rule.practiceAcademicHours || 0),
lectureStartWeek: Number(rule.lectureStartWeek || 1),
laboratoryStartWeek: Number(rule.laboratoryStartWeek || 1),
practiceStartWeek: Number(rule.practiceStartWeek || 1),
groupIds,
slots
};
}
export function validateTypeLimits(typeLimits, slots, lessonTypes) {
const selectedTypeKeys = new Set(
(slots || []).map(slot => lessonTypeKeyById(slot.lessonTypeId, lessonTypes)).filter(Boolean)
);
let hasHours = false;
for (const config of LESSON_TYPE_LIMITS) {
const hours = typeLimits[config.hoursField];
const startWeek = typeLimits[config.startWeekField];
if (hours < 0) throw new Error('Количество часов по типам занятий не может быть отрицательным');
if (!startWeek || startWeek <= 0) throw new Error('Неделя начала занятий должна быть больше нуля');
if (hours > 0) hasHours = true;
if (hours > 0 && !selectedTypeKeys.has(config.key)) {
throw new Error(`Для типа "${config.label}" добавьте хотя бы один слот`);
}
if (hours === 0 && selectedTypeKeys.has(config.key)) {
throw new Error(`Для типа "${config.label}" укажите часы или удалите слоты этого типа`);
}
}
if (!hasHours) throw new Error('Укажите часы хотя бы для одного типа занятий');
}
export function validateSlotSubgroup(slot, index, { lessonTypes, subgroups, selectedGroupIds }) {
const typeKey = lessonTypeKeyById(slot.lessonTypeId, lessonTypes);
const subgroupIds = slotSubgroupIds(slot);
if (subgroupIds.length && typeKey !== 'laboratory') {
throw new Error(`В слоте ${index + 1} подгруппа доступна только для лабораторных`);
}
if (!subgroupIds.length) return;
const selectedGroups = new Set((selectedGroupIds || []).map(String));
const subgroupGroupIds = new Set();
for (const subgroupId of subgroupIds) {
const selectedSubgroup = (subgroups || []).find(item => String(item.id) === String(subgroupId));
if (!selectedSubgroup) {
throw new Error(`В слоте ${index + 1} подгруппа не найдена`);
}
if (!selectedGroups.has(String(selectedSubgroup.groupId))) {
throw new Error(`В слоте ${index + 1} подгруппа должна относиться к выбранным группам правила`);
}
if (subgroupGroupIds.has(String(selectedSubgroup.groupId))) {
throw new Error(`В слоте ${index + 1} выберите не больше одной подгруппы каждой группы`);
}
subgroupGroupIds.add(String(selectedSubgroup.groupId));
}
}
// Слоты формы → payload с валидацией обязательных полей (как readRuleSlots(true)).
export function formSlotsToPayloads(slots, { lessonTypes, subgroups, selectedGroupIds }) {
return (slots || []).map((slot, index) => {
if (!slot.dayOfWeek || !slot.parity || !slot.timeSlotId
|| !slot.teacherId || !slot.classroomId || !slot.lessonTypeId || !slot.lessonFormat) {
throw new Error(`Заполните слот ${index + 1}`);
}
const payload = ruleSlotToPayload(slot);
validateSlotSubgroup(payload, index, { lessonTypes, subgroups, selectedGroupIds });
return payload;
});
}
export function buildRulePayloadFromForm({
ruleId,
subjectId,
semesterId,
versionId,
typeLimits,
groupIds,
slots,
lessonTypes,
subgroups
}) {
if (!subjectId) throw new Error('Выберите дисциплину');
if (!versionId) throw new Error('Выберите версию расписания');
if (!semesterId) throw new Error('Выберите семестр');
validateTypeLimits(typeLimits, slots, lessonTypes);
if (!groupIds.length) throw new Error('Выберите хотя бы одну группу');
if (!slots.length) throw new Error('Добавьте хотя бы один слот занятия');
return {
id: ruleId ? Number(ruleId) : null,
subjectId: Number(subjectId),
semesterId: Number(semesterId),
scheduleVersionId: Number(versionId),
...typeLimits,
groupIds,
slots
};
}
// --- Конфликты правил ---
export function isScheduleRuleConflictError(error) {
return error?.status === 409 && Boolean(error?.data?.conflictRule);
}
export function conflictFieldClass(field, fields) {
return (fields || []).includes(field) ? 'schedule-conflict-field-hit' : '';
}
export function conflictFieldLabel(field) {
if (field === 'teacher') return 'Преподаватель';
if (field === 'classroom') return 'Аудитория';
if (field === 'group') return 'Группа';
return field;
}
export function conflictSlotCurrentText(slot) {
const parity = PARITY_LABELS[slot.parity] || slot.parity || '-';
return [
slot.dayName,
slot.timeSlotLabel,
parity,
slot.teacherName,
slot.classroomName
].filter(Boolean).join(' · ');
}
export function conflictReasonLabels(fields, reasons) {
const labels = Array.isArray(reasons) && reasons.length
? reasons
: (fields || []).map(conflictFieldLabel).filter(Boolean);
return labels;
}
// --- Подсказки пожеланий преподавателя ---
export function preferenceHintLabel(type) {
return {
HARD_UNAVAILABLE: 'Строгая недоступность',
SOFT_PREFERRED: 'Предпочтительно',
SOFT_UNWANTED: 'Нежелательно',
CONSECUTIVE: 'Пары подряд',
NO_GAPS: 'Без окон'
}[type] || type;
}
export function preferenceHintPills({ semesterId, teacherId, dayOfWeek, timeSlotId, preferences }) {
if (!semesterId || !teacherId) return null;
const teacherItems = (preferences || []).filter(item =>
Number(item.semesterId) === Number(semesterId) && Number(item.teacherId) === Number(teacherId)
);
const exact = teacherItems.filter(item =>
Number(item.dayOfWeek) === Number(dayOfWeek) && Number(item.timeSlotId) === Number(timeSlotId)
);
const dateCount = teacherItems.filter(item => item.preferenceType === 'HARD_UNAVAILABLE' && item.preferenceDate).length;
const flags = teacherItems.filter(item => ['CONSECUTIVE', 'NO_GAPS'].includes(item.preferenceType));
return [
...exact.map(item => ({
text: preferenceHintLabel(item.preferenceType),
kind: item.preferenceType === 'HARD_UNAVAILABLE' ? 'hard'
: item.preferenceType === 'SOFT_PREFERRED' ? 'preferred' : 'unwanted'
})),
...(dateCount ? [{ text: `Недоступных дат: ${dateCount}`, kind: 'dates' }] : []),
...flags.map(item => ({ text: preferenceHintLabel(item.preferenceType), kind: 'compact' }))
];
}
// --- Визуальное расписание ---
export function visualCellKey(groupId, dayOfWeek, slotKey) {
return `${groupId}:${dayOfWeek}:${slotKey}`;
}
export function visualSlotKey(slot) {
return String(slot.timeSlotId ?? slot.timeSlotOrder ?? slot.timeSlotLabel ?? 'unknown');
}
export function visualRuleSlotKey(slot, index) {
return String(slot.id ?? `${visualSlotKey(slot)}:${slot.dayOfWeek}:${slot.parity}:${index}`);
}
export function activeWeekKey(ruleId, groupId, slotKey) {
return `${ruleId}:${groupId}:${slotKey}`;
}
export function slotAppliesToWeek(slot, week) {
if (!slot.parity || slot.parity === 'BOTH') return true;
const weekParity = week % 2 === 0 ? 'EVEN' : 'ODD';
return slot.parity === weekParity;
}
export function maxWeeksForRule(rule, semesters) {
const semester = (semesters || []).find(item => String(item.id) === String(rule.semesterId));
if (semester?.startDate && semester?.endDate) {
const start = parseIsoDate(semester.startDate);
const end = parseIsoDate(semester.endDate);
const days = Math.max(1, Math.round((end - start) / 86400000) + 1);
return Math.max(1, Math.ceil(days / 7));
}
return 18;
}
export function compactWeekRanges(weeks, maxWeeks, parity) {
const uniqueWeeks = [...new Set((weeks || []).map(Number).filter(Boolean))].sort((a, b) => a - b);
if (!uniqueWeeks.length) return '';
const start = uniqueWeeks[0];
const end = uniqueWeeks[uniqueWeeks.length - 1];
const semesterEndWeek = lastApplicableSemesterWeek(maxWeeks, parity);
const semesterStartWeek = firstApplicableSemesterWeek(parity);
if (semesterEndWeek && start <= semesterStartWeek && end >= semesterEndWeek) return '';
if (semesterEndWeek && end >= semesterEndWeek) return `(с ${start} нед.)`;
return weekRangeText(start, end);
}
function weekRangeText(start, end) {
return start === end
? `(${start} нед.)`
: `(с ${start} по ${end} нед.)`;
}
function firstApplicableSemesterWeek(parity) {
return parity === 'EVEN' ? 2 : 1;
}
function lastApplicableSemesterWeek(maxWeeks, parity) {
const totalWeeks = Number(maxWeeks || 0);
if (!totalWeeks) return 0;
if (parity === 'ODD' && totalWeeks % 2 === 0) return totalWeeks - 1;
if (parity === 'EVEN' && totalWeeks % 2 !== 0) return totalWeeks - 1;
return totalWeeks;
}
function compareSlotItems(first, second) {
return Number(first.slot.dayOfWeek || 0) - Number(second.slot.dayOfWeek || 0)
|| compareVisualSlots(slotDescriptor(first.slot), slotDescriptor(second.slot))
|| Number(first.index || 0) - Number(second.index || 0);
}
function slotDescriptor(slot) {
return {
key: visualSlotKey(slot),
orderNumber: slot.timeSlotOrder,
timeLabel: slot.timeSlotLabel
};
}
export function compareVisualSlots(first, second) {
const firstOrder = Number(first.orderNumber || first.key);
const secondOrder = Number(second.orderNumber || second.key);
if (!Number.isNaN(firstOrder) && !Number.isNaN(secondOrder) && firstOrder !== secondOrder) {
return firstOrder - secondOrder;
}
return naturalCompare(first.timeLabel, second.timeLabel) || naturalCompare(first.key, second.key);
}
export function buildActiveWeeksByRuleSlot(visualRules, { lessonTypes, semesters }) {
const activeWeeks = new Map();
(visualRules || []).forEach(rule => {
const maxWeeks = maxWeeksForRule(rule, semesters);
(rule.groupIds || []).forEach(groupId => {
LESSON_TYPE_LIMITS.forEach(config => {
const totalHours = Number(rule[config.hoursField] || 0);
if (totalHours <= 0) return;
const categorySlots = (rule.slots || [])
.map((slot, index) => ({ slot, index }))
.filter(item => lessonTypeKeyById(item.slot.lessonTypeId, lessonTypes) === config.key)
.sort(compareSlotItems);
if (!categorySlots.length) return;
let consumedHours = 0;
const startWeek = Math.max(1, Number(rule[config.startWeekField] || 1));
for (let week = startWeek; week <= maxWeeks && consumedHours < totalHours; week += 1) {
for (const item of categorySlots) {
if (consumedHours >= totalHours) break;
if (!slotAppliesToWeek(item.slot, week)) continue;
const key = activeWeekKey(rule.id, groupId, visualRuleSlotKey(item.slot, item.index));
if (!activeWeeks.has(key)) activeWeeks.set(key, []);
activeWeeks.get(key).push(week);
consumedHours += ACADEMIC_HOURS_PER_SLOT;
}
}
});
});
});
return activeWeeks;
}
export function buildVisualLessonsByCell(allowedGroupIds, visualRules, { lessonTypes, semesters, groups }) {
const grouped = new Map();
const activeWeeks = buildActiveWeeksByRuleSlot(visualRules, { lessonTypes, semesters });
(visualRules || []).forEach(rule => {
(rule.slots || []).forEach((slot, index) => {
const slotKey = visualRuleSlotKey(slot, index);
(rule.groupIds || []).forEach((groupId, groupIndex) => {
if (!allowedGroupIds.has(String(groupId))) return;
const weeks = activeWeeks.get(activeWeekKey(rule.id, groupId, slotKey)) || [];
if (!weeks.length) return;
const lesson = {
id: `${rule.id}:${slotKey}:${groupId}`,
ruleId: rule.id,
slotId: slot.id || '',
slotIndex: index,
dayOfWeek: slot.dayOfWeek,
timeSlotId: slot.timeSlotId,
parity: slot.parity || 'BOTH',
subjectName: rule.subjectName || 'Без дисциплины',
teacherName: slot.teacherName || '',
classroomName: slot.classroomName || '',
lessonTypeName: slot.lessonTypeName || '',
lessonFormat: slot.lessonFormat || '',
subgroupNames: slotSubgroupNames(slot),
weekText: compactWeekRanges(weeks, maxWeeksForRule(rule, semesters), slot.parity),
groupName: (rule.groupNames || [])[groupIndex] || groupNameById(groupId, groups)
};
const key = visualCellKey(groupId, slot.dayOfWeek, visualSlotKey(slot));
if (!grouped.has(key)) grouped.set(key, []);
grouped.get(key).push(lesson);
});
});
});
grouped.forEach(items => items.sort(compareVisualLessons));
return grouped;
}
export function buildVisualRows(columns, slots, lessonsByCell) {
const rows = [];
DAY_OPTIONS.forEach(day => {
const dayRows = (slots || [])
.map(slot => ({
day,
slot,
cells: columns.map(group => ({
group,
lessons: lessonsByCell.get(visualCellKey(group.id, day.value, slot.key)) || []
}))
}))
.filter(row => row.cells.some(cell => cell.lessons.length));
dayRows.forEach((row, index) => {
rows.push({
...row,
dayRowspan: index === 0 ? dayRows.length : 0
});
});
});
return rows;
}
export function collectVisualGroups(visualRules, groups) {
const byId = new Map();
(visualRules || []).forEach(rule => {
(rule.groupIds || []).forEach((id, index) => {
if (!byId.has(String(id))) {
byId.set(String(id), {
id,
name: (rule.groupNames || [])[index] || groupNameById(id, groups)
});
}
});
});
return Array.from(byId.values()).sort((first, second) => naturalCompare(first.name, second.name));
}
export function collectVisualSlotsFromRules(visualRules) {
const byKey = new Map();
(visualRules || []).flatMap(rule => rule.slots || []).forEach(slot => {
const key = visualSlotKey(slot);
if (!byKey.has(key)) {
byKey.set(key, {
key,
orderNumber: slot.timeSlotOrder,
orderLabel: slot.timeSlotOrder ? `${slot.timeSlotOrder} пара` : 'Пара',
timeLabel: slot.timeSlotLabel || 'Время не указано'
});
}
});
return Array.from(byKey.values()).sort(compareVisualSlots);
}
export function visualSlotFromTimeSlot(slot) {
return {
key: String(slot.id),
orderNumber: slot.orderNumber,
orderLabel: slot.orderNumber ? `${slot.orderNumber} пара` : 'Пара',
timeLabel: slot.startTime || slot.endTime ? `${trimTime(slot.startTime)}-${trimTime(slot.endTime)}` : 'Время не указано'
};
}
export function compareVisualLessons(first, second) {
return naturalCompare(first.subjectName, second.subjectName)
|| naturalCompare(first.weekText, second.weekText)
|| naturalCompare(first.lessonTypeName, second.lessonTypeName)
|| naturalCompare(first.teacherName, second.teacherName);
}
export function visualLessonSignature(lessons) {
return (lessons || [])
.map(lesson => [
lesson.id,
lesson.subjectName,
lesson.weekText,
lesson.lessonTypeName,
lesson.teacherName,
lesson.classroomName,
lesson.subgroupNames.join(',')
].join(':'))
.sort((first, second) => naturalCompare(first, second))
.join('|');
}
export function uniqueVisualLessons(lessons) {
const unique = new Map();
(lessons || []).forEach(lesson => {
unique.set(visualLessonSignature([lesson]), lesson);
});
return Array.from(unique.values()).sort(compareVisualLessons);
}
export function naturalCompare(a, b) {
return String(a || '').localeCompare(String(b || ''), 'ru', { numeric: true, sensitivity: 'base' });
}
export function ruleCountLabel(count) {
const tail = count % 100;
if (tail >= 11 && tail <= 14) return 'правил';
if (count % 10 === 1) return 'правило';
if ([2, 3, 4].includes(count % 10)) return 'правила';
return 'правил';
}
export function groupCountLabel(count) {
const tail = count % 100;
if (tail >= 11 && tail <= 14) return 'групп';
if (count % 10 === 1) return 'группа';
if ([2, 3, 4].includes(count % 10)) return 'группы';
return 'групп';
}
export function rowCountLabel(count) {
const tail = count % 100;
if (tail >= 11 && tail <= 14) return 'строк';
if (count % 10 === 1) return 'строка';
if ([2, 3, 4].includes(count % 10)) return 'строки';
return 'строк';
}
export function mergeEntitiesById(items) {
return [...new Map((items || []).filter(Boolean).map(item => [String(item.id), item])).values()];
}
export { formatLocalDate, toIsoDate };
// --- Контракты API ---
export async function fetchSubjects(client = apiClient) {
return client.get('/api/subjects', { errorMessage: 'Ошибка загрузки дисциплин' });
}
export async function fetchLessonTypes(client = apiClient) {
return client.get('/api/lesson-types', { errorMessage: 'Ошибка загрузки типов занятий' });
}
export async function fetchTimeSlots(client = apiClient) {
const slots = await client.get('/api/admin/time-slots', { errorMessage: 'Ошибка загрузки пар' });
return (slots || [])
.filter(slot => slot.scopeApplyMode === 'DEFAULT')
.sort((first, second) => (first.orderNumber || 0) - (second.orderNumber || 0));
}
export async function fetchAcademicYears(client = apiClient) {
return client.get('/api/admin/calendar/years', { errorMessage: 'Ошибка загрузки учебных годов' });
}
export async function fetchApprovedTeacherPreferences(client = apiClient) {
return client.get('/api/teacher-preferences?status=APPROVED', {
errorMessage: 'Ошибка загрузки пожеланий преподавателей'
});
}
export function scheduleRulesPageEndpoint({ versionId, page, size, query }) {
const params = new URLSearchParams({
versionId: String(versionId),
page: String(page),
size: String(size),
query: String(query || '').trim()
});
return `/api/admin/schedule-rules/page?${params}`;
}
export async function fetchScheduleRulesPage(filters, client = apiClient) {
return client.get(scheduleRulesPageEndpoint(filters), { errorMessage: 'Ошибка загрузки правил расписания' });
}
export function scheduleRulesEndpoint(versionId) {
return `/api/admin/schedule-rules?versionId=${encodeURIComponent(versionId)}`;
}
export async function fetchScheduleRules(versionId, client = apiClient) {
return client.get(scheduleRulesEndpoint(versionId), { errorMessage: 'Ошибка загрузки визуального расписания' });
}
export async function fetchScheduleRule(ruleId, client = apiClient) {
return client.get(`/api/admin/schedule-rules/${encodeURIComponent(ruleId)}`, {
errorMessage: 'Ошибка загрузки правила расписания'
});
}
export async function saveScheduleRule(payload, ruleId, client = apiClient) {
return ruleId
? client.put(`/api/admin/schedule-rules/${encodeURIComponent(ruleId)}`, payload, {
errorMessage: 'Ошибка сохранения правила'
})
: client.post('/api/admin/schedule-rules', payload, {
errorMessage: 'Ошибка сохранения правила'
});
}
export async function deleteScheduleRule(ruleId, client = apiClient) {
return client.delete(`/api/admin/schedule-rules/${encodeURIComponent(ruleId)}`, {
errorMessage: 'Ошибка удаления правила'
});
}
export function ruleGroupsOptionsEndpoint(query) {
const params = new URLSearchParams({ query: String(query || '').trim(), limit: '50' });
return `/api/groups/options?${params}`;
}
export async function fetchRuleGroupOptions(query, client = apiClient) {
return client.get(ruleGroupsOptionsEndpoint(query), { errorMessage: 'Не удалось найти группы' });
}
export function subgroupsEndpoint(groupIds) {
const params = new URLSearchParams();
(groupIds || []).forEach(groupId => params.append('groupId', groupId));
return `/api/subgroups?${params}`;
}
export async function fetchSubgroupsForGroups(groupIds, client = apiClient) {
return client.get(subgroupsEndpoint(groupIds), { errorMessage: 'Не удалось загрузить подгруппы выбранных групп' });
}

View File

@@ -0,0 +1,124 @@
import { apiClient } from '../shared/api-client.js';
import { formatLocalDate } from '../../admin/js/utils.js';
// Срез «Контур публикации» (этап 5, волна 5): чистая логика и контракты API,
// перенесённые из legacy admin/js/views/schedule-versions.js без изменения поведения.
// --- Ключи localStorage для переходов между вкладками расписания ---
// (общие с конструктором правил и анализом качества)
export const SCHEDULE_YEAR_STORAGE_KEY = 'magistr.schedule.yearId';
export const SCHEDULE_SEMESTER_STORAGE_KEY = 'magistr.schedule.semesterId';
export const SCHEDULE_OPEN_VERSION_STORAGE_KEY = 'magistr.schedule.openVersionId';
export const SCHEDULE_OPEN_RULE_STORAGE_KEY = 'magistr.schedule.openRuleId';
export const SCHEDULE_VERSION_STORAGE_KEY = 'magistr.schedule.versionId';
export const QUALITY_YEAR_STORAGE_KEY = 'magistr.quality.yearId';
export const QUALITY_SEMESTER_STORAGE_KEY = 'magistr.quality.semesterId';
export const QUALITY_VERSION_STORAGE_KEY = 'magistr.quality.versionId';
// --- Подписи ---
export function historyActionLabel(action) {
return {
CREATED: 'Создан черновик',
PUBLISHED: 'Опубликовано',
ARCHIVED: 'Архивировано',
RESTORED: 'Восстановлено'
}[action] || action;
}
export function versionSemesterLabel(semester) {
const type = semester.semesterType === 'autumn' ? 'Осенний' : 'Весенний';
return `${semester.academicYearTitle} · ${type} семестр`;
}
export function formatVersionDateTime(value) {
if (!value) return 'дата не указана';
return new Intl.DateTimeFormat('ru-RU', {
day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit'
}).format(new Date(value));
}
export function ruleChangeTypeLabel(type) {
return { ADDED: 'Добавлено', REMOVED: 'Удалено', CHANGED: 'Изменено' }[type] || type;
}
export function draftBasisText(version) {
return `Основа: ${version.basedOnVersionId ? `версия #${version.basedOnVersionId}` : 'пустое расписание'}`;
}
// --- Группировка версий ---
export function splitScheduleVersions(versions) {
const list = Array.isArray(versions) ? versions : [];
return {
published: list.find(version => version.status === 'PUBLISHED') || null,
drafts: list.filter(version => version.status === 'DRAFT'),
archive: list.filter(version => version.status === 'ARCHIVED')
};
}
// --- Сводка проверки перед публикацией ---
export function publishValidationSummary(validation, diff) {
return validation.valid
? `Проверка пройдена: ${validation.ruleCount} правил, ${validation.slotCount} слотов. `
+ `Занятия: +${diff.addedLessonCount}, ~${diff.changedLessonCount}, −${diff.removedLessonCount}.`
: `Публикация заблокирована: ${(validation.issues || []).join('; ')}`;
}
// --- Контракты API ---
export function scheduleVersionsEndpoint(semesterId) {
return `/api/edu-office/schedule/versions?semesterId=${encodeURIComponent(semesterId)}`;
}
export function scheduleVersionHistoryEndpoint(semesterId) {
return `/api/edu-office/schedule/versions/history?semesterId=${encodeURIComponent(semesterId)}`;
}
export function scheduleVersionDiffEndpoint(versionId) {
return `/api/edu-office/schedule/versions/${encodeURIComponent(versionId)}/diff`;
}
export function scheduleVersionValidateEndpoint(versionId) {
return `/api/edu-office/schedule/versions/${encodeURIComponent(versionId)}/validate`;
}
export function scheduleVersionActionEndpoint(versionId, mode) {
return `/api/edu-office/schedule/versions/${encodeURIComponent(versionId)}/${mode}`;
}
export async function fetchScheduleSemesters(client = apiClient) {
return client.get('/api/schedule/semesters', { errorMessage: 'Не удалось загрузить семестры.' });
}
export async function fetchScheduleVersions(semesterId, client = apiClient) {
return client.get(scheduleVersionsEndpoint(semesterId), { errorMessage: 'Не удалось загрузить версии расписания.' });
}
export async function fetchScheduleVersionHistory(semesterId, client = apiClient) {
return client.get(scheduleVersionHistoryEndpoint(semesterId), { errorMessage: 'Не удалось загрузить журнал действий.' });
}
export async function fetchScheduleVersionDiff(versionId, client = apiClient) {
return client.get(scheduleVersionDiffEndpoint(versionId), { errorMessage: 'Не удалось сравнить версии расписания.' });
}
export async function fetchScheduleVersionValidation(versionId, client = apiClient) {
return client.get(scheduleVersionValidateEndpoint(versionId), { errorMessage: 'Не удалось проверить черновик.' });
}
export async function createScheduleDraftVersion(payload, client = apiClient) {
return client.post('/api/edu-office/schedule/versions', payload, { errorMessage: 'Не удалось создать черновик.' });
}
export async function runScheduleVersionAction(versionId, mode, reason, client = apiClient) {
return client.post(
scheduleVersionActionEndpoint(versionId, mode),
{ reason },
{ errorMessage: 'Операцию не удалось выполнить.' }
);
}
export { formatLocalDate };

View File

@@ -0,0 +1,767 @@
import { apiClient } from '../shared/api-client.js';
// Срез «Просмотр расписаний» (этап 5, волна 5): чистая логика и контракты API,
// перенесённые из legacy admin/js/views/schedule-view.js без изменения поведения.
export const ROLE_DEPARTMENT = 'DEPARTMENT';
export const TARGET_GROUPS = 'groups';
export const TARGET_TEACHERS = 'teachers';
export const TARGET_CLASSROOMS = 'classrooms';
export const TARGET_DEPARTMENTS = 'departments';
export const PARITY_LABELS = Object.freeze({
BOTH: 'каждая',
ODD: 'нечётная',
EVEN: 'чётная'
});
export const DAY_SHORT_LABELS = Object.freeze({
1: 'Пн',
2: 'Вт',
3: 'Ср',
4: 'Чт',
5: 'Пт',
6: 'Сб',
7: 'Вс'
});
export const DAY_FULL_LABELS = Object.freeze({
1: 'Понедельник',
2: 'Вторник',
3: 'Среда',
4: 'Четверг',
5: 'Пятница',
6: 'Суббота',
7: 'Воскресенье'
});
export const DAY_ORDER = Object.freeze([1, 2, 3, 4, 5, 6, 7]);
export const CELL_PARITY_LAYOUT = Object.freeze([
Object.freeze({ parity: 'ODD', label: 'Нечётная' }),
Object.freeze({ parity: 'EVEN', label: 'Чётная' })
]);
export const MOBILE_QUERY = '(max-width: 760px)';
export const DEFAULT_SEMESTER_WEEKS = 22;
export const SCHEDULE_REFRESH_PROMPT = 'Нажмите «Показать», чтобы обновить расписание';
// --- Дата и время ---
export function parseIsoDate(value) {
const [year, month, day] = String(value).split('-').map(Number);
return new Date(year, month - 1, day);
}
export function toIsoDate(date) {
return [
date.getFullYear(),
String(date.getMonth() + 1).padStart(2, '0'),
String(date.getDate()).padStart(2, '0')
].join('-');
}
export function addDays(date, days) {
const next = new Date(date);
next.setDate(next.getDate() + days);
return next;
}
export function isoDay(value) {
const day = parseIsoDate(value).getDay();
return day === 0 ? 7 : day;
}
export function datesBetween(startDate, endDate) {
const dates = [];
for (let date = parseIsoDate(startDate); date <= parseIsoDate(endDate); date.setDate(date.getDate() + 1)) {
dates.push(toIsoDate(date));
}
return dates;
}
export function weekStart(value) {
const date = parseIsoDate(value);
const day = date.getDay() || 7;
date.setDate(date.getDate() + 1 - day);
return toIsoDate(date);
}
export function twoWeekRange(value) {
const selected = parseIsoDate(value);
const start = addDays(selected, 1 - isoDay(value));
const end = addDays(start, 13);
return {
startDate: toIsoDate(start),
endDate: toIsoDate(end)
};
}
export function formatDate(value) {
if (!value) return '-';
const [year, month, day] = String(value).split('-');
return `${day}.${month}.${year}`;
}
export function formatDateShort(value) {
const [, month, day] = String(value).split('-');
return `${day}.${month}`;
}
export function trimTime(value) {
return String(value || '').slice(0, 5);
}
export function dayShort(dayOfWeek) {
return DAY_SHORT_LABELS[Number(dayOfWeek)] || '-';
}
// --- Семестры и периоды ---
export function semesterOptionLabel(semester) {
const type = String(semester?.semesterType || '').toLowerCase();
const typeLabel = type === 'autumn'
? 'Осенний семестр'
: (type === 'spring' ? 'Весенний семестр' : 'Семестр');
const academicYear = semester?.academicYearTitle || 'Учебный год';
const dates = semester?.startDate && semester?.endDate
? ` · ${formatDate(semester.startDate)} — ${formatDate(semester.endDate)}`
: '';
return `${academicYear} · ${typeLabel}${dates}`;
}
export function findSemesterForDate(semesters, date) {
return (semesters || []).find(semester => dateWithinSemester(date, semester)) || null;
}
export function scheduleRangeForSemester(selectedDate, semester) {
if (semester?.startDate && semester?.endDate) {
return {
startDate: semester.startDate,
endDate: semester.endDate
};
}
return twoWeekRange(selectedDate);
}
export function dateWithinSemester(date, semester) {
return Boolean(
date
&& semester?.startDate
&& semester?.endDate
&& date >= semester.startDate
&& date <= semester.endDate
);
}
export function semesterWeekCount(semester) {
if (!semester?.startDate || !semester?.endDate) return DEFAULT_SEMESTER_WEEKS;
const start = parseIsoDate(semester.startDate);
const end = parseIsoDate(semester.endDate);
const days = Math.max(1, Math.round((end - start) / 86400000) + 1);
return Math.max(1, Math.ceil(days / 7));
}
export function semesterScheduleCaption(semester) {
const weeks = semesterWeekCount(semester);
const weekLabel = weeks === 1 ? '1 неделя' : `недели 1–${weeks}`;
return `Весь семестр · ${weekLabel}`;
}
export function periodCaption(range, semester = null) {
if (!range) return 'Учебное расписание';
const rangeLabel = `${formatDate(range.startDate)} — ${formatDate(range.endDate)}`;
return semester ? `${semesterOptionLabel(semester)} · ${rangeLabel}` : rangeLabel;
}
export function formatLessonWeeksText(startWeek, endWeek) {
const start = Number(startWeek);
const end = Number(endWeek);
if (!Number.isFinite(start) || !Number.isFinite(end)) return '';
return start === end
? `(${start} нед.)`
: `(с ${start} по ${end} нед.)`;
}
// Диапазон недель занятия: явные границы из матрицы либо расчёт по часам.
export function getLessonWeeksText(lesson) {
if (lesson?.displayStartWeek != null && lesson?.displayEndWeek != null) {
return formatLessonWeeksText(lesson.displayStartWeek, lesson.displayEndWeek);
}
if (!lesson || lesson.weekNumber == null || lesson.lessonTypeAcademicHours == null) {
return '';
}
const totalHours = lesson.lessonTypeAcademicHours;
const consumedBefore = lesson.consumedLessonTypeAcademicHoursBeforeLesson || 0;
// Каждое занятие - это 2 часа
const totalLessons = Math.ceil(totalHours / 2);
if (totalLessons <= 0) return '';
const parity = lesson.ruleParity || lesson.parity;
let startWeek;
let endWeek;
if (parity === 'EVEN' || parity === 'ODD') {
// Раз в две недели
startWeek = lesson.weekNumber - consumedBefore;
endWeek = startWeek + (totalLessons - 1) * 2;
} else {
// Каждую неделю
const steps = Math.floor(consumedBefore / 2);
startWeek = lesson.weekNumber - steps;
endWeek = startWeek + totalLessons - 1;
}
if (startWeek <= 0) startWeek = 1;
if (endWeek < startWeek) endWeek = startWeek;
return formatLessonWeeksText(startWeek, endWeek);
}
// --- Разделы расписания ---
export function allowedGroupIdsForScheduleTarget(target, selectedGroupId) {
return target === TARGET_GROUPS && selectedGroupId
? new Set([String(selectedGroupId)])
: new Set();
}
export function splitTypeForTarget(target) {
if (target === TARGET_TEACHERS) return TARGET_TEACHERS;
if (target === TARGET_CLASSROOMS) return TARGET_CLASSROOMS;
return TARGET_GROUPS;
}
export function buildSectionContext({ target, selectedGroupId, groups }) {
const allowedGroupIds = allowedGroupIdsForScheduleTarget(target, selectedGroupId);
const groupNamesById = new Map((groups || []).map(group => [String(group.id), group.name]));
return {
selectedGroupId,
selectedGroupName: selectedGroupId
? (groupNamesById.get(String(selectedGroupId)) || `Группа ${selectedGroupId}`)
: '',
allowedGroupIds,
groupNamesById
};
}
export function buildScheduleSections(lessons, splitType, context) {
const sections = new Map();
lessons.forEach(lesson => {
splitEntitiesForLesson(lesson, splitType, context).forEach(entity => {
if (!sections.has(entity.key)) {
sections.set(entity.key, {
key: entity.key,
title: entity.title,
lessons: []
});
}
sections.get(entity.key).lessons.push(lesson);
});
});
return Array.from(sections.values())
.map(section => ({
...section,
lessons: sortLessons(section.lessons)
}))
.sort((a, b) => naturalCompare(a.title, b.title));
}
function splitEntitiesForLesson(lesson, splitType, context) {
if (splitType === TARGET_TEACHERS) {
return [singleEntity('teacher', lesson.teacherId, lesson.teacherName, 'Преподаватель не указан')];
}
if (splitType === TARGET_CLASSROOMS) {
return [singleEntity('classroom', lesson.classroomId, lesson.classroomName, 'Аудитория не указана')];
}
if (context.selectedGroupId) {
return [singleEntity('group', context.selectedGroupId, context.selectedGroupName, 'Группа не указана')];
}
return listEntities('group', lesson.groupIds, lesson.groupNames, 'Группа не указана', context.allowedGroupIds, context.groupNamesById);
}
function singleEntity(prefix, id, name, fallback) {
const title = name || fallback;
return {
id,
key: `${prefix}:${id ?? title}`,
title
};
}
function listEntities(prefix, ids = [], names = [], fallback, allowedIds = new Set(), namesById = new Map()) {
const safeIds = Array.isArray(ids) ? ids : [];
const safeNames = Array.isArray(names) ? names : [];
if (!safeIds.length && !safeNames.length) {
return allowedIds.size ? [] : [singleEntity(prefix, null, fallback, fallback)];
}
const maxLength = Math.max(safeIds.length, safeNames.length);
const entities = [];
for (let index = 0; index < maxLength; index += 1) {
const id = safeIds[index] ?? null;
if (allowedIds.size && (id == null || !allowedIds.has(String(id)))) {
continue;
}
const name = namesById.get(String(id)) || safeNames[index] || (id == null ? fallback : `Группа ${id}`);
entities.push(singleEntity(prefix, id, name, fallback));
}
return entities;
}
export function sortLessons(lessons) {
return [...lessons].sort((a, b) =>
naturalCompare(a.date, b.date)
|| compareSlots(lessonSlotDescriptor(a), lessonSlotDescriptor(b))
|| naturalCompare(a.subjectName, b.subjectName)
|| naturalCompare(a.teacherName, b.teacherName)
|| naturalCompare(a.classroomName, b.classroomName)
);
}
function lessonSlotDescriptor(lesson) {
return {
key: lessonSlotKey(lesson),
orderNumber: lesson.timeSlotOrder,
startTime: lesson.startTime
};
}
// --- Слоты и сетки ---
export function lessonSlotKey(lesson) {
const value = lesson.timeSlotOrder ?? lesson.timeSlotId ?? `${lesson.startTime || ''}-${lesson.endTime || ''}`;
return value ? String(value) : 'unknown';
}
export function dateSlotKey(date, slotKey) {
return `${date}:${slotKey}`;
}
export function collectSlots(lessons) {
const slots = new Map();
lessons.forEach(lesson => {
const key = lessonSlotKey(lesson);
const existing = slots.get(key);
const next = {
key,
id: lesson.timeSlotId,
orderNumber: lesson.timeSlotOrder,
startTime: lesson.startTime,
endTime: lesson.endTime,
orderLabel: lesson.timeSlotOrder ? `${lesson.timeSlotOrder} пара` : 'Пара',
timeLabel: lesson.startTime || lesson.endTime
? `${trimTime(lesson.startTime)}-${trimTime(lesson.endTime)}`
: 'Время не указано'
};
if (!existing || existing.timeLabel === 'Время не указано') {
slots.set(key, next);
}
});
return Array.from(slots.values()).sort(compareSlots);
}
export function groupLessonsByDateSlot(lessons) {
const grouped = new Map();
lessons.forEach(lesson => {
if (!lesson.date) return;
const key = dateSlotKey(lesson.date, lessonSlotKey(lesson));
if (!grouped.has(key)) grouped.set(key, []);
grouped.get(key).push(lesson);
});
grouped.forEach(items => {
items.sort((a, b) => naturalCompare(a.subjectName, b.subjectName)
|| naturalCompare(a.teacherName, b.teacherName)
|| naturalCompare((a.groupNames || []).join(','), (b.groupNames || []).join(',')));
});
return grouped;
}
export function buildCombinedWeekBlocks(startDate, endDate, lessons) {
const dates = datesBetween(startDate, endDate);
const weekGroups = new Map();
dates.forEach(date => {
const key = weekStart(date);
if (!weekGroups.has(key)) weekGroups.set(key, []);
weekGroups.get(key).push(date);
});
const weekStarts = Array.from(weekGroups.keys()).sort((a, b) => naturalCompare(a, b));
const blocks = [];
for (let index = 0; index < weekStarts.length; index += 2) {
const firstWeek = weekGroups.get(weekStarts[index]) || [];
const secondWeek = weekGroups.get(weekStarts[index + 1]) || [];
const blockDates = [...firstWeek, ...secondWeek];
const firstParity = weekParity(firstWeek, lessons) || 'ODD';
const secondParity = weekParity(secondWeek, lessons) || (firstParity === 'ODD' ? 'EVEN' : 'ODD');
const datesWithFallback = [
...firstWeek.map(date => ({ date, fallbackParity: firstParity })),
...secondWeek.map(date => ({ date, fallbackParity: secondParity }))
];
blocks.push({
startDate: blockDates[0],
endDate: blockDates[blockDates.length - 1],
datesByDay: buildDatesByDay(datesWithFallback, lessons),
label: `Период ${formatDate(blockDates[0])} - ${formatDate(blockDates[blockDates.length - 1])}`
});
}
return blocks;
}
function weekParity(dates, lessons) {
for (const date of dates) {
const parity = parityForDate(date, lessons);
if (parity) return parity;
}
return null;
}
export function buildDatesByDay(datesWithFallback, lessons) {
const datesByDay = {};
datesWithFallback.forEach(({ date, fallbackParity }) => {
const day = isoDay(date);
const parity = parityForDate(date, lessons) || fallbackParity;
if (!datesByDay[day]) datesByDay[day] = {};
datesByDay[day][parity] = date;
});
return datesByDay;
}
export function parityForDate(date, lessons) {
const lesson = lessons.find(item => item.date === date && ['ODD', 'EVEN'].includes(item.parity));
if (lesson?.parity) return lesson.parity;
const withWeek = lessons.find(item => item.date === date && item.weekNumber != null);
if (withWeek?.weekNumber != null) return Number(withWeek.weekNumber) % 2 === 0 ? 'EVEN' : 'ODD';
return null;
}
export function combinedWeekCaption() {
return 'Нечётная и чётная недели';
}
export function mobileDayCaption() {
return 'Нечётная / Чётная';
}
// --- Матрица семестра ---
export function buildSemesterLessonMatrix(lessons) {
const weekRanges = new Map();
lessons.forEach(lesson => {
const signature = lessonContentSignature(lesson);
const weekNumber = Number(lesson.weekNumber);
if (!Number.isFinite(weekNumber)) return;
const range = weekRanges.get(signature) || { startWeek: weekNumber, endWeek: weekNumber };
range.startWeek = Math.min(range.startWeek, weekNumber);
range.endWeek = Math.max(range.endWeek, weekNumber);
weekRanges.set(signature, range);
});
const matrix = new Map();
lessons.forEach(lesson => {
const day = Number(lesson.dayOfWeek) || isoDay(lesson.date);
const parity = ['ODD', 'EVEN'].includes(lesson.parity)
? lesson.parity
: (Number(lesson.weekNumber) % 2 === 0 ? 'EVEN' : 'ODD');
const signature = lessonContentSignature(lesson);
const key = `${day}:${lessonSlotKey(lesson)}:${parity}`;
if (!matrix.has(key)) matrix.set(key, new Map());
if (!matrix.get(key).has(signature)) {
const range = weekRanges.get(signature);
matrix.get(key).set(signature, {
...lesson,
displayStartWeek: range?.startWeek,
displayEndWeek: range?.endWeek
});
}
});
return new Map(Array.from(matrix.entries()).map(([key, values]) => [
key,
Array.from(values.values()).sort((a, b) => naturalCompare(a.subjectName, b.subjectName))
]));
}
export function slotStatesForDates(datesByParity, slot, lessonsByDateSlot) {
return CELL_PARITY_LAYOUT.map(({ parity, label }) => ({
parity,
label,
state: scheduleSlotState(datesByParity[parity], slot, lessonsByDateSlot)
}));
}
export function slotStatesForSemester(day, slot, lessonMatrix) {
return CELL_PARITY_LAYOUT.map(({ parity, label }) => {
const lessons = lessonMatrix.get(`${day}:${slot.key}:${parity}`) || [];
return {
parity,
label,
state: lessons.length ? { kind: 'busy', lessons } : { kind: 'free', lessons: [] }
};
});
}
export function scheduleSlotState(date, slot, lessonsByDateSlot) {
if (!date) {
return { kind: 'empty', lessons: [] };
}
const lessons = lessonsByDateSlot.get(dateSlotKey(date, slot.key)) || [];
if (lessons.length) {
return { kind: 'busy', lessons };
}
return { kind: 'free', lessons: [] };
}
export function slotStatesEqual(first, second) {
if (first.kind !== second.kind) return false;
if (first.kind !== 'busy') return true;
return lessonSetSignature(first.lessons) === lessonSetSignature(second.lessons);
}
function lessonSetSignature(lessons) {
return lessons
.map(lessonContentSignature)
.sort((a, b) => naturalCompare(a, b))
.join('|');
}
export function lessonContentSignature(lesson) {
return [
lesson.scheduleRuleSlotId || lesson.scheduleRuleId || '',
lesson.subjectId || lesson.subjectName || '',
lesson.teacherId || lesson.teacherName || '',
lesson.classroomId || lesson.classroomName || '',
lesson.lessonTypeId || lesson.lessonTypeName || '',
lesson.lessonFormat || '',
lesson.scheduleOverrideId || '',
lesson.overrideAction || '',
lesson.originalLessonDate || '',
valueListSignature(lesson.groupIds, lesson.groupNames),
valueListSignature(lesson.subgroupIds, lesson.subgroupNames)
].join(':');
}
function valueListSignature(primary = [], fallback = []) {
const primaryList = Array.isArray(primary) ? primary : [];
const fallbackList = Array.isArray(fallback) ? fallback : [];
const source = primaryList.length ? primaryList : fallbackList;
return [...source]
.map(value => String(value))
.sort((a, b) => naturalCompare(a, b))
.join(',');
}
// --- Подписи ---
export function lessonCountLabel(count) {
const tail = count % 100;
if (tail >= 11 && tail <= 14) return `${count} занятий`;
if (count % 10 === 1) return `${count} занятие`;
if ([2, 3, 4].includes(count % 10)) return `${count} занятия`;
return `${count} занятий`;
}
export function sectionCountLabel(count) {
const tail = count % 100;
if (tail >= 11 && tail <= 14) return `${count} расписаний`;
if (count % 10 === 1) return `${count} расписание`;
if ([2, 3, 4].includes(count % 10)) return `${count} расписания`;
return `${count} расписаний`;
}
// --- Справочники ---
export function roomLabel(room) {
return [room?.name, room?.building, room?.floor ? `${room.floor} этаж` : null].filter(Boolean).join(' · ');
}
export function groupOptionLabel(group) {
return group?.name || '';
}
export function teacherOptionLabel(teacher) {
return teacher?.fullName || teacher?.username || '';
}
export function mergeDictionaryItems(dictionaries, key, items) {
const merged = new Map((dictionaries[key] || []).map(item => [String(item.id), item]));
(items || []).forEach(item => merged.set(String(item.id), item));
dictionaries[key] = [...merged.values()];
}
// --- Контракты API ---
export async function fetchScheduleViewDictionaries(client = apiClient) {
const [departments, subjects, lessonTypes, semesters] = await Promise.all([
client.get('/api/departments', { errorMessage: 'Ошибка загрузки кафедр' }),
client.get('/api/subjects', { errorMessage: 'Ошибка загрузки дисциплин' }),
client.get('/api/lesson-types', { errorMessage: 'Ошибка загрузки типов занятий' }),
client.get('/api/schedule/semesters', { errorMessage: 'Ошибка загрузки семестров' })
]);
return {
groups: [],
teachers: [],
classrooms: [],
departments: departments || [],
subjects: subjects || [],
lessonTypes: lessonTypes || [],
semesters: semesters || []
};
}
export function scheduleSearchEndpoint(filters) {
const params = new URLSearchParams({
startDate: filters.startDate,
endDate: filters.endDate
});
applyTargetFilter(params, filters);
addOptional(params, 'subjectId', filters.subjectId);
addOptional(params, 'lessonTypeId', filters.lessonTypeId);
addOptional(params, 'parity', filters.parity);
return `/api/schedule/search?${params}`;
}
// Backend отвергает поиск сразу по всем активным группам («Уточните группу
// или кафедру»), поэтому запрос без выбранной цели заведомо вернёт 400.
// Используется, чтобы не показывать ошибку там, где достаточно подсказки.
export function hasScheduleSearchTarget(filters) {
if (filters.role === ROLE_DEPARTMENT) {
return Boolean(filters.userDepartmentId);
}
if (filters.target === TARGET_GROUPS) {
return Boolean(filters.groupId);
}
if (filters.target === TARGET_TEACHERS) {
return Boolean(filters.teacherId);
}
if (filters.target === TARGET_CLASSROOMS) {
return Boolean(filters.classroomId);
}
if (filters.target === TARGET_DEPARTMENTS) {
return Boolean(filters.departmentId);
}
return false;
}
function applyTargetFilter(params, filters) {
if (filters.role === ROLE_DEPARTMENT) {
params.set('departmentId', filters.userDepartmentId);
return;
}
if (filters.target === TARGET_GROUPS) {
addOptional(params, 'groupId', filters.groupId);
return;
}
if (filters.target === TARGET_TEACHERS) {
addOptional(params, 'teacherId', filters.teacherId);
return;
}
if (filters.target === TARGET_CLASSROOMS) {
addOptional(params, 'classroomId', filters.classroomId);
return;
}
if (filters.target === TARGET_DEPARTMENTS) {
addOptional(params, 'departmentId', filters.departmentId);
}
}
function addOptional(params, key, value) {
if (value) params.set(key, value);
}
export async function fetchScheduleLessons(filters, client = apiClient) {
return client.get(scheduleSearchEndpoint(filters), { errorMessage: 'Ошибка загрузки расписания' });
}
export function scheduleOverridesEndpoint(range) {
return `/api/edu-office/schedule/overrides?startDate=${range.startDate}&endDate=${range.endDate}`;
}
export async function fetchScheduleOverrides(range, client = apiClient) {
return client.get(scheduleOverridesEndpoint(range), { errorMessage: 'Ошибка загрузки разовых изменений' });
}
export function groupsOptionsEndpoint(query, selectedId, departmentId = '') {
const params = new URLSearchParams({ query, limit: '30' });
if (selectedId) params.set('selectedId', selectedId);
if (departmentId) params.set('departmentId', departmentId);
return `/api/groups/options?${params}`;
}
export function teachersOptionsEndpoint(query, selectedId) {
const params = new URLSearchParams({ query, limit: '30' });
if (selectedId) params.set('selectedId', selectedId);
return `/api/users/teachers/options?${params}`;
}
export function classroomsOptionsEndpoint(query, selectedId) {
const params = new URLSearchParams({ query, limit: '30' });
if (selectedId) params.set('selectedId', selectedId);
return `/api/classrooms/options?${params}`;
}
export async function fetchGroupOptions(query, selectedId, departmentId, client = apiClient) {
return client.get(groupsOptionsEndpoint(query, selectedId, departmentId), { errorMessage: 'Ошибка поиска групп' });
}
export async function fetchTeacherOptions(query, selectedId, client = apiClient) {
return client.get(teachersOptionsEndpoint(query, selectedId), { errorMessage: 'Ошибка поиска преподавателей' });
}
export async function fetchClassroomOptions(query, selectedId, client = apiClient) {
return client.get(classroomsOptionsEndpoint(query, selectedId), { errorMessage: 'Ошибка поиска аудиторий' });
}
// --- Состояние фильтров в URL ---
export function initialScheduleViewState(values = {}) {
return {
target: values.scheduleTarget || TARGET_GROUPS,
entity: values.scheduleEntity || '',
date: values.scheduleDate || '',
yearId: values.scheduleYear || '',
semesterId: values.scheduleSemester || '',
subjectId: values.scheduleSubject || '',
lessonTypeId: values.scheduleLessonType || '',
parity: values.scheduleParity || ''
};
}
export function entityValueForTarget(target, entityIds = {}) {
if (target === TARGET_GROUPS) return entityIds.groupId || '';
if (target === TARGET_TEACHERS) return entityIds.teacherId || '';
if (target === TARGET_CLASSROOMS) return entityIds.classroomId || '';
if (target === TARGET_DEPARTMENTS) return entityIds.departmentId || '';
return '';
}
export function scheduleViewUrlState(filters) {
return {
scheduleTarget: filters.role === ROLE_DEPARTMENT ? '' : filters.target,
scheduleEntity: filters.entityValue || '',
scheduleDate: filters.date || '',
scheduleYear: filters.yearId || '',
scheduleSemester: filters.semesterId || '',
scheduleSubject: filters.subjectId || '',
scheduleLessonType: filters.lessonTypeId || '',
scheduleParity: filters.parity || ''
};
}
// --- Служебные сравнения ---
function compareSlots(a, b) {
const first = Number(a.orderNumber || a.key);
const second = Number(b.orderNumber || b.key);
if (!Number.isNaN(first) && !Number.isNaN(second) && first !== second) {
return first - second;
}
return naturalCompare(a.startTime, b.startTime)
|| naturalCompare(a.key, b.key);
}
function naturalCompare(a, b) {
return String(a || '').localeCompare(String(b || ''), 'ru', { numeric: true, sensitivity: 'base' });
}

View File

@@ -0,0 +1,153 @@
import { apiClient } from '../shared/api-client.js';
export const SUBJECT_TABLE_COLUMNS = 6;
export const SUBJECT_PAGE_SIZES = [25, 50, 100];
export const SUBJECT_SORT_OPTIONS = Object.freeze([
{ value: 'name,asc', label: 'По названию А–Я' },
{ value: 'name,desc', label: 'По названию Я–А' },
{ value: 'code,asc', label: 'По коду' },
{ value: 'departmentId,asc', label: 'По кафедре' }
]);
const DEFAULT_STATE = Object.freeze({
query: '',
departmentId: '',
page: 0,
size: 25,
sort: 'name,asc'
});
export function normalizeSubjectsState(restored = {}) {
const size = Number(restored.subjectSize);
return {
query: restored.subjectQuery || '',
departmentId: restored.subjectDepartment || '',
page: Math.max(Number(restored.subjectPage) || 0, 0),
size: SUBJECT_PAGE_SIZES.includes(size) ? size : 25,
sort: SUBJECT_SORT_OPTIONS.some(option => option.value === restored.subjectSort)
? restored.subjectSort
: 'name,asc'
};
}
export function initialSubjectsState(restored = {}) {
return normalizeSubjectsState(restored);
}
export function defaultSubjectsState() {
return { ...DEFAULT_STATE };
}
export function subjectsQueryParams(state) {
const [sort, direction] = String(state.sort || 'name,asc').split(',');
const params = new URLSearchParams({
query: state.query || '',
page: String(state.page || 0),
size: String(state.size || 25),
sort,
direction
});
if (state.departmentId) params.set('departmentId', state.departmentId);
return `/api/subjects/page?${params}`;
}
export function subjectsUrlState(state) {
return {
subjectQuery: state.query || '',
subjectDepartment: state.departmentId || '',
subjectPage: state.page || '',
subjectSize: state.size === 25 ? '' : state.size,
subjectSort: state.sort === 'name,asc' ? '' : state.sort
};
}
export function fetchDepartments(client = apiClient) {
return client.get('/api/departments', { errorMessage: 'Не удалось загрузить список кафедр' });
}
export function fetchSubjectsPage(state, client = apiClient) {
return client.get(subjectsQueryParams(state), { errorMessage: 'Не удалось загрузить дисциплины' });
}
export function teacherAssignmentsEndpoint(subjectIds) {
const params = new URLSearchParams();
(subjectIds || []).forEach(subjectId => params.append('subjectId', subjectId));
return `/api/teacher-subjects?${params}`;
}
export function fetchTeacherAssignments(subjectIds, client = apiClient) {
if (!(subjectIds || []).length) return Promise.resolve([]);
return client.get(
teacherAssignmentsEndpoint(subjectIds),
{ errorMessage: 'Не удалось загрузить привязки преподавателей' }
);
}
export function createSubject({ name, code, departmentId }, client = apiClient) {
return client.post(
'/api/subjects',
{ name, code, departmentId: Number(departmentId) },
{ errorMessage: 'Ошибка создания' }
);
}
export function archiveSubject(id, client = apiClient) {
return client.delete(`/api/subjects/${id}`, { errorMessage: 'Ошибка архивирования' });
}
export function assignTeacher({ userId, subjectId }, client = apiClient) {
return client.post(
'/api/teacher-subjects',
{ userId: Number(userId), subjectId: Number(subjectId) },
{ errorMessage: 'Ошибка привязки' }
);
}
export function removeTeacherAssignment({ userId, subjectId }, client = apiClient) {
return client.delete('/api/teacher-subjects', {
json: { userId: Number(userId), subjectId: Number(subjectId) },
errorMessage: 'Ошибка удаления привязки'
});
}
export function teachersSearchEndpoint(query, selectedId) {
const params = new URLSearchParams({ query, limit: '30' });
if (selectedId) params.set('selectedId', selectedId);
return `/api/users/teachers/options?${params}`;
}
export function departmentLabel(departments, departmentId) {
const department = (departments || []).find(item => String(item.id) === String(departmentId));
if (!department) return departmentId || '-';
return department.departmentName || department.name || departmentId;
}
export function departmentOptionLabel(department) {
const name = department.departmentName || department.name || 'Без названия';
const code = department.departmentCode || department.code || department.id;
return `${name} (${code})`;
}
export function groupTeachersBySubject(assignments) {
const grouped = new Map();
(assignments || []).forEach(assignment => {
const key = String(assignment.subjectId);
if (!grouped.has(key)) grouped.set(key, []);
grouped.get(key).push(assignment);
});
return grouped;
}
export function subjectContextLabel(subject) {
return `${subject.code ? `${subject.code} · ` : ''}${subject.name}`;
}
export function subjectsEmptyMessage(state) {
return state.query || state.departmentId ? 'Дисциплины не найдены' : 'Дисциплин пока нет';
}
export function normalizePageState(state, page) {
const nextPage = Number(page);
return { ...state, page: Number.isFinite(nextPage) && nextPage > 0 ? nextPage : 0 };
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,775 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { toIsoDate } from '../../shared/dates.js';
import { FormAlert } from '../../shared/ui/FormAlert.jsx';
import {
CELL_PARITY_LAYOUT,
DAY_FULL_LABELS,
DAY_ORDER,
DISPLAY_ALL,
TARGET_CLASSROOMS,
TARGET_DEPARTMENTS,
TARGET_TEACHERS,
WORKLOAD_TARGETS,
buildingOptions,
countOccupiedCells,
datesBetween,
entityAxisParts,
entityOptionLabel,
entitySlotKey,
equipmentOptions,
filteredClassrooms,
formatDateShort,
loadEntityLessons,
loadOverviewLessons,
loadSlotsForDates,
overviewSummaryText,
parseMinCapacity,
roomSlotState,
roomSlotStatesEqual,
roomSummaryParts,
roomWeekCaption,
sameId,
scopeShort,
semestersFromYears,
sortClassrooms,
sortDepartments,
sortTeachers,
timeSlotLabel,
twoWeekRange,
buildDateGridByDay,
buildSlotRows,
countAvailableRoomCells,
countOccupiedRoomCells,
dayShort,
fetchAcademicYears,
fetchEffectiveTimeSlots,
fetchWorkloadClassrooms,
fetchWorkloadDepartments,
fetchWorkloadEquipments,
fetchWorkloadGroups,
fetchWorkloadTeachers
} from '../auditorium-workload-service.js';
export function AuditoriumWorkloadTab() {
const [date, setDate] = useState(() => toIsoDate(new Date()));
const [currentTarget, setCurrentTarget] = useState(TARGET_CLASSROOMS);
const [displayValue, setDisplayValue] = useState(DISPLAY_ALL);
const [buildings, setBuildings] = useState([]);
const [equipment, setEquipment] = useState([]);
const [minCapacityText, setMinCapacityText] = useState('');
const [classrooms, setClassrooms] = useState([]);
const [teachers, setTeachers] = useState([]);
const [departments, setDepartments] = useState([]);
const [equipments, setEquipments] = useState([]);
const [groups, setGroups] = useState([]);
const [semesters, setSemesters] = useState([]);
const [overview, setOverview] = useState({
loading: true,
message: 'Загрузка справочников и расписания...',
error: '',
timeSlots: [],
lessonsByEntitySlot: new Map(),
failedGroupLoads: 0,
summaryReady: false
});
const [detail, setDetail] = useState({
loading: false,
message: '',
error: '',
summary: null,
weekTable: null
});
const [alertState, setAlertState] = useState(null);
const dateRef = useRef(date);
const targetRef = useRef(currentTarget);
const displayRef = useRef(displayValue);
const targetConfig = WORKLOAD_TARGETS[currentTarget] || WORKLOAD_TARGETS[TARGET_CLASSROOMS];
const roomMode = displayValue !== DISPLAY_ALL;
const showOverviewFilters = !roomMode && currentTarget === TARGET_CLASSROOMS;
function showWorkloadAlert(text) {
setAlertState({ message: text, type: 'error' });
}
function hideWorkloadAlert() {
setAlertState(null);
}
useEffect(() => {
let active = true;
(async () => {
try {
const [loadedClassrooms, loadedEquipments, loadedGroups, academicYears, loadedTeachers, loadedDepartments] = await Promise.all([
fetchWorkloadClassrooms(),
fetchWorkloadEquipments(),
fetchWorkloadGroups(),
fetchAcademicYears(),
fetchWorkloadTeachers(),
fetchWorkloadDepartments()
]);
if (!active) return;
const dictionaries = {
classrooms: sortClassrooms(loadedClassrooms),
teachers: sortTeachers(loadedTeachers),
departments: sortDepartments(loadedDepartments),
equipments: loadedEquipments || [],
groups: loadedGroups || [],
semesters: semestersFromYears(academicYears)
};
setClassrooms(dictionaries.classrooms);
setTeachers(dictionaries.teachers);
setDepartments(dictionaries.departments);
setEquipments(dictionaries.equipments);
setGroups(dictionaries.groups);
setSemesters(dictionaries.semesters);
await loadCurrentView({ dictionaries });
} catch (error) {
if (!active) return;
const message = error.message || 'Ошибка загрузки данных загруженности';
setOverview(current => ({ ...current, loading: false, error: message }));
setDetail(current => ({ ...current, loading: false, error: message }));
showWorkloadAlert(message);
}
})();
return () => {
active = false;
};
}, []);
async function loadCurrentView(context = {}) {
const targetValue = context.target ?? targetRef.current;
const displayVal = context.display ?? displayRef.current;
const dateValue = context.date ?? dateRef.current;
const source = context.dictionaries ?? { classrooms, teachers, departments, groups, semesters };
const config = WORKLOAD_TARGETS[targetValue] || WORKLOAD_TARGETS[TARGET_CLASSROOMS];
const entities = targetValue === TARGET_TEACHERS
? source.teachers
: targetValue === TARGET_DEPARTMENTS ? source.departments : source.classrooms;
if (displayVal !== DISPLAY_ALL) {
await loadDetail({
dateValue,
targetValue,
displayVal,
entities,
groups: source.groups,
teachers: source.teachers,
departments: source.departments,
semesters: source.semesters
});
} else {
await loadOverview({
dateValue,
targetValue,
groups: source.groups,
teachers: source.teachers,
departments: source.departments
});
}
}
async function loadOverview({ dateValue, targetValue, groups: groupList, teachers: teacherList, departments: departmentList }) {
if (!dateValue) {
showWorkloadAlert('Выберите дату');
return;
}
hideWorkloadAlert();
setOverview(current => ({
...current,
loading: true,
message: 'Загрузка расписания на выбранную дату...',
error: ''
}));
try {
const slots = await fetchEffectiveTimeSlots(dateValue);
if (!(groupList || []).length) {
setOverview({
loading: false,
error: '',
timeSlots: slots || [],
lessonsByEntitySlot: new Map(),
failedGroupLoads: 0,
summaryReady: true
});
showWorkloadAlert('Группы не созданы, загруженность пуста');
return;
}
const { lessonsByEntitySlot, failedLoads } = await loadOverviewLessons({
groups: groupList,
date: dateValue,
target: targetValue,
teachers: teacherList,
departments: departmentList
});
setOverview({
loading: false,
error: '',
timeSlots: slots || [],
lessonsByEntitySlot,
failedGroupLoads: failedLoads,
summaryReady: true
});
if (failedLoads > 0) {
showWorkloadAlert(`Часть групп не загрузилась: ${failedLoads}. Показаны доступные данные.`);
}
} catch (error) {
const message = error.message || 'Ошибка загрузки загруженности';
setOverview(current => ({ ...current, loading: false, error: message }));
showWorkloadAlert(message);
}
}
async function loadDetail({ dateValue, targetValue, displayVal, entities, groups: groupList, teachers: teacherList, departments: departmentList, semesters: semesterList }) {
const config = WORKLOAD_TARGETS[targetValue] || WORKLOAD_TARGETS[TARGET_CLASSROOMS];
const entityId = displayVal !== DISPLAY_ALL ? displayVal.slice(config.prefix.length) : null;
const entity = entityId
? (entities || []).find(item => sameId(item.id, entityId)) || null
: null;
if (!dateValue) {
showWorkloadAlert('Выберите дату');
setDetail({
loading: false,
message: '',
error: 'Выберите дату для расчёта двухнедельного периода',
summary: null,
weekTable: null
});
return;
}
if (!entity) {
showWorkloadAlert(config.selectError);
setDetail({
loading: false,
message: '',
error: `${config.selectError} в поле «Отображение»`,
summary: null,
weekTable: null
});
return;
}
hideWorkloadAlert();
setDetail({
loading: true,
message: 'Загрузка расписания на две недели...',
error: '',
summary: null,
weekTable: null
});
try {
const range = twoWeekRange(dateValue);
const dates = datesBetween(range.startDate, range.endDate);
const [slotLoad, lessonLoad] = await Promise.all([
loadSlotsForDates(dates),
loadEntityLessons({
groups: groupList,
startDate: range.startDate,
endDate: range.endDate,
entityId: entity.id,
target: targetValue,
teachers: teacherList,
departments: departmentList
})
]);
const slotRows = buildSlotRows(dates, slotLoad.slotsByDate, lessonLoad.lessons);
const dateGrid = buildDateGridByDay(dates, lessonLoad.parityLessons, semesterList);
setDetail({
loading: false,
message: '',
error: '',
summary: roomSummaryParts({
target: targetValue,
entity,
range,
totalCells: countAvailableRoomCells(dates, slotLoad.slotsByDate, lessonLoad.lessonsByDateSlot),
occupiedCells: countOccupiedRoomCells(lessonLoad.lessonsByDateSlot),
failedGroupLoads: lessonLoad.failedLoads,
failedSlotLoads: slotLoad.failedLoads,
unknownParityDates: dateGrid.unknownParityDates
}),
weekTable: slotRows.length ? {
datesByDay: dateGrid.datesByDay,
slotRows,
slotsByDate: slotLoad.slotsByDate,
lessonsByDateSlot: lessonLoad.lessonsByDateSlot
} : null
});
const warnings = [];
if (lessonLoad.failedLoads > 0) warnings.push(`групп не загрузилось: ${lessonLoad.failedLoads}`);
if (slotLoad.failedLoads > 0) warnings.push(`сеток времени не загрузилось: ${slotLoad.failedLoads}`);
if (warnings.length) {
showWorkloadAlert(`Часть данных не загрузилась (${warnings.join(', ')}). Показаны доступные данные.`);
}
} catch (error) {
const message = error.message || 'Ошибка загрузки загруженности';
setDetail({
loading: false,
message: '',
error: message,
summary: null,
weekTable: null
});
showWorkloadAlert(message);
}
}
function handleDateChange(value) {
dateRef.current = value;
setDate(value);
void loadCurrentView();
}
function handleTargetChange(value) {
const nextTarget = value || TARGET_CLASSROOMS;
targetRef.current = nextTarget;
displayRef.current = DISPLAY_ALL;
setCurrentTarget(nextTarget);
setDisplayValue(DISPLAY_ALL);
void loadCurrentView({ target: nextTarget, display: DISPLAY_ALL });
}
function handleDisplayChange(value) {
displayRef.current = value;
setDisplayValue(value);
void loadCurrentView({ display: value });
}
function toggleBuilding(value) {
setBuildings(current => (
current.includes(value) ? current.filter(item => item !== value) : [...current, value]
));
}
function toggleEquipment(value) {
setEquipment(current => (
current.includes(value) ? current.filter(item => item !== value) : [...current, value]
));
}
const buildingOptionItems = useMemo(() => buildingOptions(classrooms), [classrooms]);
const equipmentOptionItems = useMemo(
() => equipmentOptions(equipments, classrooms),
[equipments, classrooms]
);
const minCapacity = parseMinCapacity(minCapacityText);
const entities = currentTarget === TARGET_TEACHERS
? teachers
: currentTarget === TARGET_DEPARTMENTS ? departments : classrooms;
const gridRows = useMemo(() => {
if (currentTarget === TARGET_CLASSROOMS) {
return filteredClassrooms(classrooms, { buildings, minCapacity, equipment });
}
return currentTarget === TARGET_TEACHERS ? teachers : departments;
}, [currentTarget, classrooms, teachers, departments, buildings, minCapacity, equipment]);
const displayOptions = useMemo(() => ([
{ value: DISPLAY_ALL, label: targetConfig.allLabel },
...entities.map(entity => ({
value: `${targetConfig.prefix}${entity.id}`,
label: entityOptionLabel(currentTarget, entity)
}))
]), [currentTarget, entities, targetConfig]);
const summaryText = !overview.loading && overview.summaryReady
? overviewSummaryText({
target: currentTarget,
dateValue: date,
entityCount: gridRows.length,
occupiedCells: countOccupiedCells(gridRows, overview.timeSlots, overview.lessonsByEntitySlot),
totalCells: gridRows.length * overview.timeSlots.length,
failedGroupLoads: overview.failedGroupLoads,
selectedEntityId: null
})
: '';
const stateColSpan = Math.max(overview.timeSlots.length, 1) + 1;
return (
<div className="card">
<div className="card-header-row workload-card-header">
<h2>Загруженность</h2>
<button
type="button"
className="btn btn-md btn-secondary"
id="workload-refresh"
onClick={() => void loadCurrentView()}
>
Обновить
</button>
</div>
<div className="filter-row workload-filter-row">
<div className="form-group workload-target-control">
<label htmlFor="workload-target-select">Что смотреть</label>
<select
id="workload-target-select"
className="workload-display-select"
value={currentTarget}
onChange={event => handleTargetChange(event.target.value)}
>
<option value={TARGET_CLASSROOMS}>Аудитории</option>
<option value={TARGET_TEACHERS}>Преподаватели</option>
<option value={TARGET_DEPARTMENTS}>Кафедры</option>
</select>
</div>
<div className="form-group workload-display-control">
<label htmlFor="workload-display-select">Отображение</label>
<select
id="workload-display-select"
className="workload-display-select"
value={displayValue}
onChange={event => handleDisplayChange(event.target.value)}
>
{displayOptions.map(option => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</div>
{showOverviewFilters && (
<div className="form-group workload-overview-filter">
<label>Корпус</label>
<WorkloadMultiSelect
options={buildingOptionItems}
selectedValues={buildings}
emptyText="Все корпуса"
onToggle={toggleBuilding}
/>
</div>
)}
{showOverviewFilters && (
<div className="form-group workload-overview-filter">
<label htmlFor="workload-min-capacity">Мин. вместимость</label>
<input
type="number"
id="workload-min-capacity"
className="workload-min-capacity"
placeholder="0"
min="0"
value={minCapacityText}
onChange={event => setMinCapacityText(event.target.value)}
/>
</div>
)}
{showOverviewFilters && (
<div className="form-group workload-overview-filter">
<label>Оборудование</label>
<WorkloadMultiSelect
options={equipmentOptionItems}
selectedValues={equipment}
emptyText="Любое оборудование"
onToggle={toggleEquipment}
/>
</div>
)}
<div className="form-group workload-date-field">
<label htmlFor="workload-date">{roomMode ? 'Дата в периоде' : 'Дата'}</label>
<input
type="date"
id="workload-date"
value={date}
onChange={event => handleDateChange(event.target.value)}
/>
</div>
</div>
<FormAlert alert={alertState} id="workload-alert" />
{!roomMode && (
<div id="workload-overview-block">
<div className="workload-summary" id="workload-summary">{summaryText}</div>
<div className="workload-grid-container">
<table className="workload-table" id="workload-table">
<thead>
<tr id="workload-header-row">
<th className="top-left-cell">
<span className="top-label">Время</span>
<span className="bottom-label">{targetConfig.rowLabel}</span>
</th>
{overview.timeSlots.map(slot => (
<th key={slot.id ?? slot.orderNumber}>
<strong>{slot.orderNumber || ''} пара</strong>
<span>{timeSlotLabel(slot)}</span>
{slot.scopeApplyMode !== 'DEFAULT' && <small>{scopeShort(slot)}</small>}
</th>
))}
</tr>
</thead>
<tbody id="workload-tbody">
{overview.loading ? (
<tr>
<td colSpan={stateColSpan} className="loading-row">{overview.message}</td>
</tr>
) : overview.error ? (
<tr>
<td colSpan={stateColSpan} className="loading-row">{overview.error}</td>
</tr>
) : gridRows.length === 0 ? (
<tr>
<td colSpan={overview.timeSlots.length + 1} className="loading-row">
{targetConfig.emptyLabel}
</td>
</tr>
) : gridRows.map(entity => (
<WorkloadRow
key={entity.id}
target={currentTarget}
entity={entity}
timeSlots={overview.timeSlots}
lessonsByEntitySlot={overview.lessonsByEntitySlot}
/>
))}
</tbody>
</table>
</div>
</div>
)}
{roomMode && (
<div className="room-workload-block" id="room-workload-block">
<div className="room-workload-summary" id="room-workload-summary">
{detail.loading || detail.error || !detail.summary ? '' : (
<>
<div className="room-summary-main">
<strong>{detail.summary.name}</strong>
{detail.summary.details && <span>{detail.summary.details}</span>}
</div>
<span className="room-summary-chip">{detail.summary.rangeLabel}</span>
<span className="room-summary-chip">{detail.summary.occupancyLabel}</span>
{detail.summary.warnings && (
<span className="room-summary-warning">{detail.summary.warnings}</span>
)}
</>
)}
</div>
<div className="room-workload-tables" id="room-workload-tables">
{detail.loading ? (
<div className="loading-row room-workload-state">{detail.message}</div>
) : detail.error ? (
<div className="loading-row room-workload-state">{detail.error}</div>
) : !detail.weekTable ? (
<div className="loading-row room-workload-state">
За выбранный период нет временных слотов
</div>
) : (
<RoomWeekTable
target={currentTarget}
semesters={semesters}
datesByDay={detail.weekTable.datesByDay}
slotRows={detail.weekTable.slotRows}
slotsByDate={detail.weekTable.slotsByDate}
lessonsByDateSlot={detail.weekTable.lessonsByDateSlot}
/>
)}
</div>
</div>
)}
</div>
);
}
function WorkloadRow({ target, entity, timeSlots, lessonsByEntitySlot }) {
const axis = entityAxisParts(target, entity);
const axisClass = `axis-cell${target === TARGET_CLASSROOMS && entity.isAvailable === false ? ' room-unavailable' : ''}`;
return (
<tr>
<td className={axisClass}>
<strong>{axis.title}</strong>
<span>{axis.subtitle}</span>
{axis.note && <small>{axis.note}</small>}
</td>
{timeSlots.map(slot => {
const lessons = lessonsByEntitySlot.get(entitySlotKey(entity.id, slot.id)) || [];
return (
<td
key={slot.id ?? slot.orderNumber}
className={lessons.length ? 'workload-busy-cell' : 'workload-free-cell'}
>
{lessons.length
? lessons.map((lesson, index) => <LessonCard key={index} lesson={lesson} />)
: <span className="free-slot">Свободно</span>}
</td>
);
})}
</tr>
);
}
function LessonCard({ lesson }) {
const subgroupText = (lesson.subgroupNames || []).join(', ');
return (
<div className="lesson-card">
<div className="lesson-subject">{lesson.subjectName || 'Без дисциплины'}</div>
<div className="lesson-group">{(lesson.groupNames || []).join(', ') || 'Группа не указана'}</div>
{subgroupText && <div className="lesson-subgroup">{subgroupText}</div>}
<div className="lesson-teacher">{lesson.teacherName || 'Преподаватель не указан'}</div>
<div className="lesson-meta">
{[lesson.lessonTypeName, lesson.lessonFormat].filter(Boolean).join(' · ')}
</div>
</div>
);
}
function RoomWeekTable({ target, semesters, datesByDay, slotRows, slotsByDate, lessonsByDateSlot }) {
const config = WORKLOAD_TARGETS[target] || WORKLOAD_TARGETS[TARGET_CLASSROOMS];
return (
<section className="room-week-panel">
<div className="room-week-heading">
<h3>{config.periodTitle}</h3>
<span>{roomWeekCaption(datesByDay, semesters)}</span>
</div>
<div className="workload-grid-container room-grid-container">
<table className="workload-table room-week-table">
<thead>
<tr>
<th className="room-time-heading">Время</th>
{DAY_ORDER.map(day => (
<th className="room-day-cell" key={day}>
<strong>{dayShort(day)}</strong>
<span>{DAY_FULL_LABELS[day] || ''}</span>
<div className="room-day-parity-dates">
{CELL_PARITY_LAYOUT.map(({ parity, label }) => (
<span key={parity}>
{label}: {datesByDay[day]?.[parity]
? formatDateShort(datesByDay[day][parity])
: '-'}
</span>
))}
</div>
</th>
))}
</tr>
</thead>
<tbody>
{slotRows.map(row => (
<tr key={row.key}>
<td className="room-time-cell">
<strong>{row.orderLabel}</strong>
<span>{row.timeLabel}</span>
</td>
{DAY_ORDER.map(day => (
<RoomCombinedCell
key={day}
datesByParity={datesByDay[day] || {}}
row={row}
slotsByDate={slotsByDate}
lessonsByDateSlot={lessonsByDateSlot}
/>
))}
</tr>
))}
</tbody>
</table>
</div>
</section>
);
}
function RoomCombinedCell({ datesByParity, row, slotsByDate, lessonsByDateSlot }) {
const states = CELL_PARITY_LAYOUT.map(({ parity, label }) => ({
parity,
label,
state: roomSlotState(datesByParity[parity], row, slotsByDate, lessonsByDateSlot)
}));
if (roomSlotStatesEqual(states[0].state, states[1].state)) {
const state = states[0].state;
return (
<td className="room-combined-cell room-single-cell">
<div className={`room-week-unified room-week-unified-${state.kind === 'busy' ? 'busy' : state.kind}`}>
{state.kind === 'busy'
? state.lessons.map((lesson, index) => <LessonCard key={index} lesson={lesson} />)
: state.kind === 'missing' ? <span className="slot-unavailable">Нет слота</span>
: state.kind === 'empty' ? <span className="slot-unavailable">Нет даты</span>
: <span className="free-slot">Свободно</span>}
</div>
</td>
);
}
return (
<td className="room-combined-cell">
{states.map(({ parity, label, state }) => (
<div
key={parity}
className={`room-week-half room-week-half-${parity.toLowerCase()} room-week-half-${state.kind === 'busy' ? 'busy' : state.kind}`}
>
<span className="room-half-marker">{label}</span>
{state.kind === 'busy'
? state.lessons.map((lesson, index) => <LessonCard key={index} lesson={lesson} />)
: state.kind === 'missing' ? <span className="slot-unavailable">Нет слота</span>
: state.kind === 'empty' ? <span className="slot-unavailable">Нет даты</span>
: <span className="free-slot">Свободно</span>}
</div>
))}
</td>
);
}
function WorkloadMultiSelect({ options, selectedValues, emptyText, onToggle }) {
const rootRef = useRef(null);
const [open, setOpen] = useState(false);
useEffect(() => {
if (!open) return undefined;
function handlePointerDown(event) {
if (rootRef.current && !rootRef.current.contains(event.target)) {
setOpen(false);
}
}
document.addEventListener('mousedown', handlePointerDown);
return () => document.removeEventListener('mousedown', handlePointerDown);
}, [open]);
const selectedLabels = options
.filter(option => selectedValues.includes(option.value))
.map(option => option.label);
const label = selectedLabels.length === 0
? emptyText
: selectedLabels.length === 1 ? selectedLabels[0] : `Выбрано: ${selectedLabels.length}`;
return (
<div className="custom-multi-select" ref={rootRef}>
<button
type="button"
className={`select-box${open ? ' active' : ''}`}
aria-expanded={open}
onClick={() => setOpen(current => !current)}
>
<span className="select-text">{label}</span>
<svg className="dropdown-icon" width="12" height="8" viewBox="0 0 12 8" fill="none" aria-hidden="true">
<path d="M1 1.5L6 6.5L11 1.5" stroke="#9ca3af" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
<div className={`dropdown-menu${open ? ' open' : ''}`}>
<div className="checkbox-group-vertical">
{options.length === 0 ? (
<div className="workload-filter-empty">Нет данных</div>
) : options.map(option => (
<label className="checkbox-item" key={option.value}>
<input
type="checkbox"
value={option.value}
checked={selectedValues.includes(option.value)}
onChange={() => onToggle(option.value)}
/>
<span className="checkmark"></span>
<span className="checkbox-label">{option.label}</span>
</label>
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,632 @@
import { useEffect, useRef, useState } from 'react';
import { AlertDialog, ConfirmDialog } from '../../shared/ui/ConfirmDialog.jsx';
import {
archiveClassroom,
classroomStatusLabel,
createClassroom,
createEquipment,
deleteEquipment,
equipmentIdSelection,
equipmentNamesText,
equipmentSelectLabel,
fetchClassrooms,
fetchEquipments,
parseCapacity,
restoreClassroom,
toggleClassroomAvailability,
updateClassroom
} from '../classrooms-service.js';
export function ClassroomsTab() {
const [equipments, setEquipments] = useState([]);
const [classrooms, setClassrooms] = useState(null);
const [loadError, setLoadError] = useState(false);
const [createName, setCreateName] = useState('');
const [createCapacity, setCreateCapacity] = useState('');
const [createEquipmentIds, setCreateEquipmentIds] = useState(() => new Set());
const [createAlert, setCreateAlert] = useState({ text: '', isError: false });
const [editModal, setEditModal] = useState(null);
const [equipmentModal, setEquipmentModal] = useState(null);
const [confirmState, setConfirmState] = useState(null);
const [alertState, setAlertState] = useState(null);
useEffect(() => {
let active = true;
(async () => {
try {
const [loadedEquipments, loadedClassrooms] = await Promise.all([
fetchEquipments(),
fetchClassrooms()
]);
if (!active) return;
setEquipments(loadedEquipments || []);
setClassrooms(loadedClassrooms || []);
setLoadError(false);
} catch {
if (active) setLoadError(true);
}
})();
return () => {
active = false;
};
}, []);
async function reloadClassrooms() {
try {
setClassrooms(await fetchClassrooms());
setLoadError(false);
} catch {
setLoadError(true);
}
}
async function reloadEquipments() {
const loaded = await fetchEquipments();
setEquipments(loaded || []);
return loaded || [];
}
function toggleCreateEquipment(equipmentId) {
setCreateEquipmentIds(current => {
const next = new Set(current);
if (next.has(equipmentId)) {
next.delete(equipmentId);
} else {
next.add(equipmentId);
}
return next;
});
}
async function submitCreate(event) {
event.preventDefault();
const name = createName.trim();
const capacity = parseCapacity(createCapacity);
if (!name || Number.isNaN(capacity)) {
setCreateAlert({ text: 'Заполните обязательные поля', isError: true });
return;
}
try {
const created = await createClassroom({
name,
capacity,
equipmentIds: [...createEquipmentIds].map(Number)
});
setCreateAlert({ text: `Аудитория «${created.name}» добавлена`, isError: false });
setCreateName('');
setCreateCapacity('');
setCreateEquipmentIds(new Set());
await reloadClassrooms();
} catch (error) {
setCreateAlert({ text: error.message || 'Ошибка создания', isError: true });
}
}
function confirmArchive(classroom) {
setConfirmState({
title: 'Вывод аудитории из эксплуатации',
message: 'Историческое расписание сохранится, но аудитория перестанет участвовать в планировании.',
confirmLabel: 'Вывести из эксплуатации',
danger: true,
action: async () => {
try {
await archiveClassroom(classroom.id);
await reloadClassrooms();
} catch (error) {
setAlertState({
title: 'Ошибка архивирования',
message: error.message || 'Не удалось архивировать аудиторию'
});
}
}
});
}
async function restore(classroom) {
try {
await restoreClassroom(classroom.id);
await reloadClassrooms();
} catch (error) {
setAlertState({
title: 'Ошибка восстановления',
message: error.message || 'Не удалось восстановить аудиторию'
});
}
}
async function toggleAvailability(classroom) {
try {
await toggleClassroomAvailability(classroom.id, !classroom.isAvailable);
await reloadClassrooms();
} catch (error) {
setAlertState({
title: 'Ошибка изменения статуса',
message: error.message || 'Не удалось изменить доступность аудитории'
});
}
}
async function openEditModal(classroomId) {
try {
const fresh = await fetchClassrooms();
const classroom = (fresh || []).find(item => String(item.id) === String(classroomId));
if (!classroom) return;
setEditModal({
classroom,
name: classroom.name,
capacity: String(classroom.capacity),
equipmentIds: new Set((classroom.equipments || []).map(equipment => String(equipment.id))),
alert: { text: '', isError: false }
});
} catch (error) {
setAlertState({
title: 'Ошибка загрузки',
message: error.message || 'Не удалось загрузить данные аудитории'
});
}
}
function toggleEditEquipment(equipmentId) {
setEditModal(current => {
if (!current) return current;
const next = new Set(current.equipmentIds);
if (next.has(equipmentId)) {
next.delete(equipmentId);
} else {
next.add(equipmentId);
}
return { ...current, equipmentIds: next };
});
}
async function submitEdit(event) {
event.preventDefault();
const modal = editModal;
const name = modal.name.trim();
const capacity = parseCapacity(modal.capacity);
if (!name || Number.isNaN(capacity)) {
setEditModal({ ...modal, alert: { text: 'Заполните обязательные поля', isError: true } });
return;
}
try {
const updated = await updateClassroom(modal.classroom.id, {
name,
capacity,
equipmentIds: [...modal.equipmentIds].map(Number),
isAvailable: modal.classroom.isAvailable
});
setEditModal(null);
setCreateAlert({ text: `Аудитория «${updated.name}» обновлена`, isError: false });
await reloadClassrooms();
} catch (error) {
setEditModal({
...modal,
alert: { text: error.message || 'Ошибка обновления', isError: true }
});
}
}
async function openEquipmentModal() {
setEquipmentModal({
name: '',
list: null,
alert: { text: '', isError: false }
});
await reloadEquipmentModalList();
}
async function reloadEquipmentModalList() {
try {
const loaded = await reloadEquipments();
setEquipmentModal(current => (current ? { ...current, list: loaded } : current));
} catch {
setEquipmentModal(current => (current ? { ...current, list: [] } : current));
}
}
async function submitEquipment(event) {
event.preventDefault();
const modal = equipmentModal;
const name = modal.name.trim();
if (!name) {
setEquipmentModal({ ...modal, alert: { text: 'Введите название', isError: true } });
return;
}
try {
const created = await createEquipment(name);
setEquipmentModal(current => (
current ? { ...current, name: '', alert: { text: `Оборудование «${created.name}» добавлено`, isError: false } } : current
));
await reloadEquipmentModalList();
} catch (error) {
setEquipmentModal({
...modal,
alert: { text: error.message || 'Ошибка добавления', isError: true }
});
}
}
function confirmDeleteEquipment(equipment) {
setConfirmState({
title: 'Удаление оборудования',
message: 'Удалить оборудование из каталога?',
confirmLabel: 'Удалить',
danger: true,
action: async () => {
try {
await deleteEquipment(equipment.id);
await reloadEquipmentModalList();
} catch (error) {
setAlertState({
title: 'Ошибка удаления',
message: error.message || 'Не удалось удалить оборудование'
});
}
}
});
}
async function runConfirm() {
const confirm = confirmState;
setConfirmState(null);
await confirm.action();
}
return (
<>
<div className="card create-card">
<div className="card-toolbar">
<h2>Новая аудитория</h2>
<button type="button" className="btn btn-md btn-secondary" onClick={openEquipmentModal}>
Справочник оборудования
</button>
</div>
<form onSubmit={submitCreate}>
<div className="form-row">
<div className="form-group">
<label htmlFor="new-classroom-name">Номер / Название</label>
<input
type="text"
id="new-classroom-name"
placeholder="101 Ленинская"
required
value={createName}
onChange={event => setCreateName(event.target.value)}
/>
</div>
<div className="form-group">
<label htmlFor="new-classroom-capacity">Вместимость (чел.)</label>
<input
type="number"
id="new-classroom-capacity"
placeholder="30"
min="1"
required
value={createCapacity}
onChange={event => setCreateCapacity(event.target.value)}
/>
</div>
</div>
<div className="form-row form-row--spacing">
<div className="form-group form-group--wide">
<label>Оборудование</label>
<EquipmentMultiSelect
equipments={equipments}
selectedIds={createEquipmentIds}
onToggle={toggleCreateEquipment}
/>
</div>
<div className="form-group form-group--submit">
<button type="submit" className="btn btn-md btn-primary">Добавить</button>
</div>
</div>
<div
className={`form-alert${createAlert.text ? (createAlert.isError ? ' error' : ' success') : ''}`}
role="alert"
>
{createAlert.text}
</div>
</form>
</div>
<div className="card">
<h2>Список аудиторий</h2>
<div className="table-wrap">
<table id="classrooms-table">
<thead>
<tr>
<th>ID</th>
<th>Название</th>
<th>Вместимость</th>
<th>Оборудование</th>
<th>Статус</th>
<th className="th-actions">Действия</th>
</tr>
</thead>
<tbody>
{loadError ? (
<tr><td colSpan="6" className="loading-row">Ошибка загрузки данных</td></tr>
) : classrooms === null ? (
<tr><td colSpan="6" className="loading-row"><span className="state-spinner" aria-hidden="true" />Загрузка...</td></tr>
) : classrooms.length === 0 ? (
<tr><td colSpan="6" className="loading-row">Нет аудиторий</td></tr>
) : classrooms.map(classroom => (
<tr key={classroom.id}>
<td>{classroom.id}</td>
<td><strong>{classroom.name}</strong></td>
<td>{classroom.capacity} чел.</td>
<td><small>{equipmentNamesText(classroom) || '—'}</small></td>
<td>
<div className="status-cell">
<span className={`badge ${classroom.isAvailable ? 'badge-available' : 'badge-unavailable'}`}>
{classroomStatusLabel(classroom)}
</span>
<button
type="button"
className="btn btn-icon-sm btn-secondary btn-icon-toggle"
title="Сменить статус"
aria-label="Сменить статус"
onClick={() => void toggleAvailability(classroom)}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"></path></svg>
</button>
</div>
</td>
<td className="table-cell-actions">
<div className="table-actions">
<button
type="button"
className="btn btn-sm btn-secondary classroom-edit-action"
onClick={() => void openEditModal(classroom.id)}
>
Изменить
</button>
{classroom.status === 'ARCHIVED' ? (
<button
type="button"
className="btn btn-sm btn-secondary btn-restore-classroom"
onClick={() => void restore(classroom)}
>
Восстановить
</button>
) : (
<button
type="button"
className="btn btn-sm btn-danger-subtle btn-archive-classroom"
onClick={() => confirmArchive(classroom)}
>
Архивировать
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{editModal && (
<div
className="modal-overlay open"
onClick={event => {
if (event.target === event.currentTarget) setEditModal(null);
}}
>
<div className="modal-content card">
<h2>Редактировать аудиторию</h2>
<button
type="button"
className="btn btn-icon-md btn-ghost modal-close"
aria-label="Закрыть окно"
onClick={() => setEditModal(null)}
>
×
</button>
<form onSubmit={submitEdit}>
<input type="hidden" value={editModal.classroom.id} />
<div className="form-row form-row--spacing">
<div className="form-group">
<label htmlFor="edit-classroom-name">Номер / Название</label>
<input
type="text"
id="edit-classroom-name"
required
value={editModal.name}
onChange={event => setEditModal({ ...editModal, name: event.target.value })}
/>
</div>
<div className="form-group">
<label htmlFor="edit-classroom-capacity">Вместимость (чел.)</label>
<input
type="number"
id="edit-classroom-capacity"
min="1"
required
value={editModal.capacity}
onChange={event => setEditModal({ ...editModal, capacity: event.target.value })}
/>
</div>
</div>
<div className="form-row form-row--spacing">
<div className="form-group form-group--wide">
<label>Оборудование</label>
<EquipmentMultiSelect
equipments={equipments}
selectedIds={editModal.equipmentIds}
onToggle={toggleEditEquipment}
/>
</div>
<div className="form-group form-group--submit">
<button type="submit" className="btn btn-md btn-primary btn-block">Сохранить</button>
</div>
</div>
<div
className={`form-alert${editModal.alert.text ? (editModal.alert.isError ? ' error' : ' success') : ''}`}
role="alert"
>
{editModal.alert.text}
</div>
</form>
</div>
</div>
)}
{equipmentModal && (
<div
className="modal-overlay open"
onClick={event => {
if (event.target === event.currentTarget) setEquipmentModal(null);
}}
>
<div className="modal-content card modal-content--medium">
<div className="modal-header-row">
<h2>Справочник оборудования</h2>
<button
type="button"
className="btn btn-icon-md btn-ghost modal-close modal-close--static"
aria-label="Закрыть окно"
onClick={() => setEquipmentModal(null)}
>
×
</button>
</div>
<div className="card create-card modal-subcard">
<h3>Добавить оборудование</h3>
<form onSubmit={submitEquipment}>
<div className="form-row form-row--bottom">
<div className="form-group form-group--flush">
<label htmlFor="manage-equipment-name">Название оборудования</label>
<input
type="text"
id="manage-equipment-name"
placeholder="Проектор"
required
value={equipmentModal.name}
onChange={event => setEquipmentModal({ ...equipmentModal, name: event.target.value })}
/>
</div>
<button type="submit" className="btn btn-md btn-primary btn-inline">Добавить</button>
</div>
<div
className={`form-alert${equipmentModal.alert.text ? (equipmentModal.alert.isError ? ' error' : ' success') : ''}`}
role="alert"
>
{equipmentModal.alert.text}
</div>
</form>
</div>
<div className="table-wrap">
<table id="manage-equipments-table">
<thead>
<tr>
<th>ID</th>
<th>Название</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
{equipmentModal.list === null ? (
<tr><td colSpan="3" className="loading-row">Загрузка...</td></tr>
) : equipmentModal.list.length === 0 ? (
<tr><td colSpan="3" className="loading-row">Нет оборудования</td></tr>
) : equipmentModal.list.map(equipment => (
<tr key={equipment.id}>
<td>{equipment.id}</td>
<td>{equipment.name}</td>
<td>
<button
type="button"
className="btn btn-sm btn-danger btn-delete-equipment"
onClick={() => confirmDeleteEquipment(equipment)}
>
Удалить
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)}
{confirmState && (
<ConfirmDialog
title={confirmState.title}
message={confirmState.message}
confirmLabel={confirmState.confirmLabel}
danger={confirmState.danger}
onConfirm={() => void runConfirm()}
onClose={() => setConfirmState(null)}
/>
)}
{alertState && (
<AlertDialog
title={alertState.title}
message={alertState.message}
onClose={() => setAlertState(null)}
/>
)}
</>
);
}
function EquipmentMultiSelect({ equipments, selectedIds, onToggle }) {
const rootRef = useRef(null);
const [open, setOpen] = useState(false);
useEffect(() => {
if (!open) return undefined;
function handlePointerDown(event) {
if (rootRef.current && !rootRef.current.contains(event.target)) {
setOpen(false);
}
}
document.addEventListener('mousedown', handlePointerDown);
return () => document.removeEventListener('mousedown', handlePointerDown);
}, [open]);
const selection = equipmentIdSelection(equipments, [...selectedIds]);
const selectedNames = selection.filter(item => item.checked).map(item => item.name);
return (
<div className="custom-multi-select" ref={rootRef}>
<button
type="button"
className={`select-box${open ? ' active' : ''}`}
aria-expanded={open}
onClick={() => setOpen(current => !current)}
>
<span className="select-text">{equipmentSelectLabel(selectedNames)}</span>
<svg className="dropdown-icon" width="12" height="8" viewBox="0 0 12 8" fill="none" aria-hidden="true">
<path d="M1 1.5L6 6.5L11 1.5" stroke="#9ca3af" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
<div className={`dropdown-menu${open ? ' open' : ''}`}>
<div className="checkbox-group-vertical">
{equipments.length === 0 ? (
<p className="text-muted"><small>Оборудование отсутствует</small></p>
) : selection.map(item => (
<label className="checkbox-item" key={item.id}>
<input
type="checkbox"
value={item.id}
checked={item.checked}
onChange={() => onToggle(String(item.id))}
/>
<span className="checkmark"></span>
<span>{item.name}</span>
</label>
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,354 @@
import { useEffect, useState } from 'react';
import {
CONFLICT_CHECK_STATE,
classroomOccupancyLabel,
conflictCheckPresentation,
currentSlotLabel,
currentWeekDateRange,
detectScheduleConflicts,
fetchDashboardReferenceData,
fetchEffectiveTimeSlots,
fetchTodayLessons,
findCurrentSlot,
formatLocalDate,
freeClassroomsForSlot,
lessonsForSlot,
loadWeekDepartmentSchedules,
sortTimeSlots,
totalStudents
} from '../dashboard-service.js';
const REALTIME_UPDATE_MS = 30_000;
export function DashboardTab() {
const [reference, setReference] = useState(null);
const [todayLessons, setTodayLessons] = useState([]);
const [timeSlots, setTimeSlots] = useState([]);
const [dataPhase, setDataPhase] = useState('loading');
const [conflictPhase, setConflictPhase] = useState('checking');
const [conflicts, setConflicts] = useState({ items: [], presentation: null });
const [recheckBusy, setRecheckBusy] = useState(false);
const [now, setNow] = useState(() => new Date());
useEffect(() => {
void loadData();
}, []);
useEffect(() => {
const timer = setInterval(() => setNow(new Date()), REALTIME_UPDATE_MS);
return () => clearInterval(timer);
}, []);
async function loadData() {
setDataPhase('loading');
setConflictPhase('checking');
try {
const loadedReference = await fetchDashboardReferenceData();
const todayStr = formatLocalDate(new Date());
const [lessons, slots] = await Promise.all([
fetchTodayLessons(loadedReference.departments, todayStr),
fetchEffectiveTimeSlots(todayStr)
]);
setReference(loadedReference);
setTodayLessons(lessons);
setTimeSlots(sortTimeSlots(slots));
setDataPhase('ready');
await checkConflicts(loadedReference);
} catch {
setDataPhase('error');
setConflictPhase('error');
console.error('Не удалось загрузить данные панели');
}
}
async function checkConflicts(referenceData) {
setConflictPhase('checking');
try {
const range = currentWeekDateRange(new Date());
const scheduleLoad = await loadWeekDepartmentSchedules(referenceData.departments, range);
const items = detectScheduleConflicts(scheduleLoad.lessons, referenceData.groups, referenceData.classrooms);
const presentation = scheduleLoad.state === CONFLICT_CHECK_STATE.NOT_RUN
? conflictCheckPresentation(scheduleLoad, 0, range)
: conflictCheckPresentation(scheduleLoad, items.length, range);
setConflicts({ items, presentation });
setConflictPhase('ready');
} catch {
setConflictPhase('error');
console.error('Не удалось выполнить проверку конфликтов');
}
}
async function recheck() {
setRecheckBusy(true);
try {
if (dataPhase === 'ready' && reference) {
await checkConflicts(reference);
} else {
await loadData();
}
} finally {
setRecheckBusy(false);
}
}
const ready = dataPhase === 'ready' && reference !== null;
const currentSlot = ready ? findCurrentSlot(timeSlots, now) : null;
const currentLessons = currentSlot ? lessonsForSlot(todayLessons, currentSlot.orderNumber) : [];
const freeRooms = ready ? freeClassroomsForSlot(reference.classrooms, todayLessons, currentSlot ? currentSlot.orderNumber : null) : [];
return (
<div className="dashboard-container">
<div className="dashboard-metrics-grid">
<MetricCard
tone="groups"
label="Активные группы"
value={ready ? String(reference.groups.length) : '—'}
>
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</MetricCard>
<MetricCard
tone="students"
label="Всего студентов"
value={ready ? String(totalStudents(reference.groups)) : '—'}
>
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M22 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</MetricCard>
<MetricCard
tone="teachers"
label="Преподаватели"
value={ready ? String(reference.teachers.length) : '—'}
>
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</MetricCard>
<MetricCard
tone="classrooms"
label="Загрузка аудиторий сегодня"
value={ready ? classroomOccupancyLabel(reference.classrooms, todayLessons) : '—'}
>
<rect width="18" height="18" x="3" y="3" rx="2" />
<path d="M9 3v18M15 3v18M3 9h18M3 15h18" />
</MetricCard>
</div>
<div className="dashboard-middle">
<div className="card dashboard-monitor-card">
<div className="dashboard-card-header">
<h2>Мониторинг пар (Идет сейчас)</h2>
<div className="dashboard-time-slot">
{ready ? currentSlotLabel(currentSlot) : '—'}
</div>
</div>
<div className="table-wrap dashboard-monitor-table">
<table>
<thead>
<tr>
<th>Аудитория</th>
<th>Дисциплина</th>
<th>Преподаватель</th>
<th>Группа</th>
</tr>
</thead>
<tbody>
{!ready ? (
<tr><td colSpan="4" className="loading-row dashboard-state-row dashboard-state-row--wide">Определение текущего времени...</td></tr>
) : !currentSlot ? (
<tr><td colSpan="4" className="loading-row dashboard-state-row">В данный момент учебные пары не проводятся</td></tr>
) : currentLessons.length === 0 ? (
<tr><td colSpan="4" className="loading-row dashboard-state-row">В текущий слот занятий не запланировано</td></tr>
) : currentLessons.map(lesson => (
<tr key={`${lesson.classroomId}-${lesson.subjectId}-${lesson.teacherId}-${lesson.groupNames.join(',')}`}>
<td><strong>{lesson.classroomName}</strong></td>
<td>
{lesson.subjectName}{' '}
<span className="badge badge-ef badge--lesson-type">{lesson.lessonTypeName}</span>
</td>
<td>{lesson.teacherName}</td>
<td><span className="badge badge-available">{lesson.groupNames.join(', ')}</span></td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div className="dashboard-side-stack">
<div className="card dashboard-compact-card">
<h3 className="dashboard-card-title">Свободные аудитории (ближайшие)</h3>
<div className="dashboard-badge-list" tabIndex="0" role="region" aria-label="Свободные аудитории">
{!ready ? (
<div className="dashboard-free-hint">Загрузка...</div>
) : freeRooms.length === 0 ? (
<span className="dashboard-free-busy">Все аудитории заняты</span>
) : freeRooms.map(classroom => (
<span className="badge badge-available dashboard-free-badge" key={classroom.id}>
{classroom.name} ({classroom.capacity} м.)
</span>
))}
</div>
</div>
<div className="card dashboard-compact-card">
<h3 className="dashboard-card-title">Быстрый переход</h3>
<div className="dashboard-actions">
<a href="#schedule" className="btn btn-md btn-primary dashboard-action-link">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
<line x1="16" y1="2" x2="16" y2="6"></line>
<line x1="8" y1="2" x2="8" y2="6"></line>
<line x1="3" y1="10" x2="21" y2="10"></line>
</svg>
Конструктор расписания
</a>
<a href="#schedule-view" className="btn btn-md btn-secondary dashboard-action-link">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z" />
<circle cx="12" cy="12" r="3" />
</svg>
Точечные изменения / Просмотр
</a>
</div>
</div>
</div>
</div>
<div className="card red-zone-card">
<div className="red-zone-header">
<div>
<h2 className="red-zone-title">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ef4444" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polygon points="7.86 2 16.14 2 22 7.86 22 16.14 16.14 22 7.86 22 2 16.14 2 7.86 7.86 2" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
Центр предупреждений (Red Zone)
</h2>
<div className="red-zone-subtitle">Анализ накладок и коллизий расписания на текущую неделю</div>
</div>
<button
type="button"
className="btn btn-sm btn-secondary"
id="btn-recheck-conflicts"
disabled={recheckBusy}
aria-busy={recheckBusy ? 'true' : undefined}
onClick={() => void recheck()}
>
{recheckBusy ? 'Проверка…' : 'Перепроверить'}
</button>
</div>
<div
className="red-zone-conflicts"
role="status"
aria-live="polite"
aria-atomic="true"
aria-busy={conflictPhase === 'checking' ? 'true' : undefined}
>
{conflictPhase === 'checking' ? (
<div className="dashboard-check-loading">Анализ расписания и выявление конфликтов...</div>
) : conflictPhase === 'error' ? (
<CheckStateCard
tone="error"
title="Проверка не выполнена"
description={dataPhase === 'error'
? 'Не удалось загрузить данные панели. Нажмите «Перепроверить», чтобы повторить попытку.'
: 'Во время анализа произошла ошибка. Повторите проверку позже.'}
/>
) : (
<>
{conflicts.presentation && (
<CheckStateCard
tone={conflicts.presentation.tone}
title={conflicts.presentation.title}
description={conflicts.presentation.description}
meta={conflicts.presentation.meta}
/>
)}
{conflicts.items.map((conflict, index) => (
<ConflictCard conflict={conflict} key={`${conflict.type}-${index}`} />
))}
</>
)}
</div>
</div>
</div>
);
}
function MetricCard({ tone, label, value, children }) {
return (
<div className={`card dashboard-metric-card dashboard-metric-card--${tone}`}>
<div className="metric-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
{children}
</svg>
</div>
<div>
<div className="dashboard-metric-label">{label}</div>
<h3 className="dashboard-metric-value">{value}</h3>
</div>
</div>
);
}
function CheckStateCard({ tone, title, description, meta = '' }) {
const icon = tone === 'success'
? <path d="M20 6 9 17l-5-5" />
: tone === 'warning'
? (
<>
<path d="M12 9v4m0 4h.01" />
<path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0Z" />
</>
)
: (
<>
<circle cx="12" cy="12" r="9" />
<path d="M12 8v5m0 3h.01" />
</>
);
return (
<section className={`dashboard-check-state dashboard-check-state--${tone}`}>
<span className="dashboard-check-state__icon" aria-hidden="true">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round">{icon}</svg>
</span>
<span className="dashboard-check-state__body">
<strong>{title}</strong>
<span>{description}</span>
{meta ? <small>{meta}</small> : null}
</span>
</section>
);
}
function ConflictCard({ conflict }) {
const isError = conflict.severity === 'error';
return (
<div className={`dashboard-conflict-card dashboard-conflict-card--${isError ? 'error' : 'warning'}`}>
<div className="dashboard-conflict-card__icon">
{isError ? (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#ef4444" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10" /><line x1="15" y1="9" x2="9" y2="15" /><line x1="9" y1="9" x2="15" y2="15" /></svg>
) : (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#f59e0b" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" /><line x1="12" y1="9" x2="12" y2="13" /><line x1="12" y1="17" x2="12.01" y2="17" /></svg>
)}
</div>
<div className="dashboard-conflict-card__body">
<h4>{conflict.title}</h4>
<p>
{(conflict.segments || []).map((segment, index) => (
typeof segment === 'string'
? segment
: <strong key={index}>{segment.strong}</strong>
))}
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,774 @@
import { useEffect, useRef, useState } from 'react';
import { getSession } from '../../../admin/js/api.js';
import { addDays, toIsoDate } from '../../shared/dates.js';
import { requestCountLabel } from '../admin-service.js';
import {
DEPARTMENT_REQUEST_TABLE_COLUMNS,
SUBJECT_TABLE_COLUMNS,
assignTeacher,
assignTeacherError,
availableTeachers,
defaultWorkloadPeriod,
departmentOptionLabel,
fetchAllTeachers,
fetchDepartmentSubjects,
fetchDepartmentTeacherRequests,
fetchDepartmentTeachers,
fetchDepartments,
fetchSemesters,
fetchSubjectComments,
fetchTeacherWorkload,
formatDateTime,
importSubjects,
importSubjectsPayload,
requestStatusClass,
requestStatusLabel,
saveSubjectComment,
subjectCountLabel,
submitTeacherRequest,
teacherMetrics,
teacherOptionLabel,
teacherRequestPayload,
workloadLabel
} from '../department-workspace-service.js';
const ROLE_DEPARTMENT = 'DEPARTMENT';
export function DepartmentWorkspaceTab() {
const [session] = useState(() => getSession());
const role = session?.role;
const userDepartmentId = session?.departmentId;
const isDepartmentRole = role === ROLE_DEPARTMENT;
const departmentIdRef = useRef('');
const startDateRef = useRef(toIsoDate(new Date()));
const endDateRef = useRef(toIsoDate(addDays(new Date(), 7)));
const [departments, setDepartments] = useState([]);
const [departmentId, setDepartmentId] = useState('');
const [startDate, setStartDate] = useState(startDateRef.current);
const [endDate, setEndDate] = useState(endDateRef.current);
const [workspaceAlert, setWorkspaceAlert] = useState({ text: '', isError: false });
const [subjects, setSubjects] = useState({ loading: true, items: [], error: '' });
const [teachers, setTeachers] = useState({ items: null, error: '' });
const [workload, setWorkload] = useState({ items: null, error: '' });
const [requests, setRequests] = useState({ items: null, error: '' });
const [allTeachers, setAllTeachers] = useState([]);
const [allTeachersError, setAllTeachersError] = useState('');
const [importRows, setImportRows] = useState([{ code: '', name: '' }]);
const [importAlert, setImportAlert] = useState({ text: '', isError: false });
const [assignTeacherId, setAssignTeacherId] = useState('');
const [assignComment, setAssignComment] = useState('');
const [assignAlert, setAssignAlert] = useState({ text: '', isError: false });
const [requestForm, setRequestForm] = useState({ username: '', fullName: '', jobTitle: '', comment: '' });
const [requestAlert, setRequestAlert] = useState({ text: '', isError: false });
const [comments, setComments] = useState(null);
const [commentText, setCommentText] = useState('');
const [commentAlert, setCommentAlert] = useState({ text: '', isError: false });
function currentDepartmentId() {
return isDepartmentRole ? userDepartmentId : departmentIdRef.current;
}
useEffect(() => {
let active = true;
(async () => {
try {
const loaded = await fetchDepartments();
if (!active) return;
const list = loaded || [];
setDepartments(list);
if (isDepartmentRole) {
departmentIdRef.current = userDepartmentId || '';
setDepartmentId(departmentIdRef.current);
} else if (list.length) {
departmentIdRef.current = String(list[0].id);
setDepartmentId(departmentIdRef.current);
}
try {
const semesters = await fetchSemesters();
const period = defaultWorkloadPeriod(semesters, new Date());
startDateRef.current = period.startDate;
endDateRef.current = period.endDate;
if (active) {
setStartDate(period.startDate);
setEndDate(period.endDate);
}
} catch {
// Текущий диапазон остаётся безопасным fallback, если справочник
// семестров недоступен.
}
try {
const loadedTeachers = await fetchAllTeachers();
if (active) setAllTeachers(Array.isArray(loadedTeachers) ? loadedTeachers : []);
} catch (error) {
if (active) setAllTeachersError(error.message || 'Ошибка загрузки преподавателей');
}
if (!active) return;
await loadWorkspace();
} catch (error) {
if (!active) return;
setWorkspaceAlert({ text: error.message || 'Ошибка загрузки кабинета кафедры', isError: true });
}
})();
return () => {
active = false;
};
}, []);
async function loadWorkspace() {
setWorkspaceAlert({ text: '', isError: false });
setTeachers({ items: null, error: '' });
setWorkload({ items: null, error: '' });
setRequests({ items: null, error: '' });
await Promise.all([loadSubjects(), loadTeachers(), loadWorkload(), loadTeacherRequests()]);
}
async function loadSubjects() {
setSubjects({ loading: true, items: [], error: '' });
try {
const items = await fetchDepartmentSubjects(currentDepartmentId());
setSubjects({ loading: false, items: Array.isArray(items) ? items : [], error: '' });
} catch (error) {
setSubjects({ loading: false, items: [], error: error.message || 'Ошибка загрузки дисциплин' });
}
}
async function loadTeachers() {
setTeachers({ items: null, error: '' });
try {
const items = await fetchDepartmentTeachers(currentDepartmentId());
setTeachers({ items: Array.isArray(items) ? items : [], error: '' });
} catch (error) {
setTeachers({ items: [], error: error.message || 'Ошибка загрузки преподавателей' });
}
}
async function loadWorkload() {
if (!startDateRef.current || !endDateRef.current) {
setWorkload({ items: [], error: 'Выберите начало и окончание периода' });
return;
}
setWorkload({ items: null, error: '' });
try {
const items = await fetchTeacherWorkload({
startDate: startDateRef.current,
endDate: endDateRef.current,
departmentId: currentDepartmentId()
});
setWorkload({ items: Array.isArray(items) ? items : [], error: '' });
} catch (error) {
setWorkload({ items: [], error: error.message || 'Ошибка расчёта нагрузки' });
}
}
async function loadTeacherRequests() {
setRequests({ items: null, error: '' });
try {
const items = await fetchDepartmentTeacherRequests(currentDepartmentId());
setRequests({ items: Array.isArray(items) ? items : [], error: '' });
} catch (error) {
setRequests({ items: [], error: error.message || 'Ошибка загрузки заявок' });
}
}
function handleDepartmentChange(value) {
departmentIdRef.current = value;
setDepartmentId(value);
void loadWorkspace();
}
function handleStartDateChange(value) {
startDateRef.current = value;
setStartDate(value);
if (value && endDateRef.current && value > endDateRef.current) {
endDateRef.current = '';
setEndDate('');
}
void loadWorkload();
}
function handleEndDateChange(value) {
endDateRef.current = value;
setEndDate(value);
void loadWorkload();
}
// --- Загрузка дисциплин ---
function addImportRow() {
setImportRows(current => [...current, { code: '', name: '' }]);
}
function updateImportRow(index, patch) {
setImportRows(current => current.map((row, rowIndex) => (
rowIndex === index ? { ...row, ...patch } : row
)));
}
function removeImportRow(index) {
setImportRows(current => {
const rows = current.filter((_, rowIndex) => rowIndex !== index);
return rows.length ? rows : [{ code: '', name: '' }];
});
}
async function submitImport(event) {
event.preventDefault();
setImportAlert({ text: '', isError: false });
const rows = importRows.map(row => ({
code: (row.code || '').trim(),
name: (row.name || '').trim()
}));
const { payload, error } = importSubjectsPayload(rows, currentDepartmentId());
if (error) {
setImportAlert({ text: error, isError: true });
return;
}
try {
const result = await importSubjects(payload);
setImportAlert({ text: result?.message || 'Дисциплины загружены', isError: false });
setImportRows([{ code: '', name: '' }]);
await loadSubjects();
} catch (err) {
setImportAlert({ text: err.message || 'Ошибка загрузки дисциплин', isError: true });
}
}
// --- Привязка преподавателя ---
async function submitAssign(event) {
event.preventDefault();
setAssignAlert({ text: '', isError: false });
const error = assignTeacherError(currentDepartmentId(), assignTeacherId);
if (error) {
setAssignAlert({ text: error, isError: true });
return;
}
try {
await assignTeacher(assignTeacherId, currentDepartmentId(), assignComment.trim());
setAssignAlert({ text: 'Преподаватель привязан к кафедре', isError: false });
setAssignComment('');
await Promise.all([loadTeachers(), loadWorkload()]);
} catch (err) {
setAssignAlert({ text: err.message || 'Ошибка привязки преподавателя', isError: true });
}
}
// --- Заявка на преподавателя ---
async function submitRequest(event) {
event.preventDefault();
setRequestAlert({ text: '', isError: false });
const { payload, error } = teacherRequestPayload(requestForm, currentDepartmentId());
if (error) {
setRequestAlert({ text: error, isError: true });
return;
}
try {
await submitTeacherRequest(payload);
setRequestAlert({ text: 'Заявка отправлена администратору', isError: false });
setRequestForm({ username: '', fullName: '', jobTitle: '', comment: '' });
await loadTeacherRequests();
} catch (err) {
setRequestAlert({ text: err.message || 'Ошибка отправки заявки', isError: true });
}
}
// --- Комментарии к дисциплинам ---
async function openComments(subjectId, subjectName) {
setComments({ subjectId, subjectName, items: null, error: '' });
setCommentText('');
setCommentAlert({ text: '', isError: false });
try {
const items = await fetchSubjectComments(subjectId);
setComments(current => (
current && current.subjectId === subjectId
? { ...current, items: Array.isArray(items) ? items : [], error: '' }
: current
));
} catch (error) {
setComments(current => (
current && current.subjectId === subjectId
? { ...current, items: [], error: error.message || 'Ошибка загрузки комментариев' }
: current
));
}
}
async function submitComment(event) {
event.preventDefault();
const subjectId = comments?.subjectId;
const comment = commentText.trim();
if (!subjectId) {
setCommentAlert({ text: 'Выберите дисциплину', isError: true });
return;
}
if (!comment) {
setCommentAlert({ text: 'Комментарий не должен быть пустым', isError: true });
return;
}
try {
await saveSubjectComment(subjectId, comment);
setCommentAlert({ text: 'Комментарий сохранён', isError: false });
await openComments(subjectId, comments.subjectName);
} catch (error) {
setCommentAlert({ text: error.message || 'Ошибка сохранения комментария', isError: true });
}
}
const metricRows = teachers.items === null || workload.items === null
? null
: teacherMetrics(teachers.items, workload.items);
const freeTeachers = teachers.items === null
? null
: availableTeachers(allTeachers, teachers.items);
const requestsCountLabel = requests.error
? '0 заявок'
: (requests.items === null ? 'Загрузка...' : requestCountLabel(requests.items.length));
const subjectsCountLabel = subjects.error
? '0 дисциплин'
: (subjects.loading ? 'Загрузка...' : subjectCountLabel(subjects.items.length));
return (
<>
<div className="card department-workspace-control-card">
<div className="card-header-row">
<h2>Рабочая область кафедры</h2>
<button
type="button"
className="btn btn-md btn-secondary"
id="department-workspace-refresh"
onClick={() => void loadWorkspace()}
>
Обновить
</button>
</div>
<div className="department-workspace-filter-grid">
{!isDepartmentRole && (
<div className="form-group" id="department-workspace-department-field">
<label htmlFor="department-workspace-department">Кафедра</label>
<select
id="department-workspace-department"
value={departmentId}
onChange={event => handleDepartmentChange(event.target.value)}
>
<option value="">Выберите кафедру</option>
{departments.map(department => (
<option key={department.id} value={department.id}>
{departmentOptionLabel(department)}
</option>
))}
</select>
</div>
)}
<div className="form-group">
<label htmlFor="department-workspace-start">Начало периода</label>
<input
type="date"
id="department-workspace-start"
value={startDate}
onChange={event => handleStartDateChange(event.target.value)}
/>
</div>
<div className="form-group">
<label htmlFor="department-workspace-end">Окончание периода</label>
<input
type="date"
id="department-workspace-end"
value={endDate}
onChange={event => handleEndDateChange(event.target.value)}
/>
</div>
</div>
<div
className={`form-alert${workspaceAlert.text ? (workspaceAlert.isError ? ' error' : ' success') : ''}`}
id="department-workspace-alert"
role="alert"
>
{workspaceAlert.text}
</div>
</div>
<div className="department-workspace-grid department-workspace-grid-wide department-workspace-actions-grid">
<div className="card create-card department-subject-import-card">
<div className="card-header-row">
<h2>Загрузка дисциплин</h2>
</div>
<form id="department-subject-import-form" className="stack-form" onSubmit={submitImport}>
<div className="subject-import-slots-panel">
<div className="card-header-row">
<label className="schedule-section-label schedule-section-label--flush">
Дисциплины для загрузки
</label>
<button
type="button"
className="btn btn-sm btn-secondary"
id="subject-import-add"
onClick={addImportRow}
>
Добавить строку
</button>
</div>
<div id="subject-import-rows" className="subject-import-rows-list">
{importRows.map((row, index) => (
<div className="subject-import-row" key={index}>
<div className="form-group">
<label>Код</label>
<input
type="text"
className="subject-import-code"
placeholder="DB201"
value={row.code}
onChange={event => updateImportRow(index, { code: event.target.value })}
/>
</div>
<div className="form-group">
<label>Название</label>
<input
type="text"
className="subject-import-name"
placeholder="Базы данных"
value={row.name}
onChange={event => updateImportRow(index, { name: event.target.value })}
/>
</div>
<button
type="button"
className="btn btn-sm btn-danger subject-import-remove"
onClick={() => removeImportRow(index)}
>
Удалить
</button>
</div>
))}
</div>
</div>
<button type="submit" className="btn btn-md btn-secondary">Загрузить</button>
<div
className={`form-alert${importAlert.text ? (importAlert.isError ? ' error' : ' success') : ''}`}
id="department-subject-import-alert"
role="alert"
>
{importAlert.text}
</div>
</form>
</div>
<div className="card create-card department-teacher-assign-card">
<div className="card-header-row">
<h2>Добавить преподавателя</h2>
</div>
<form id="department-teacher-assign-form" className="stack-form" onSubmit={submitAssign}>
<div className="form-group">
<label htmlFor="department-teacher-select">Преподаватель</label>
<select
id="department-teacher-select"
value={assignTeacherId}
onChange={event => setAssignTeacherId(event.target.value)}
>
{freeTeachers === null ? (
<option value="">Загрузка...</option>
) : allTeachersError ? (
<option value="">{allTeachersError}</option>
) : freeTeachers.length === 0 ? (
<option value="">Нет доступных преподавателей</option>
) : (
<>
<option value="">Выберите преподавателя</option>
{freeTeachers.map(teacher => (
<option key={teacher.id} value={teacher.id}>
{teacherOptionLabel(teacher)}
</option>
))}
</>
)}
</select>
</div>
<div className="form-group">
<label htmlFor="department-teacher-assign-comment">Комментарий</label>
<input
type="text"
id="department-teacher-assign-comment"
placeholder="Например, ведет лабораторные работы"
value={assignComment}
onChange={event => setAssignComment(event.target.value)}
/>
</div>
<button type="submit" className="btn btn-md btn-secondary">Привязать к кафедре</button>
<div
className={`form-alert${assignAlert.text ? (assignAlert.isError ? ' error' : ' success') : ''}`}
id="department-teacher-assign-alert"
role="alert"
>
{assignAlert.text}
</div>
</form>
</div>
<div className="card create-card department-teacher-request-card">
<div className="card-header-row">
<h2>Заявка на преподавателя</h2>
</div>
<form id="department-teacher-request-form" className="stack-form" onSubmit={submitRequest}>
<div className="form-group">
<label htmlFor="teacher-request-username">Логин</label>
<input
type="text"
id="teacher-request-username"
placeholder="ivanov_teacher"
maxLength={50}
required
value={requestForm.username}
onChange={event => setRequestForm(current => (
{ ...current, username: event.target.value }
))}
/>
</div>
<div className="form-group">
<label htmlFor="teacher-request-fullname">ФИО</label>
<input
type="text"
id="teacher-request-fullname"
placeholder="Иванов Иван Иванович"
maxLength={255}
required
value={requestForm.fullName}
onChange={event => setRequestForm(current => (
{ ...current, fullName: event.target.value }
))}
/>
</div>
<div className="form-group">
<label htmlFor="teacher-request-jobtitle">Должность</label>
<input
type="text"
id="teacher-request-jobtitle"
placeholder="Доцент"
maxLength={255}
value={requestForm.jobTitle}
onChange={event => setRequestForm(current => (
{ ...current, jobTitle: event.target.value }
))}
/>
</div>
<div className="form-group">
<label htmlFor="teacher-request-comment">Комментарий</label>
<input
type="text"
id="teacher-request-comment"
placeholder="Что важно проверить администратору"
value={requestForm.comment}
onChange={event => setRequestForm(current => (
{ ...current, comment: event.target.value }
))}
/>
</div>
<button type="submit" className="btn btn-md btn-primary">Отправить заявку</button>
<div
className={`form-alert${requestAlert.text ? (requestAlert.isError ? ' error' : ' success') : ''}`}
id="department-teacher-request-alert"
role="alert"
>
{requestAlert.text}
</div>
</form>
</div>
<div className="card department-teachers-card">
<div className="card-header-row">
<h2>Преподаватели и нагрузка</h2>
</div>
<div className="metric-grid" id="department-teachers-list">
{metricRows === null ? (
'Загрузка...'
) : metricRows.length === 0 ? (
<div className="loading-row">
{teachers.error || 'Преподаватели не найдены'}
</div>
) : metricRows.map((row, index) => (
<article className="metric-card" key={index}>
<strong>{row.name || '-'}</strong>
<span>{row.meta || 'Должность не указана'}</span>
<small>{workloadLabel(row.workload, workload.error)}</small>
</article>
))}
</div>
</div>
</div>
<div className="card department-workspace-table-card">
<div className="card-header-row">
<h2>Заявки кафедры</h2>
<span className="muted" id="department-teacher-request-count">{requestsCountLabel}</span>
</div>
<div className="table-wrap">
<table>
<thead>
<tr>
<th>Статус</th>
<th>Логин</th>
<th>ФИО</th>
<th>Должность</th>
<th>Комментарий</th>
<th>Рассмотрение</th>
</tr>
</thead>
<tbody id="department-teacher-requests-tbody">
{requests.items === null && !requests.error ? (
<tr>
<td colSpan={DEPARTMENT_REQUEST_TABLE_COLUMNS} className="loading-row">
Загрузка...
</td>
</tr>
) : requests.error ? (
<tr>
<td colSpan={DEPARTMENT_REQUEST_TABLE_COLUMNS} className="loading-row">
{requests.error}
</td>
</tr>
) : requests.items.length === 0 ? (
<tr>
<td colSpan={DEPARTMENT_REQUEST_TABLE_COLUMNS} className="loading-row">
Заявок пока нет
</td>
</tr>
) : requests.items.map(request => (
<tr key={request.id}>
<td>
<span className={`badge ${requestStatusClass(request.status)}`}>
{requestStatusLabel(request.status)}
</span>
</td>
<td>{request.username || '-'}</td>
<td>{request.fullName || '-'}</td>
<td>{request.jobTitle || '-'}</td>
<td>{request.comment || '-'}</td>
<td>{request.reviewComment || formatDateTime(request.reviewedAt) || '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div className="card department-workspace-table-card">
<div className="card-header-row">
<h2>Дисциплины кафедры</h2>
<span className="muted" id="department-subject-count">{subjectsCountLabel}</span>
</div>
<div className="table-wrap">
<table>
<thead>
<tr>
<th>Код</th>
<th>Название</th>
<th>Описание</th>
<th>Комментарии</th>
</tr>
</thead>
<tbody id="department-subjects-tbody">
{subjects.loading ? (
<tr>
<td colSpan={SUBJECT_TABLE_COLUMNS} className="loading-row">
Загрузка...
</td>
</tr>
) : subjects.error ? (
<tr>
<td colSpan={SUBJECT_TABLE_COLUMNS} className="loading-row">
{subjects.error}
</td>
</tr>
) : subjects.items.length === 0 ? (
<tr>
<td colSpan={SUBJECT_TABLE_COLUMNS} className="loading-row">
Дисциплины не найдены
</td>
</tr>
) : subjects.items.map(subject => (
<tr key={subject.id}>
<td>{subject.code || '-'}</td>
<td>{subject.name || '-'}</td>
<td>{subject.description || '-'}</td>
<td>
<button
type="button"
className="btn btn-sm btn-secondary department-comments-open"
onClick={() => void openComments(subject.id, subject.name || '')}
>
Открыть
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{comments && (
<div className="card department-workspace-table-card" id="department-comments-card">
<div className="card-header-row">
<h2 id="department-comments-title">
Комментарии: {comments.subjectName || 'дисциплина'}
</h2>
<button
type="button"
className="btn btn-sm btn-ghost"
id="department-comments-close"
onClick={() => setComments(null)}
>
Скрыть
</button>
</div>
<form id="department-comment-form" className="form-row" onSubmit={submitComment}>
<div className="form-group">
<label htmlFor="department-comment-text">Новый комментарий</label>
<input
type="text"
id="department-comment-text"
placeholder="Добавьте примечание по дисциплине"
value={commentText}
onChange={event => setCommentText(event.target.value)}
/>
</div>
<button type="submit" className="btn btn-md btn-primary">Сохранить</button>
</form>
<div
className={`form-alert${commentAlert.text ? (commentAlert.isError ? ' error' : ' success') : ''}`}
id="department-comment-alert"
role="alert"
>
{commentAlert.text}
</div>
<div className="department-comment-list" id="department-comment-list">
{comments.items === null ? (
'Загрузка...'
) : comments.error ? (
<div className="loading-row">{comments.error}</div>
) : comments.items.length === 0 ? (
<div className="loading-row">Комментариев пока нет</div>
) : comments.items.map((comment, index) => (
<article className="department-comment-item" key={index}>
<strong>{comment.authorName || 'Автор не указан'}</strong>
<span>{formatDateTime(comment.createdAt)}</span>
<p>{comment.comment}</p>
</article>
))}
</div>
</div>
)}
</>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,468 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import {
academicYearsFromSemesters,
resolveAcademicPeriod,
semestersForAcademicYear
} from '../../../admin/js/academic-period.js';
import { trackUxEvent } from '../../../telemetry.js';
import {
QUALITY_SEMESTER_STORAGE_KEY,
QUALITY_VERSION_STORAGE_KEY,
QUALITY_YEAR_STORAGE_KEY,
SCHEDULE_OPEN_RULE_STORAGE_KEY,
SCHEDULE_OPEN_VERSION_STORAGE_KEY,
SCHEDULE_SEMESTER_STORAGE_KEY
} from '../schedule-versions-service.js';
import {
fetchScheduleQualityAnalysis,
fetchScheduleSemesters,
fetchScheduleVersions,
filterQualityProblems,
formatLocalDate,
formatMetricValue,
formatQualityDate,
lessonWord,
problemTotalLabel,
qualityBand,
qualitySemesterLabel,
severityLabel,
versionStatusLabel
} from '../schedule-quality-service.js';
const INITIAL_STATUS = Object.freeze({ message: 'Выберите семестр, чтобы получить объяснимую оценку.', tone: '' });
export function ScheduleQualityTab() {
const [semesters, setSemesters] = useState([]);
const [semestersPhase, setSemestersPhase] = useState('loading');
const [academicYearId, setAcademicYearId] = useState('');
const [semesterId, setSemesterId] = useState('');
const [versions, setVersions] = useState([]);
const [versionPhase, setVersionPhase] = useState('idle');
const [versionId, setVersionId] = useState('');
const [analysis, setAnalysis] = useState(null);
const [resultsHidden, setResultsHidden] = useState(true);
const [analyzing, setAnalyzing] = useState(false);
const [status, setStatus] = useState(INITIAL_STATUS);
const [severityFilter, setSeverityFilter] = useState('');
const [categoryFilter, setCategoryFilter] = useState('');
const [selectedProblemId, setSelectedProblemId] = useState(null);
const latestRef = useRef(null);
latestRef.current = { academicYearId, semesterId, versionId };
const years = useMemo(() => academicYearsFromSemesters(semesters), [semesters]);
const yearSemesters = useMemo(
() => semestersForAcademicYear(semesters, academicYearId),
[semesters, academicYearId]
);
const visibleProblems = useMemo(
() => analysis ? filterQualityProblems(analysis.problems || [], severityFilter, categoryFilter) : [],
[analysis, severityFilter, categoryFilter]
);
const band = analysis ? qualityBand(analysis.score) : null;
const selectedProblem = analysis
? (analysis.problems || []).find(problem => problem.id === selectedProblemId)
: null;
useEffect(() => {
async function initialize() {
try {
const loadedSemesters = await fetchScheduleSemesters();
setSemesters(loadedSemesters);
if (!loadedSemesters.length) {
setSemestersPhase('empty');
setStatus({ message: 'Сначала создайте учебный год и семестр.', tone: 'warning' });
return;
}
setSemestersPhase('ready');
const selected = resolveAcademicPeriod(loadedSemesters, {
academicYearId: window.localStorage.getItem(QUALITY_YEAR_STORAGE_KEY),
semesterId: window.localStorage.getItem(QUALITY_SEMESTER_STORAGE_KEY),
date: formatLocalDate(new Date())
});
setAcademicYearId(selected.academicYearId);
setSemesterId(selected.semesterId);
window.localStorage.setItem(QUALITY_YEAR_STORAGE_KEY, selected.academicYearId);
await loadVersions(selected.semesterId);
} catch (error) {
setSemestersPhase('error');
setStatus({ message: error.message || 'Не удалось загрузить семестры.', tone: 'error' });
}
}
void initialize();
}, []);
async function loadVersions(overrideSemesterId) {
const nextSemesterId = overrideSemesterId ?? latestRef.current.semesterId;
if (!nextSemesterId) return;
setVersionPhase('loading');
try {
const loadedVersions = await fetchScheduleVersions(nextSemesterId);
setVersions(loadedVersions);
if (!loadedVersions.length) {
setVersionPhase('empty');
return;
}
setVersionPhase('ready');
const savedVersionId = window.localStorage.getItem(QUALITY_VERSION_STORAGE_KEY);
const saved = loadedVersions.find(version => String(version.id) === savedVersionId);
const published = loadedVersions.find(version => version.status === 'PUBLISHED');
setVersionId(String((saved || published || loadedVersions[0]).id));
window.localStorage.setItem(QUALITY_YEAR_STORAGE_KEY, latestRef.current.academicYearId);
window.localStorage.setItem(QUALITY_SEMESTER_STORAGE_KEY, nextSemesterId);
} catch (error) {
setVersionPhase('error');
setStatus({ message: error.message || 'Не удалось загрузить версии расписания.', tone: 'error' });
}
}
function handleYearChange(event) {
const nextYearId = event.target.value || '';
const selected = resolveAcademicPeriod(semesters, {
academicYearId: nextYearId,
date: formatLocalDate(new Date())
});
setAcademicYearId(nextYearId);
setSemesterId(selected.semesterId);
window.localStorage.setItem(QUALITY_YEAR_STORAGE_KEY, nextYearId);
void loadVersions(selected.semesterId);
}
function handleSemesterChange(event) {
const nextSemesterId = event.target.value || '';
setSemesterId(nextSemesterId);
void loadVersions(nextSemesterId);
}
async function runAnalysis() {
const currentSemesterId = latestRef.current.semesterId;
const currentVersionId = latestRef.current.versionId;
if (!currentSemesterId) {
setStatus({ message: 'Выберите семестр для анализа.', tone: 'warning' });
return;
}
if (!currentVersionId) {
setStatus({ message: 'Выберите версию расписания для анализа.', tone: 'warning' });
return;
}
window.localStorage.setItem(QUALITY_SEMESTER_STORAGE_KEY, currentSemesterId);
window.localStorage.setItem(QUALITY_VERSION_STORAGE_KEY, currentVersionId);
setAnalyzing(true);
setResultsHidden(true);
setStatus({ message: 'Строим фактическое расписание и рассчитываем показатели…', tone: 'loading' });
try {
const nextAnalysis = await fetchScheduleQualityAnalysis(currentSemesterId, currentVersionId);
setAnalysis(nextAnalysis);
setSelectedProblemId(null);
setResultsHidden(false);
trackUxEvent('качество_расписания_проверено', {
semesterId: currentSemesterId,
versionId: currentVersionId,
score: Number(nextAnalysis.score || 0)
});
setStatus({
message: nextAnalysis.scheduleVersionStatus === 'DRAFT'
? 'Черновик проверен. Проблемные правила можно открыть в конструкторе до публикации.'
: 'Анализ завершён. Проблемные правила можно открыть в конструкторе расписания.',
tone: 'success'
});
} catch (error) {
setStatus({ message: error.message || 'Не удалось выполнить анализ качества.', tone: 'error' });
} finally {
setAnalyzing(false);
}
}
function handleProblemClick(problem, action) {
if (action === 'edit-rule') {
openRuleInConstructor(problem);
return;
}
setSelectedProblemId(problem.id);
}
function openRuleInConstructor(problem) {
if (!problem?.scheduleRuleId || analysis?.scheduleVersionStatus === 'ARCHIVED') return;
window.localStorage.setItem(SCHEDULE_SEMESTER_STORAGE_KEY, String(analysis.semesterId));
window.localStorage.setItem(SCHEDULE_OPEN_VERSION_STORAGE_KEY, String(analysis.scheduleVersionId));
window.localStorage.setItem(SCHEDULE_OPEN_RULE_STORAGE_KEY, String(problem.scheduleRuleId));
window.location.hash = 'schedule';
}
const yearOptions = semestersPhase === 'ready' || semestersPhase === 'empty'
? (years.length
? years.map(year => <option key={String(year.id)} value={String(year.id)}>{year.title}</option>)
: <option value="">Учебные годы не настроены</option>)
: <option value="">Загрузка годов…</option>;
const semesterOptions = semestersPhase === 'error'
? <option value="">Ошибка загрузки</option>
: (semestersPhase === 'ready' || semestersPhase === 'empty'
? (yearSemesters.length
? yearSemesters.map(semester => (
<option key={String(semester.id)} value={String(semester.id)}>
{qualitySemesterLabel(semester)}
</option>
))
: <option value="">Семестры не настроены</option>)
: <option value="">Сначала выберите год</option>);
const versionPlaceholder = {
idle: 'Сначала выберите семестр',
loading: 'Загрузка версий…',
empty: 'Версии не созданы',
error: 'Ошибка загрузки'
}[versionPhase];
const versionOptions = versionPhase === 'ready'
? versions.map(version => (
<option key={String(version.id)} value={String(version.id)}>
v{version.versionNumber} · {version.name} · {versionStatusLabel(version.status)}
</option>
))
: <option value="">{versionPlaceholder}</option>;
const archived = analysis?.scheduleVersionStatus === 'ARCHIVED';
return (
<section className="quality-lab" aria-labelledby="quality-lab-title">
<header className="quality-hero">
<div className="quality-hero-copy">
<span className="quality-kicker">Диагностика · публикация или черновик</span>
<h2 id="quality-lab-title">Качество видно до перестановок</h2>
<p>
Анализатор находит окна, перегруженные дни, неудачные аудитории и нарушения
пожеланий. Все проблемные занятия объединяются по правилу, а их штрафы
суммируются. Черновик можно оценить до публикации.
</p>
</div>
<div className="quality-run-panel">
<label htmlFor="quality-year">Учебный год</label>
<select id="quality-year" value={academicYearId} onChange={handleYearChange}>
{yearOptions}
</select>
<label htmlFor="quality-semester">Семестр</label>
<select id="quality-semester" value={semesterId} onChange={handleSemesterChange}>
{semesterOptions}
</select>
<label htmlFor="quality-version">Версия</label>
<select id="quality-version" value={versionId} onChange={event => setVersionId(event.target.value)}>
{versionOptions}
</select>
<button
type="button"
className="btn btn-md btn-primary"
id="quality-run"
disabled={analyzing}
onClick={() => void runAnalysis()}
>
{analyzing ? 'Анализируем…' : 'Выполнить анализ'}
</button>
</div>
</header>
<div
className="quality-status"
id="quality-status"
role="status"
aria-live="polite"
data-tone={status.tone || undefined}
>
{status.message}
</div>
<div className="quality-results" id="quality-results" hidden={resultsHidden}>
{analysis ? (
<>
<section className="quality-scoreboard" aria-label="Итог анализа">
<div className="quality-score-orbit" id="quality-score-orbit" data-band={band.code}>
<svg className="quality-score-ring" viewBox="0 0 36 36" aria-hidden="true">
<circle className="quality-score-ring-track" cx="18" cy="18" r="15.9155"></circle>
<circle
className="quality-score-ring-value"
id="quality-score-ring-value"
cx="18"
cy="18"
r="15.9155"
pathLength="100"
strokeDasharray={`${Math.max(0, Math.min(100, analysis.score))} 100`}
></circle>
</svg>
<div className="quality-score-inner">
<strong id="quality-score">{analysis.score}</strong>
<span>из 100</span>
</div>
</div>
<div className="quality-score-copy">
<span className="quality-score-band" id="quality-score-band" data-band={band.code}>
{band.label}
</span>
<h3 id="quality-semester-label">{analysis.semesterLabel}</h3>
<p id="quality-score-summary">{band.summary}</p>
<div className="quality-score-meta" id="quality-score-meta">
<span>{versionStatusLabel(analysis.scheduleVersionStatus)} · v{analysis.scheduleVersionNumber || '—'}</span>
<span>{analysis.lessonCount} занятий</span>
<span>правил с проблемами: {analysis.totalProblemCount}</span>
<span>штраф {analysis.rawPenalty}</span>
</div>
</div>
</section>
<section className="quality-metrics" id="quality-metrics" aria-label="Метрики качества">
{(analysis.metrics || []).map((metric, index) => (
<article className={`quality-metric quality-metric-${index + 1}`} key={index}>
<span className="quality-metric-index">{String(index + 1).padStart(2, '0')}</span>
<strong>{formatMetricValue(metric)}</strong>
<h4>{metric.label}</h4>
<p>{metric.explanation}</p>
<span className="quality-metric-penalty">штраф {metric.penalty}</span>
</article>
))}
</section>
<section className="quality-workbench">
<div className="quality-problems-column">
<div className="quality-section-heading">
<div>
<span className="quality-kicker">Карта правил</span>
<h3>Правила, снижающие оценку</h3>
</div>
<span className="quality-problem-total" id="quality-problem-total">
{problemTotalLabel(analysis)}
</span>
</div>
<div className="quality-filters">
<label>
Важность
<select
id="quality-severity-filter"
value={severityFilter}
onChange={event => setSeverityFilter(event.target.value)}
>
<option value="">Все уровни</option>
<option value="CRITICAL">Критические</option>
<option value="HIGH">Высокие</option>
<option value="MEDIUM">Средние</option>
<option value="LOW">Низкие</option>
</select>
</label>
<label>
Категория
<select
id="quality-type-filter"
value={categoryFilter}
onChange={event => setCategoryFilter(event.target.value)}
>
<option value="">Все категории</option>
<option value="GAPS">Окна и компактность</option>
<option value="LOAD">Дневная нагрузка</option>
<option value="ROOM">Аудитории</option>
<option value="PREFERENCE">Пожелания</option>
</select>
</label>
</div>
<div className="quality-problems" id="quality-problems">
{visibleProblems.length ? visibleProblems.map(problem => {
const selected = problem.id === selectedProblemId;
const affectedLessonCount = Number(problem.affectedLessonCount || 1);
const issueLabels = problem.issueLabels?.length ? problem.issueLabels : [problem.title];
const canEdit = analysis.scheduleVersionStatus !== 'ARCHIVED' && problem.scheduleRuleId;
return (
<article
className={`quality-problem${selected ? ' is-selected' : ''}`}
key={String(problem.id)}
data-severity={problem.severity}
>
<button
type="button"
className="quality-problem-main"
onClick={() => handleProblemClick(problem)}
>
<span className="quality-severity">{severityLabel(problem.severity)}</span>
<span className="quality-problem-copy">
<strong>{problem.title}</strong>
<span>{problem.description}</span>
<span className="quality-rule-issues">
{issueLabels.map((label, index) => <span key={index}>{label}</span>)}
</span>
<small>
Правило #{problem.scheduleRuleId || '—'}
· затронуто {affectedLessonCount} {lessonWord(affectedLessonCount)}
· суммарный штраф
</small>
</span>
<span className="quality-penalty">−{problem.penalty}</span>
</button>
<div className="quality-problem-action">
{canEdit ? (
<button
type="button"
className="btn btn-sm btn-secondary"
onClick={() => handleProblemClick(problem, 'edit-rule')}
>
Изменить правило
</button>
) : <span>Только просмотр</span>}
</div>
</article>
);
}) : (
<div className="quality-empty-state">
<strong>По выбранному фильтру проблем нет</strong>
<span>Измените уровень важности или категорию.</span>
</div>
)}
</div>
</div>
<aside className="quality-recommendations" id="quality-recommendations" aria-live="polite">
{selectedProblem ? (
<RuleDetails problem={selectedProblem} archived={archived} onEdit={openRuleInConstructor} />
) : (
<div className="quality-recommendations-empty">
<span className="quality-recommendations-mark">↗</span>
<h3>{archived ? 'Архивная версия' : 'Работа с правилом'}</h3>
<p>
{archived
? 'Правила этой версии доступны только для просмотра. Для правок создайте новый черновик.'
: 'Выберите правило, чтобы увидеть его суммарное влияние и открыть его в конструкторе.'}
</p>
</div>
)}
</aside>
</section>
</>
) : null}
</div>
</section>
);
}
function RuleDetails({ problem, archived, onEdit }) {
const affectedLessonCount = Number(problem.affectedLessonCount || 1);
const issueLabels = problem.issueLabels?.length ? problem.issueLabels : [problem.title];
const canEdit = !archived && problem.scheduleRuleId;
return (
<>
<div className="quality-recommendations-head">
<span className="quality-kicker">Правило #{problem.scheduleRuleId || '—'}</span>
<h3>{problem.subjectName || problem.title}</h3>
<p>Затронуто {affectedLessonCount} {lessonWord(affectedLessonCount)}; суммарный штраф −{problem.penalty}.</p>
</div>
<div className="quality-rule-detail-list">
{issueLabels.map((label, index) => <span key={index}>{label}</span>)}
</div>
{canEdit ? (
<button
type="button"
className="btn btn-md btn-primary quality-edit-rule"
onClick={() => onEdit(problem)}
>
Открыть правило в конструкторе
</button>
) : <p className="quality-recommendations-note">Архивное расписание нельзя изменить.</p>}
</>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,622 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import {
academicYearsFromSemesters,
resolveAcademicPeriod,
semestersForAcademicYear
} from '../../../admin/js/academic-period.js';
import { trackUxEvent } from '../../../telemetry.js';
import {
QUALITY_SEMESTER_STORAGE_KEY,
QUALITY_VERSION_STORAGE_KEY,
SCHEDULE_OPEN_VERSION_STORAGE_KEY,
SCHEDULE_SEMESTER_STORAGE_KEY,
SCHEDULE_VERSION_STORAGE_KEY,
SCHEDULE_YEAR_STORAGE_KEY,
createScheduleDraftVersion,
draftBasisText,
fetchScheduleSemesters,
fetchScheduleVersionDiff,
fetchScheduleVersionHistory,
fetchScheduleVersionValidation,
fetchScheduleVersions,
formatLocalDate,
formatVersionDateTime,
historyActionLabel,
publishValidationSummary,
ruleChangeTypeLabel,
runScheduleVersionAction,
splitScheduleVersions,
versionSemesterLabel
} from '../schedule-versions-service.js';
const INITIAL_STATUS = Object.freeze({ message: 'Загружаем контур версий…', tone: '' });
export function ScheduleVersionsTab() {
const [semesters, setSemesters] = useState([]);
const [baseLoaded, setBaseLoaded] = useState(false);
const [academicYearId, setAcademicYearId] = useState('');
const [semesterId, setSemesterId] = useState('');
const [versions, setVersions] = useState([]);
const [history, setHistory] = useState([]);
const [loaded, setLoaded] = useState(false);
const [loading, setLoading] = useState(false);
const [status, setStatus] = useState(INITIAL_STATUS);
const [diffState, setDiffState] = useState(null);
const [modal, setModal] = useState(null);
const [modalValue, setModalValue] = useState('');
const [submitting, setSubmitting] = useState(false);
const latestRef = useRef(null);
latestRef.current = { academicYearId, semesterId, loading };
const years = useMemo(() => academicYearsFromSemesters(semesters), [semesters]);
const yearSemesters = useMemo(
() => semestersForAcademicYear(semesters, academicYearId),
[semesters, academicYearId]
);
const { published, drafts, archive } = useMemo(() => splitScheduleVersions(versions), [versions]);
useEffect(() => {
async function initialize() {
try {
const loadedSemesters = await fetchScheduleSemesters();
setSemesters(loadedSemesters);
setBaseLoaded(true);
if (!loadedSemesters.length) {
setStatus({ message: 'Сначала создайте учебный год и семестр.', tone: 'error' });
return;
}
const selected = resolveAcademicPeriod(loadedSemesters, {
academicYearId: window.localStorage.getItem(SCHEDULE_YEAR_STORAGE_KEY),
semesterId: window.localStorage.getItem(SCHEDULE_SEMESTER_STORAGE_KEY),
date: formatLocalDate(new Date())
});
setAcademicYearId(selected.academicYearId);
setSemesterId(selected.semesterId);
window.localStorage.setItem(SCHEDULE_YEAR_STORAGE_KEY, selected.academicYearId);
await loadWorkspace(selected.semesterId);
} catch (error) {
setStatus({ message: error.message || 'Не удалось загрузить семестры.', tone: 'error' });
}
}
void initialize();
}, []);
// Escape закрывает окно операции (в legacy — слушатель на document)
useEffect(() => {
if (!modal) return;
function handleKeydown(event) {
if (event.key === 'Escape') closeModal();
}
document.addEventListener('keydown', handleKeydown);
return () => document.removeEventListener('keydown', handleKeydown);
}, [modal]);
async function loadWorkspace(overrideSemesterId) {
const nextSemesterId = overrideSemesterId ?? latestRef.current.semesterId;
if (!nextSemesterId || latestRef.current.loading) return;
setLoading(true);
window.localStorage.setItem(SCHEDULE_YEAR_STORAGE_KEY, latestRef.current.academicYearId);
window.localStorage.setItem(SCHEDULE_SEMESTER_STORAGE_KEY, nextSemesterId);
setStatus({ message: 'Собираем версии, правила и журнал публикаций…', tone: 'loading' });
try {
const [nextVersions, nextHistory] = await Promise.all([
fetchScheduleVersions(nextSemesterId),
fetchScheduleVersionHistory(nextSemesterId)
]);
setVersions(nextVersions);
setHistory(nextHistory);
setLoaded(true);
trackUxEvent('версии_расписания_загружены', { semesterId: nextSemesterId, total: nextVersions.length });
setStatus({ message: 'Контур версий синхронизирован.', tone: 'success' });
} catch (error) {
setStatus({ message: error.message || 'Не удалось загрузить версии расписания.', tone: 'error' });
} finally {
setLoading(false);
}
}
function handleYearChange(event) {
const nextYearId = event.target.value || '';
const selected = resolveAcademicPeriod(semesters, {
academicYearId: nextYearId,
date: formatLocalDate(new Date())
});
setAcademicYearId(nextYearId);
setSemesterId(selected.semesterId);
window.localStorage.setItem(SCHEDULE_YEAR_STORAGE_KEY, nextYearId);
void loadWorkspace(selected.semesterId);
}
function handleSemesterChange(event) {
const nextSemesterId = event.target.value || '';
setSemesterId(nextSemesterId);
void loadWorkspace(nextSemesterId);
}
function handleVersionAction(version, action) {
if (action === 'edit') {
window.localStorage.setItem(SCHEDULE_SEMESTER_STORAGE_KEY, String(version.semesterId));
window.localStorage.setItem(SCHEDULE_OPEN_VERSION_STORAGE_KEY, String(version.id));
window.location.hash = 'schedule';
return;
}
if (action === 'quality') {
window.localStorage.setItem(QUALITY_SEMESTER_STORAGE_KEY, String(version.semesterId));
window.localStorage.setItem(QUALITY_VERSION_STORAGE_KEY, String(version.id));
window.location.hash = 'schedule-quality';
return;
}
if (action === 'diff') {
void loadDiff(version);
return;
}
if (action === 'publish') {
void openPublishModal(version);
return;
}
if (action === 'restore') {
openRestoreModal(version);
}
}
async function loadDiff(version) {
setDiffState({ phase: 'loading' });
try {
const diff = await fetchScheduleVersionDiff(version.id);
setDiffState({ phase: 'ready', diff });
} catch (error) {
setDiffState({ phase: 'error', message: error.message });
}
}
function openCreateModal() {
openModal({
mode: 'create',
version: published,
kicker: 'Изолированная рабочая копия',
title: 'Создать новый черновик',
copy: published
? `Правила версии ${published.versionNumber} будут скопированы. Пользователи продолжат видеть текущую публикацию.`
: 'Будет создан пустой черновик для первого расписания семестра.',
label: 'Название черновика',
submit: 'Создать черновик'
});
}
async function openPublishModal(version) {
setStatus({ message: 'Проверяем целостность черновика перед публикацией…', tone: 'loading' });
try {
const [validation, diff] = await Promise.all([
fetchScheduleVersionValidation(version.id),
fetchScheduleVersionDiff(version.id)
]);
openModal({
mode: 'publish',
version,
valid: validation.valid,
kicker: 'Атомарная публикация',
title: `Опубликовать версию ${version.versionNumber}`,
copy: 'После подтверждения текущая версия целиком уйдёт в архив, а этот черновик станет виден студентам и преподавателям.',
label: 'Причина изменений',
submit: 'Опубликовать',
summary: publishValidationSummary(validation, diff)
});
setStatus(validation.valid
? { message: 'Черновик готов к публикации.', tone: 'success' }
: { message: 'Черновик требует исправлений.', tone: 'error' });
} catch (error) {
setStatus({ message: error.message || 'Не удалось проверить черновик.', tone: 'error' });
}
}
function openRestoreModal(version) {
openModal({
mode: 'restore',
version,
kicker: 'Контролируемый возврат',
title: `Восстановить версию ${version.versionNumber}`,
copy: 'Текущая публикация будет архивирована, а выбранная версия снова станет активной одной транзакцией.',
label: 'Причина восстановления',
submit: 'Восстановить'
});
}
function openModal(config) {
setModal(config);
setModalValue(config.value || '');
setSubmitting(false);
}
function closeModal() {
setModal(null);
setModalValue('');
setSubmitting(false);
}
async function handleSubmit(event) {
event.preventDefault();
if (!modal || modal.valid === false || submitting) return;
const value = modalValue.trim();
if (!value) {
setStatus({
message: modal.mode === 'create' ? 'Укажите название черновика.' : 'Укажите причину операции.',
tone: 'error'
});
return;
}
setSubmitting(true);
try {
if (modal.mode === 'create') {
const created = await createScheduleDraftVersion({
semesterId: Number(latestRef.current.semesterId),
name: value,
basedOnVersionId: modal.version?.id || null
});
window.localStorage.setItem(SCHEDULE_VERSION_STORAGE_KEY, String(created.id));
setStatus({ message: `Черновик «${created.name}» создан.`, tone: 'success' });
} else {
await runScheduleVersionAction(modal.version.id, modal.mode, value);
setStatus({
message: modal.mode === 'publish'
? 'Версия опубликована атомарно.'
: 'Предыдущая версия восстановлена.',
tone: 'success'
});
}
closeModal();
await loadWorkspace();
} catch (error) {
setStatus({ message: error.message || 'Операцию не удалось выполнить.', tone: 'error' });
setSubmitting(false);
}
}
const yearOptions = !baseLoaded
? <option value="">Загрузка годов…</option>
: (years.length
? years.map(year => <option key={String(year.id)} value={String(year.id)}>{year.title}</option>)
: <option value="">Учебные годы не настроены</option>);
const semesterOptions = !baseLoaded
? <option value="">Сначала выберите год</option>
: (yearSemesters.length
? yearSemesters.map(semester => (
<option key={String(semester.id)} value={String(semester.id)}>
{versionSemesterLabel(semester)}
</option>
))
: <option value="">Семестры не настроены</option>);
return (
<section className="version-studio" aria-labelledby="version-studio-title">
<header className="version-hero">
<div>
<span className="version-eyebrow">Контур публикации</span>
<h2 id="version-studio-title">Расписание меняется за кулисами</h2>
<p>Черновики изолированы от студентов и преподавателей. Сравните правила и занятия, проверьте целостность и опубликуйте весь пакет одной операцией.</p>
</div>
<div className="version-hero-controls">
<div className="version-hero-period-grid">
<div>
<label htmlFor="version-year">Учебный год</label>
<select id="version-year" value={academicYearId} onChange={handleYearChange}>
{yearOptions}
</select>
</div>
<div>
<label htmlFor="version-semester">Рабочий семестр</label>
<select id="version-semester" value={semesterId} onChange={handleSemesterChange}>
{semesterOptions}
</select>
</div>
</div>
<button type="button" className="btn btn-md btn-primary" id="version-create" onClick={openCreateModal}>
Новый черновик
</button>
</div>
</header>
<div
className="version-status"
id="version-status"
role="status"
aria-live="polite"
data-tone={status.tone || undefined}
>
{status.message}
</div>
<section className="version-current" id="version-current" aria-label="Опубликованная версия">
{loaded ? (published ? (
<>
<div className="version-live-mark">LIVE</div>
<div>
<span className="version-eyebrow">Сейчас видят пользователи</span>
<h3>{published.name} · версия {published.versionNumber}</h3>
<p>{published.changeReason || 'Причина первой публикации не указана'}</p>
<div className="version-stats">
<span>{published.ruleCount} правил</span>
<span>{published.slotCount} слотов</span>
<span>{formatVersionDateTime(published.publishedAt)}</span>
</div>
</div>
<div className="version-card-actions">
<button
type="button"
className="btn btn-sm btn-primary"
onClick={() => handleVersionAction(published, 'edit')}
>
Редактировать
</button>
<button
type="button"
className="btn btn-sm btn-secondary"
onClick={() => handleVersionAction(published, 'quality')}
>
Проверить качество
</button>
</div>
</>
) : (
<>
<div className="version-live-mark">ПУСТО</div>
<div>
<h3>Опубликованной версии нет</h3>
<p>Создайте первый черновик и опубликуйте его после проверки.</p>
</div>
</>
)) : null}
</section>
<div className="version-workbench">
<section className="version-drafts-panel">
<div className="version-section-head">
<div>
<span className="version-eyebrow">Подготовка</span>
<h3>Черновики</h3>
</div>
<span id="version-draft-count">{drafts.length}</span>
</div>
<div className="version-draft-list" id="version-drafts">
{loaded ? (drafts.length ? drafts.map(version => (
<article
className="version-card"
key={String(version.id)}
data-number={String(version.versionNumber).padStart(2, '0')}
>
<div className="version-card-top">
<div>
<span className="version-status-chip">Черновик · v{version.versionNumber}</span>
<h4>{version.name}</h4>
</div>
</div>
<p>{draftBasisText(version)}</p>
<small>
{version.ruleCount} правил · {version.slotCount} слотов · {formatVersionDateTime(version.createdAt)}
</small>
<div className="version-card-actions">
<button
type="button"
className="btn btn-sm btn-primary"
onClick={() => handleVersionAction(version, 'edit')}
>
Редактировать
</button>
<button
type="button"
className="btn btn-sm btn-secondary"
onClick={() => handleVersionAction(version, 'diff')}
>
Сравнить
</button>
<button
type="button"
className="btn btn-sm btn-secondary"
onClick={() => handleVersionAction(version, 'quality')}
>
Качество
</button>
<button
type="button"
className="btn btn-sm btn-secondary"
onClick={() => handleVersionAction(version, 'publish')}
>
Опубликовать
</button>
</div>
</article>
)) : (
<div className="version-empty">
<span>+</span>
<h3>Нет активных черновиков</h3>
<p>Создайте копию опубликованной версии — её изменения останутся невидимыми до публикации.</p>
</div>
)) : null}
</div>
</section>
<aside className="version-diff-panel" id="version-diff">
{diffState === null ? (
<div className="version-empty">
<span>Δ</span>
<h3>Сравнение версий</h3>
<p>Выберите «Сравнить» у черновика, чтобы увидеть изменения правил и сформированных занятий.</p>
</div>
) : diffState.phase === 'loading' ? (
<div className="version-empty">
<span>…</span>
<h3>Сравниваем расписания</h3>
<p>Генерируем занятия обеих версий по всему семестру.</p>
</div>
) : diffState.phase === 'error' ? (
<div className="version-empty">
<span>!</span>
<h3>Сравнение недоступно</h3>
<p>{diffState.message}</p>
</div>
) : (
<VersionDiff diff={diffState.diff} />
)}
</aside>
</div>
<section className="version-archive-panel">
<div className="version-section-head">
<div>
<span className="version-eyebrow">Возврат без паники</span>
<h3>Архив публикаций</h3>
</div>
</div>
<div className="version-archive-list" id="version-archive">
{loaded ? (archive.length ? archive.map(version => (
<article
className="version-card"
key={String(version.id)}
data-number={String(version.versionNumber).padStart(2, '0')}
>
<span className="version-status-chip">Архив · v{version.versionNumber}</span>
<h4>{version.name}</h4>
<p>{version.changeReason || 'Причина не указана'}</p>
<small>{version.ruleCount} правил · публикация {formatVersionDateTime(version.publishedAt)}</small>
<div className="version-card-actions">
<button
type="button"
className="btn btn-sm btn-secondary"
onClick={() => handleVersionAction(version, 'diff')}
>
Сравнить
</button>
<button
type="button"
className="btn btn-sm btn-secondary"
onClick={() => handleVersionAction(version, 'quality')}
>
Качество
</button>
<button
type="button"
className="btn btn-sm btn-danger"
onClick={() => handleVersionAction(version, 'restore')}
>
Восстановить
</button>
</div>
</article>
)) : <p className="version-empty-note">Предыдущих публикаций пока нет.</p>) : null}
</div>
</section>
<section className="version-history-panel">
<div className="version-section-head">
<div>
<span className="version-eyebrow">Неизменяемый след</span>
<h3>Журнал действий</h3>
</div>
</div>
<div className="version-history" id="version-history">
{loaded ? (history.length ? history.map((item, index) => (
<article className="version-history-row" key={index}>
<time>{formatVersionDateTime(item.createdAt)}</time>
<div>
<strong>
{historyActionLabel(item.action)}: v{item.versionNumber} · {item.versionName}
</strong>
<p>{item.reason || 'Без комментария'}</p>
</div>
<span className="version-status-chip">{item.actorName || 'Система'}</span>
</article>
)) : <p className="version-empty-note">Действия ещё не зафиксированы.</p>) : null}
</div>
</section>
{modal ? (
<div
className="version-modal"
id="version-modal"
onClick={event => {
if (event.target === event.currentTarget) closeModal();
}}
>
<div className="version-modal-card" role="dialog" aria-modal="true" aria-labelledby="version-modal-title">
<button
type="button"
className="btn btn-icon-md btn-ghost version-modal-close"
id="version-modal-close"
aria-label="Закрыть окно"
onClick={closeModal}
>
&times;
</button>
<span className="version-eyebrow" id="version-modal-kicker">{modal.kicker}</span>
<h3 id="version-modal-title">{modal.title}</h3>
<p id="version-modal-copy">{modal.copy}</p>
<form id="version-modal-form" onSubmit={handleSubmit}>
<label htmlFor="version-modal-value" id="version-modal-label">{modal.label}</label>
<textarea
id="version-modal-value"
rows="3"
maxLength="2000"
required
autoFocus
value={modalValue}
onChange={event => setModalValue(event.target.value)}
></textarea>
<div className="version-modal-summary" id="version-modal-summary" hidden={!modal.summary}>
{modal.summary || ''}
</div>
<div className="version-modal-actions">
<button type="button" className="btn btn-md btn-ghost" id="version-modal-cancel" onClick={closeModal}>
Отмена
</button>
<button
type="submit"
className="btn btn-md btn-primary"
id="version-modal-submit"
disabled={modal.valid === false || submitting}
>
{submitting ? 'Выполняем…' : modal.submit}
</button>
</div>
</form>
</div>
</div>
) : null}
</section>
);
}
function VersionDiff({ diff }) {
return (
<>
<span className="version-eyebrow">{diff.baseVersionLabel} → {diff.targetVersionLabel}</span>
<h3>Что изменится после публикации</h3>
<div className="version-diff-score">
<div><strong>+{diff.addedLessonCount}</strong><span>занятий</span></div>
<div><strong>{diff.changedLessonCount}</strong><span>изменено</span></div>
<div><strong>−{diff.removedLessonCount}</strong><span>удалено</span></div>
</div>
<p className="version-diff-caption">
Правила: +{diff.addedRuleCount} · ~{diff.changedRuleCount} · −{diff.removedRuleCount} · без изменений {diff.unchangedRuleCount}
</p>
<div className="version-change-list">
{(diff.ruleChanges || []).length
? diff.ruleChanges.map((change, index) => (
<article className="version-change" key={index} data-type={change.type}>
<strong>{ruleChangeTypeLabel(change.type)} · {change.subjectName}</strong>
<span>{(change.groupNames || []).join(', ') || 'Группы не указаны'}</span>
{change.beforeSummary ? <span>Было: {change.beforeSummary}</span> : null}
{change.afterSummary ? <span>Стало: {change.afterSummary}</span> : null}
</article>
))
: (
<div className="version-empty">
<h3>Правила совпадают</h3>
<p>Структурных изменений между версиями нет.</p>
</div>
)}
</div>
</>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,535 @@
import { useEffect, useRef, useState } from 'react';
import { readUrlState, writeUrlState } from '../../../admin/js/url-state.js';
import { trackUxEvent } from '../../../telemetry.js';
import { AsyncCombobox } from '../../shared/ui/AsyncCombobox.jsx';
import { AlertDialog, ConfirmDialog } from '../../shared/ui/ConfirmDialog.jsx';
import { Pagination } from '../../shared/ui/Pagination.jsx';
import {
SUBJECT_SORT_OPTIONS,
SUBJECT_TABLE_COLUMNS,
archiveSubject,
assignTeacher,
createSubject,
departmentLabel,
departmentOptionLabel,
fetchDepartments,
fetchSubjectsPage,
fetchTeacherAssignments,
groupTeachersBySubject,
initialSubjectsState,
removeTeacherAssignment,
subjectsEmptyMessage,
subjectsUrlState,
teachersSearchEndpoint
} from '../subjects-service.js';
import { apiClient } from '../../shared/api-client.js';
const SEARCH_DEBOUNCE_MS = 320;
export function SubjectsTab() {
const [departments, setDepartments] = useState(null);
const [referenceError, setReferenceError] = useState('');
const [state, setState] = useState(() => initialSubjectsState(
typeof window === 'undefined' ? {} : readUrlState({
subjectQuery: '',
subjectDepartment: '',
subjectPage: '0',
subjectSize: '25',
subjectSort: 'name,asc'
})
));
const [searchInput, setSearchInput] = useState(() => state.query);
const [pageData, setPageData] = useState(null);
const [assignments, setAssignments] = useState([]);
const [tableError, setTableError] = useState('');
const [tableLoading, setTableLoading] = useState(true);
const [createName, setCreateName] = useState('');
const [createCode, setCreateCode] = useState('');
const [createDepartmentId, setCreateDepartmentId] = useState('');
const [createAlert, setCreateAlert] = useState({ text: '', isError: false });
const [teachersModal, setTeachersModal] = useState(null);
const [confirmState, setConfirmState] = useState(null);
const [alertState, setAlertState] = useState(null);
const searchTimer = useRef(null);
const requestNumber = useRef(0);
const stateRef = useRef(state);
stateRef.current = state;
useEffect(() => {
let active = true;
(async () => {
try {
const loaded = await fetchDepartments();
if (!active) return;
setDepartments(loaded || []);
await loadSubjects(stateRef.current);
} catch (error) {
if (active) setReferenceError(error.message || 'Не удалось загрузить справочники');
}
})();
return () => {
active = false;
requestNumber.current += 1;
};
}, []);
useEffect(() => () => {
window.clearTimeout(searchTimer.current);
}, []);
async function loadSubjects(nextState) {
const currentRequest = requestNumber.current + 1;
requestNumber.current = currentRequest;
setTableLoading(true);
setTableError('');
try {
let page = await fetchSubjectsPage(nextState);
if (!(page.items || []).length && page.totalItems > 0 && nextState.page > 0) {
nextState = { ...nextState, page: Math.max(0, page.totalPages - 1) };
page = await fetchSubjectsPage(nextState);
}
const loadedAssignments = await fetchTeacherAssignments((page.items || []).map(subject => subject.id));
if (requestNumber.current !== currentRequest) return;
setState(nextState);
setPageData(page);
setAssignments(loadedAssignments || []);
setTableLoading(false);
writeUrlState(subjectsUrlState(nextState));
trackUxEvent('реестр_дисциплин_загружен', {
total: Number(page.totalItems || 0),
page: nextState.page
});
} catch (error) {
if (requestNumber.current !== currentRequest) return;
setState(nextState);
setTableLoading(false);
setTableError(error.message || 'Не удалось загрузить дисциплины');
}
}
function applyState(patch) {
const nextState = { ...stateRef.current, ...patch };
void loadSubjects(nextState);
}
function handleSearchInput(value) {
setSearchInput(value);
window.clearTimeout(searchTimer.current);
searchTimer.current = window.setTimeout(() => {
applyState({ query: value.trim(), page: 0 });
}, SEARCH_DEBOUNCE_MS);
}
async function submitCreate(event) {
event.preventDefault();
const name = createName.trim();
const code = createCode.trim();
if (!name || !code || !createDepartmentId) {
setCreateAlert({ text: 'Заполните все поля', isError: true });
return;
}
try {
const created = await createSubject({ name, code, departmentId: createDepartmentId });
setCreateAlert({ text: `Дисциплина «${created.name || name}» создана`, isError: false });
setCreateName('');
setCreateCode('');
await loadSubjects(stateRef.current);
} catch (error) {
setCreateAlert({ text: error.message || 'Ошибка создания', isError: true });
}
}
function confirmArchiveSubject(subject) {
setConfirmState({
title: 'Архивировать дисциплину?',
message: 'Дисциплина станет недоступна для новых назначений. Исторические данные сохранятся.',
confirmLabel: 'Архивировать',
danger: true,
action: async () => {
try {
await archiveSubject(subject.id);
await loadSubjects(stateRef.current);
} catch (error) {
setAlertState({ title: 'Ошибка архивирования', message: error.message || 'Ошибка архивирования' });
}
}
});
}
function openTeachersModal(subject) {
setTeachersModal({
subject,
alert: { text: '', isError: false },
selectedTeacher: null
});
}
async function reloadAssignments() {
const subjectIds = (pageData?.items || []).map(subject => subject.id);
const loaded = await fetchTeacherAssignments(subjectIds);
setAssignments(loaded || []);
return loaded || [];
}
async function searchTeachers(query, selectedId) {
const teachers = await apiClient.get(teachersSearchEndpoint(query, selectedId));
const modalSubjectId = teachersModal?.subject?.id;
const assigned = new Set(
(assignments || [])
.filter(item => String(item.subjectId) === String(modalSubjectId))
.map(item => String(item.userId))
);
return (teachers || []).filter(teacher =>
!assigned.has(String(teacher.id)) || String(teacher.id) === String(selectedId));
}
async function submitTeacherAssignment(event) {
event.preventDefault();
const modal = teachersModal;
if (!modal?.selectedTeacher) {
setTeachersModal({ ...modal, alert: { text: 'Выберите преподавателя', isError: true } });
return;
}
try {
await assignTeacher({ userId: modal.selectedTeacher.id, subjectId: modal.subject.id });
setTeachersModal(current => (
current ? { ...current, alert: { text: 'Преподаватель успешно привязан', isError: false }, selectedTeacher: null } : current
));
await reloadAssignments();
} catch (error) {
setTeachersModal({ ...modal, alert: { text: error.message || 'Ошибка привязки', isError: true } });
}
}
function confirmDeleteAssignment(assignment) {
setConfirmState({
title: 'Удалить привязку преподавателя?',
message: 'Преподаватель больше не будет доступен для этой дисциплины в новых правилах.',
confirmLabel: 'Удалить привязку',
danger: true,
action: async () => {
try {
await removeTeacherAssignment({
userId: assignment.userId,
subjectId: assignment.subjectId
});
await reloadAssignments();
} catch (error) {
setTeachersModal(current => (
current ? { ...current, alert: { text: error.message || 'Ошибка удаления привязки', isError: true } } : current
));
}
}
});
}
async function runConfirm() {
const confirm = confirmState;
setConfirmState(null);
await confirm.action();
}
const teachersBySubject = groupTeachersBySubject(assignments);
const subjects = pageData?.items || [];
return (
<>
<div className="card create-card">
<h2>Новая дисциплина</h2>
<form onSubmit={submitCreate}>
<div className="form-row">
<div className="form-group">
<label htmlFor="new-subject-name">Название дисциплины</label>
<input
type="text"
id="new-subject-name"
placeholder="Высшая математика"
required
value={createName}
onChange={event => setCreateName(event.target.value)}
/>
</div>
<div className="form-group">
<label htmlFor="new-subject-code">Код предмета</label>
<input
type="text"
id="new-subject-code"
placeholder="Например: MATH101"
required
value={createCode}
onChange={event => setCreateCode(event.target.value)}
/>
</div>
<div className="form-group">
<label htmlFor="new-subject-department">Кафедра</label>
<select
id="new-subject-department"
required
value={createDepartmentId}
onChange={event => setCreateDepartmentId(event.target.value)}
>
<option value="">Выберите кафедру</option>
{(departments || []).map(department => (
<option key={department.id} value={department.id}>
{departmentOptionLabel(department)}
</option>
))}
</select>
</div>
<button type="submit" className="btn btn-md btn-primary">Добавить</button>
</div>
<div
className={`form-alert${createAlert.text ? (createAlert.isError ? ' error' : ' success') : ''}`}
role="alert"
>
{createAlert.text}
</div>
</form>
</div>
<div className="card">
<h2>Все дисциплины</h2>
<div className="registry-toolbar">
<div className="form-group registry-search">
<label htmlFor="subjects-search">Поиск</label>
<input
type="search"
id="subjects-search"
placeholder="Название или код дисциплины"
value={searchInput}
onChange={event => handleSearchInput(event.target.value)}
/>
</div>
<div className="form-group">
<label htmlFor="subjects-department-filter">Кафедра</label>
<select
id="subjects-department-filter"
value={state.departmentId}
onChange={event => applyState({ departmentId: event.target.value, page: 0 })}
>
<option value="">Все кафедры</option>
{(departments || []).map(department => (
<option key={department.id} value={department.id}>
{department.departmentName || department.name || department.id}
</option>
))}
</select>
</div>
<div className="form-group">
<label htmlFor="subjects-sort">Сортировка</label>
<select
id="subjects-sort"
value={state.sort}
onChange={event => applyState({ sort: event.target.value, page: 0 })}
>
{SUBJECT_SORT_OPTIONS.map(option => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</div>
</div>
<div className="table-wrap">
<table id="subjects-table">
<thead>
<tr>
<th>ID</th>
<th>Название</th>
<th>Код предмета</th>
<th>Кафедра</th>
<th>Преподаватели</th>
<th className="th-actions">Действия</th>
</tr>
</thead>
<tbody>
{referenceError ? (
<tr><td colSpan={SUBJECT_TABLE_COLUMNS} className="loading-row">Ошибка загрузки справочников: {referenceError}</td></tr>
) : tableError ? (
<tr>
<td colSpan={SUBJECT_TABLE_COLUMNS} className="loading-row view-state-error">
<span>{tableError}</span>
<button
type="button"
className="btn btn-sm btn-secondary"
onClick={() => void loadSubjects(stateRef.current)}
>
Повторить
</button>
</td>
</tr>
) : tableLoading ? (
<tr><td colSpan={SUBJECT_TABLE_COLUMNS} className="loading-row"><span className="state-spinner" aria-hidden="true" />Загрузка дисциплин...</td></tr>
) : subjects.length === 0 ? (
<tr><td colSpan={SUBJECT_TABLE_COLUMNS} className="loading-row view-state-empty">{subjectsEmptyMessage(state)}</td></tr>
) : subjects.map(subject => {
const teachers = teachersBySubject.get(String(subject.id)) || [];
return (
<tr key={subject.id}>
<td>{subject.id}</td>
<td><strong>{subject.name}</strong></td>
<td>{subject.code || '-'}</td>
<td>{departmentLabel(departments || [], subject.departmentId)}</td>
<td>
<div className="badge-list">
{teachers.length === 0 ? (
<span className="text-muted"><small>Нет преподавателей</small></span>
) : teachers.map(teacher => (
<span className="badge badge-ef badge--tight" key={teacher.userId}>
{teacher.fullName || teacher.username}
</span>
))}
</div>
</td>
<td className="table-cell-actions">
<button
type="button"
className="btn btn-sm btn-secondary btn-manage-teachers"
onClick={() => openTeachersModal(subject)}
>
Преподаватели
</button>
<button
type="button"
className="btn btn-sm btn-danger btn-delete-subject"
onClick={() => confirmArchiveSubject(subject)}
>
Удалить
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{pageData && (
<Pagination
page={pageData.page}
size={pageData.size}
totalItems={pageData.totalItems}
totalPages={pageData.totalPages}
label="дисциплин"
onPage={page => applyState({ page })}
onSize={size => applyState({ size, page: 0 })}
/>
)}
</div>
{teachersModal && (
<div
className="modal-overlay open"
onClick={event => {
if (event.target === event.currentTarget) setTeachersModal(null);
}}
>
<div className="modal-content card modal-content--medium">
<div className="modal-header-row">
<div>
<h2>Преподаватели дисциплины</h2>
<div className="modal-subtitle">
{teachersModal.subject.code ? `${teachersModal.subject.code} · ` : ''}{teachersModal.subject.name}
</div>
</div>
<button
type="button"
className="btn btn-icon-md btn-ghost modal-close modal-close--static"
aria-label="Закрыть окно"
onClick={() => setTeachersModal(null)}
>
×
</button>
</div>
<div className="card create-card modal-subcard">
<h3>Добавить привязку</h3>
<form onSubmit={submitTeacherAssignment}>
<div className="form-row form-row--bottom">
<div className="form-group form-group--flush">
<label htmlFor="manage-ts-teacher-select">Преподаватель</label>
<AsyncCombobox
ariaLabel="Поиск преподавателя"
placeholder="Найдите преподавателя по ФИО"
emptyLabel="Преподаватели не найдены"
load={(query, selectedId) => searchTeachers(query, selectedId)}
optionLabel={teacher => teacher.fullName || teacher.username}
optionDescription={teacher => [teacher.jobTitle, teacher.departmentName].filter(Boolean).join(' · ')}
onSelect={teacher => setTeachersModal(current => (
current ? { ...current, selectedTeacher: teacher } : current
))}
selectedLabel={teachersModal.selectedTeacher
? (teachersModal.selectedTeacher.fullName || teachersModal.selectedTeacher.username)
: ''}
selectedValue={teachersModal.selectedTeacher
? String(teachersModal.selectedTeacher.id)
: ''}
/>
</div>
<button type="submit" className="btn btn-md btn-primary btn-inline">Привязать</button>
</div>
<div
className={`form-alert${teachersModal.alert.text ? (teachersModal.alert.isError ? ' error' : ' success') : ''}`}
role="alert"
>
{teachersModal.alert.text}
</div>
</form>
</div>
<div className="table-wrap">
<table id="manage-ts-table">
<thead>
<tr>
<th>Преподаватель</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
{(teachersBySubject.get(String(teachersModal.subject.id)) || []).length === 0 ? (
<tr><td colSpan="2" className="loading-row">Преподаватели не привязаны</td></tr>
) : (teachersBySubject.get(String(teachersModal.subject.id)) || []).map(assignment => (
<tr key={assignment.userId}>
<td>
<strong>{assignment.fullName || assignment.username}</strong>{' '}
<span className="text-muted">({assignment.username})</span>
</td>
<td>
<button
type="button"
className="btn btn-sm btn-danger btn-delete-ts"
onClick={() => confirmDeleteAssignment(assignment)}
>
Удалить
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)}
{confirmState && (
<ConfirmDialog
title={confirmState.title}
message={confirmState.message}
confirmLabel={confirmState.confirmLabel}
danger={confirmState.danger}
onConfirm={() => void runConfirm()}
onClose={() => setConfirmState(null)}
/>
)}
{alertState && (
<AlertDialog
title={alertState.title}
message={alertState.message}
onClose={() => setAlertState(null)}
/>
)}
</>
);
}

View File

@@ -0,0 +1,940 @@
import { useEffect, useRef, useState } from 'react';
import { getSession } from '../../../admin/js/api.js';
import { AsyncCombobox } from '../../shared/ui/AsyncCombobox.jsx';
import { FormAlert } from '../../shared/ui/FormAlert.jsx';
import {
absenceSemesterLabel,
absenceStats,
changeReviewTarget,
changeTypeLabel,
createTeacherAbsence,
decisionCountLabel,
decisionOptions,
decisionStatusLabel,
deleteTeacherAbsence,
fetchAbsenceTeacherOptions,
fetchTeacherAbsenceWizard,
fetchTeacherAbsences,
fetchTeacherChangeRequests,
fetchTeacherPreferenceMeta,
fetchTeacherPreferences,
formatAbsenceDate,
formatLocalDate,
dayOfMonth,
genericStatusLabel,
monthShort,
parseDecisionOption,
preferenceDescription,
resolutionLabel,
resolveTeacherAbsence,
reviewTeacherAbsence,
reviewTeacherChangeRequest,
reviewTeacherPreference,
statusClass,
statusLabel,
trimTime
} from '../teacher-absences-service.js';
function teacherOptionLabel(teacher) {
return teacher.fullName || teacher.username || `ID ${teacher.id}`;
}
export function TeacherAbsencesTab() {
const session = getSession();
const role = session?.role;
const [teacherSelection, setTeacherSelection] = useState({ id: '', label: '' });
const [startDate, setStartDate] = useState(() => formatLocalDate(new Date()));
const [endDate, setEndDate] = useState(() => formatLocalDate(new Date()));
const [reason, setReason] = useState('');
const [formAlert, setFormAlert] = useState(null);
const [absences, setAbsences] = useState([]);
const [absencesPhase, setAbsencesPhase] = useState('loading');
const [absencesError, setAbsencesError] = useState('');
const [statusFilter, setStatusFilter] = useState('');
const [registryBusy, setRegistryBusy] = useState(null);
const [wizardOpen, setWizardOpen] = useState(false);
const [wizardData, setWizardData] = useState(null);
const [wizardLoading, setWizardLoading] = useState(false);
const [wizardAlert, setWizardAlert] = useState(null);
const [wizardApplying, setWizardApplying] = useState(false);
const [decisions, setDecisions] = useState({});
const [preferenceSemesters, setPreferenceSemesters] = useState([]);
const [preferenceSemesterId, setPreferenceSemesterId] = useState('');
const [preferenceStatusFilter, setPreferenceStatusFilter] = useState('PENDING');
const [preferenceItems, setPreferenceItems] = useState([]);
const [preferencePhase, setPreferencePhase] = useState('idle');
const [preferenceError, setPreferenceError] = useState('');
const [preferenceComments, setPreferenceComments] = useState({});
const [preferenceBusy, setPreferenceBusy] = useState(null);
const [changeStatusFilter, setChangeStatusFilter] = useState('PENDING');
const [changeItems, setChangeItems] = useState([]);
const [changePhase, setChangePhase] = useState('idle');
const [changeError, setChangeError] = useState('');
const [changeComments, setChangeComments] = useState({});
const [changeBusy, setChangeBusy] = useState(null);
const [alert, setAlert] = useState(null);
const latestRef = useRef(null);
latestRef.current = { statusFilter, preferenceSemesterId, preferenceStatusFilter, changeStatusFilter };
const stats = absenceStats(absences);
const canApplyChanges = role === 'ADMIN' || role === 'EDUCATION_OFFICE';
useEffect(() => {
void loadAbsences();
void loadPreferenceMeta();
void loadChangeReviews();
}, []);
// Мастер замены блокирует прокрутку страницы (в legacy — класс на body)
useEffect(() => {
if (!wizardOpen) return;
document.body.classList.add('absence-wizard-open');
return () => document.body.classList.remove('absence-wizard-open');
}, [wizardOpen]);
// Escape закрывает системное окно сообщения (в legacy — диалог из dialog.js)
useEffect(() => {
if (!alert) return;
function handleKeydown(event) {
if (event.key === 'Escape') setAlert(null);
}
document.addEventListener('keydown', handleKeydown);
return () => document.removeEventListener('keydown', handleKeydown);
}, [alert]);
async function loadAbsences(overrideStatus) {
const nextStatus = overrideStatus ?? latestRef.current.statusFilter;
setAbsencesPhase('loading');
try {
const items = await fetchTeacherAbsences(nextStatus);
setAbsences(items);
setAbsencesPhase('ready');
} catch (error) {
setAbsencesError(error.message || 'Не удалось загрузить отсутствия');
setAbsencesPhase('error');
}
}
async function handleSubmit(event) {
event.preventDefault();
setFormAlert(null);
if (!teacherSelection.id || !startDate || !endDate || !reason.trim()) {
setFormAlert({ type: 'error', message: 'Заполните преподавателя, период и причину' });
return;
}
if (endDate < startDate) {
setFormAlert({ type: 'error', message: 'Дата окончания не может быть раньше даты начала' });
return;
}
try {
await createTeacherAbsence({
teacherId: Number(teacherSelection.id),
startDate,
endDate,
reason: reason.trim()
});
setFormAlert({ type: 'success', message: 'Отсутствие зарегистрировано' });
setReason('');
await loadAbsences();
} catch (error) {
setFormAlert({ type: 'error', message: error.message || 'Не удалось зарегистрировать отсутствие' });
}
}
async function handleRegistryAction(item, action) {
setRegistryBusy({ id: item.id, action });
try {
if (action === 'approve' || action === 'reject') {
await reviewTeacherAbsence(item.id, {
approved: action === 'approve',
comment: action === 'approve'
? 'Подтверждено кафедрой или ответственным сотрудником'
: 'Заявка отклонена ответственным сотрудником'
});
await loadAbsences();
} else if (action === 'cancel') {
await deleteTeacherAbsence(item.id);
await loadAbsences();
} else if (action === 'wizard') {
await openWizard(item.id);
}
} catch (error) {
setAlert({ title: 'Действие не выполнено', message: error.message || 'Не удалось выполнить действие' });
} finally {
setRegistryBusy(null);
}
}
async function openWizard(id) {
setWizardOpen(true);
setWizardLoading(true);
setWizardData(null);
setWizardAlert(null);
setDecisions({});
try {
const data = await fetchTeacherAbsenceWizard(id);
setWizardData(data);
setWizardLoading(false);
} catch (error) {
setWizardAlert({ type: 'error', message: error.message || 'Не удалось открыть мастер замены' });
}
}
function closeWizard() {
setWizardOpen(false);
setWizardData(null);
setWizardLoading(false);
setWizardAlert(null);
setWizardApplying(false);
setDecisions({});
}
async function applyWizard() {
if (!wizardData) return;
const nextDecisions = (wizardData.lessons || []).map((lesson, index) => {
const value = decisions[index] || '';
if (!value) return null;
return {
baseRuleSlotId: Number(lesson.baseRuleSlotId),
lessonDate: lesson.lessonDate,
...parseDecisionOption(value)
};
}).filter(Boolean);
if (!nextDecisions.length && (wizardData.lessons || []).length) {
setWizardAlert({ type: 'error', message: 'Выберите хотя бы одно решение' });
return;
}
setWizardApplying(true);
setWizardAlert(null);
try {
const data = await resolveTeacherAbsence(wizardData.absence.id, nextDecisions);
setWizardData(data);
setDecisions({});
setWizardAlert({ type: 'success', message: 'Выбранные решения применены к расписанию' });
await loadAbsences();
} catch (error) {
setWizardAlert({ type: 'error', message: error.message || 'Не удалось применить решения' });
} finally {
setWizardApplying(false);
}
}
async function loadPreferenceMeta() {
setPreferencePhase('meta-loading');
try {
const meta = await fetchTeacherPreferenceMeta();
setPreferenceSemesters(meta.semesters || []);
const today = formatLocalDate(new Date());
const current = (meta.semesters || []).find(item => item.startDate <= today && item.endDate >= today);
const nextSemesterId = current ? String(current.id) : '';
setPreferenceSemesterId(nextSemesterId);
await loadPreferenceReviews({ semesterId: nextSemesterId });
} catch (error) {
setPreferenceError(error.message || 'Не удалось загрузить семестры');
setPreferencePhase('error');
}
}
async function loadPreferenceReviews(overrides = {}) {
const semesterId = overrides.semesterId ?? latestRef.current.preferenceSemesterId;
const status = overrides.status ?? latestRef.current.preferenceStatusFilter;
setPreferencePhase('loading');
try {
const items = await fetchTeacherPreferences({ semesterId, status });
setPreferenceItems(items);
setPreferencePhase('ready');
} catch (error) {
setPreferenceError(error.message || 'Не удалось загрузить пожелания');
setPreferencePhase('error');
}
}
async function handlePreferenceReview(item, approved) {
const comment = (preferenceComments[item.id] || '').trim();
if (!approved && !comment) {
setAlert({ title: 'Нужен комментарий', message: 'При отклонении укажите комментарий.' });
return;
}
setPreferenceBusy({ id: item.id, action: approved ? 'approve' : 'reject' });
try {
await reviewTeacherPreference(item.id, {
approved,
comment: comment || 'Согласовано ответственным сотрудником'
});
await loadPreferenceReviews();
} catch (error) {
setAlert({ title: 'Ошибка рассмотрения', message: error.message || 'Не удалось рассмотреть пожелание' });
} finally {
setPreferenceBusy(null);
}
}
async function loadChangeReviews(overrideStatus) {
const nextStatus = overrideStatus ?? latestRef.current.changeStatusFilter;
setChangePhase('loading');
try {
const items = await fetchTeacherChangeRequests(nextStatus);
setChangeItems(items);
setChangePhase('ready');
} catch (error) {
setChangeError(error.message || 'Не удалось загрузить заявки');
setChangePhase('error');
}
}
async function handleChangeReview(item, approved) {
const comment = (changeComments[item.id] || '').trim();
if (!approved && !comment) {
setAlert({ title: 'Нужен комментарий', message: 'При отклонении укажите комментарий.' });
return;
}
setChangeBusy({ id: item.id, action: approved ? 'approve' : 'reject' });
try {
await reviewTeacherChangeRequest(item.id, { approved, comment });
await Promise.all([loadChangeReviews(), loadAbsences()]);
} catch (error) {
setAlert({ title: 'Решение не применено', message: error.message || 'Не удалось применить решение' });
} finally {
setChangeBusy(null);
}
}
const registryContent = absencesPhase === 'loading'
? <div className="loading-row">Загрузка...</div>
: absencesPhase === 'error'
? <div className="loading-row">{absencesError}</div>
: (absences.length ? absences.map(item => (
<article className="absence-registry-item" key={String(item.id)} data-status={item.status}>
<div className="absence-date-rail">
<strong>{dayOfMonth(item.startDate)}</strong>
<span>{monthShort(item.startDate)}</span>
<i></i>
<strong>{dayOfMonth(item.endDate)}</strong>
<span>{monthShort(item.endDate)}</span>
</div>
<div className="absence-registry-main">
<div className="absence-registry-title">
<div>
<h3>{item.teacherName || 'Преподаватель не указан'}</h3>
<p>{item.reason || 'Причина не указана'}</p>
</div>
<span className={`absence-status ${statusClass(item.status)}`}>{statusLabel(item.status)}</span>
</div>
<div className="absence-meta-row">
<span>{formatAbsenceDate(item.startDate)} — {formatAbsenceDate(item.endDate)}</span>
<span>{decisionCountLabel(item.decisionCount || 0)}</span>
{item.reviewComment ? <span>{item.reviewComment}</span> : null}
</div>
</div>
<div className="absence-registry-buttons">
{item.status === 'PENDING' ? (
<>
<button
type="button"
className="btn btn-sm btn-primary"
disabled={registryBusy?.id === item.id && registryBusy?.action === 'approve'}
onClick={() => void handleRegistryAction(item, 'approve')}
>
Подтвердить
</button>
<button
type="button"
className="btn btn-sm btn-danger-subtle"
disabled={registryBusy?.id === item.id && registryBusy?.action === 'reject'}
onClick={() => void handleRegistryAction(item, 'reject')}
>
Отклонить
</button>
</>
) : null}
{['APPROVED', 'RESOLVED'].includes(item.status) ? (
<button
type="button"
className="btn btn-sm btn-secondary"
disabled={registryBusy?.id === item.id && registryBusy?.action === 'wizard'}
onClick={() => void handleRegistryAction(item, 'wizard')}
>
{item.status === 'APPROVED' ? 'Открыть мастер' : 'Открыть журнал'}
</button>
) : null}
{['PENDING', 'APPROVED'].includes(item.status) ? (
<button
type="button"
className="btn btn-sm btn-ghost"
disabled={registryBusy?.id === item.id && registryBusy?.action === 'cancel'}
onClick={() => void handleRegistryAction(item, 'cancel')}
>
Отменить
</button>
) : null}
</div>
</article>
)) : (
<div className="absence-empty">
<strong>Инцидентов не найдено</strong>
<span>Измените фильтр или зарегистрируйте отсутствие.</span>
</div>
));
const preferenceContent = preferencePhase === 'idle'
? null
: preferencePhase === 'meta-loading'
? <div className="loading-row">Загрузка семестров...</div>
: preferencePhase === 'loading'
? <div className="loading-row">Загрузка пожеланий...</div>
: preferencePhase === 'error'
? <div className="loading-row">{preferenceError}</div>
: (preferenceItems.length ? preferenceItems.map(item => (
<article className="teacher-dialogue-item" key={String(item.id)}>
<span className={`teacher-dialogue-mark preference-${item.preferenceType.toLowerCase()}`}></span>
<div className="teacher-dialogue-copy">
<small>
{item.academicYearTitle} · {item.semesterType === 'AUTUMN' ? 'осенний семестр' : 'весенний семестр'}
</small>
<h3>{item.teacherName}</h3>
<p>{preferenceDescription(item)}</p>
{item.comment ? <blockquote>{item.comment}</blockquote> : null}
{item.reviewComment
? <span className="teacher-dialogue-review">Решение: {item.reviewComment}</span>
: null}
</div>
<div className="teacher-dialogue-action">
<span className={`absence-status ${statusClass(item.status)}`}>{genericStatusLabel(item.status)}</span>
{item.status === 'PENDING' ? (
<>
<input
type="text"
className="teacher-dialogue-comment"
maxLength="2000"
placeholder="Комментарий к решению"
value={preferenceComments[item.id] || ''}
onChange={event => setPreferenceComments(previous => ({
...previous,
[item.id]: event.target.value
}))}
/>
<div>
<button
className="btn btn-sm btn-primary"
type="button"
disabled={preferenceBusy?.id === item.id}
onClick={() => void handlePreferenceReview(item, true)}
>
Согласовать
</button>
<button
className="btn btn-sm btn-danger-subtle"
type="button"
disabled={preferenceBusy?.id === item.id}
onClick={() => void handlePreferenceReview(item, false)}
>
Отклонить
</button>
</div>
</>
) : null}
</div>
</article>
)) : (
<div className="absence-empty">
<strong>Пожеланий не найдено</strong>
<span>Измените фильтры или дождитесь новых отметок преподавателей.</span>
</div>
));
const changeContent = changePhase === 'idle'
? null
: changePhase === 'loading'
? <div className="loading-row">Загрузка заявок...</div>
: changePhase === 'error'
? <div className="loading-row">{changeError}</div>
: (changeItems.length ? changeItems.map(item => (
<article className="teacher-dialogue-item change-item" key={String(item.id)}>
<span className={`teacher-dialogue-mark change-${item.requestType.toLowerCase()}`}></span>
<div className="teacher-dialogue-copy">
<small>
{formatAbsenceDate(item.lessonDate)} · {trimTime(item.sourceStartTime)}–{trimTime(item.sourceEndTime)} · {item.sourceClassroomName}
</small>
<h3>{item.teacherName} · {item.subjectName}</h3>
<p>{changeTypeLabel(item.requestType)} → <strong>{changeReviewTarget(item)}</strong></p>
<blockquote>{item.reason}</blockquote>
{item.reviewComment
? <span className="teacher-dialogue-review">Решение: {item.reviewComment}</span>
: null}
<details>
<summary>История · {(item.history || []).length}</summary>
<ol>
{(item.history || []).map((entry, index) => (
<li key={index}>
<b>{genericStatusLabel(entry.status)}</b> · {entry.comment || 'Без комментария'}
</li>
))}
</ol>
</details>
</div>
<div className="teacher-dialogue-action">
<span className={`absence-status ${statusClass(item.status)}`}>{genericStatusLabel(item.status)}</span>
{item.status === 'PENDING' && canApplyChanges ? (
<>
<input
type="text"
className="teacher-dialogue-comment"
maxLength="2000"
placeholder="Комментарий к решению"
value={changeComments[item.id] || ''}
onChange={event => setChangeComments(previous => ({
...previous,
[item.id]: event.target.value
}))}
/>
<div>
<button
className="btn btn-sm btn-primary"
type="button"
disabled={changeBusy?.id === item.id}
onClick={() => void handleChangeReview(item, true)}
>
Применить
</button>
<button
className="btn btn-sm btn-danger-subtle"
type="button"
disabled={changeBusy?.id === item.id}
onClick={() => void handleChangeReview(item, false)}
>
Отклонить
</button>
</div>
</>
) : item.status === 'PENDING'
? <small>Применение доступно учебному отделу</small>
: null}
</div>
</article>
)) : (
<div className="absence-empty">
<strong>Заявок не найдено</strong>
<span>Очередь изменений сейчас пуста.</span>
</div>
));
return (
<>
<section className="absence-command-card">
<div className="absence-command-copy">
<span className="absence-kicker">Оперативный контур расписания</span>
<h2>От регистрации инцидента до готовой замены</h2>
<p>Зафиксируйте период отсутствия, проверьте затронутые пары и примените только выбранные решения.</p>
</div>
<div className="absence-command-stats" aria-label="Сводка отсутствий">
<article><strong id="absence-stat-active">{stats.active}</strong><span>требуют внимания</span></article>
<article><strong id="absence-stat-pending">{stats.pending}</strong><span>ждут подтверждения</span></article>
<article><strong id="absence-stat-resolved">{stats.resolved}</strong><span>разрешены</span></article>
</div>
</section>
<section className="card absence-create-card">
<div className="card-header-row">
<div>
<span className="absence-section-number">01</span>
<h2>Зарегистрировать отсутствие</h2>
</div>
<span className="muted">Ответственный сотрудник создаёт согласованную запись сразу</span>
</div>
<form id="teacher-absence-form" noValidate onSubmit={handleSubmit}>
<div className="absence-form-grid">
<div className="form-group absence-teacher-field">
<label htmlFor="teacher-absence-teacher">Преподаватель</label>
<AsyncCombobox
ariaLabel="Преподаватель"
placeholder="Найдите преподавателя по ФИО"
selectedValue={teacherSelection.id}
selectedLabel={teacherSelection.label}
optionLabel={teacherOptionLabel}
optionDescription={teacher => [
teacher.jobTitle,
teacher.departmentName
].filter(Boolean).join(' · ')}
load={(query, selectedId) => fetchAbsenceTeacherOptions({
query,
selectedId,
role,
departmentId: session?.departmentId
})}
onSelect={item => setTeacherSelection(item
? { id: String(item.id), label: teacherOptionLabel(item) }
: { id: '', label: '' })}
/>
</div>
<div className="form-group">
<label htmlFor="teacher-absence-start">Начало</label>
<input
type="date"
id="teacher-absence-start"
required
value={startDate}
onChange={event => setStartDate(event.target.value)}
/>
</div>
<div className="form-group">
<label htmlFor="teacher-absence-end">Окончание</label>
<input
type="date"
id="teacher-absence-end"
required
value={endDate}
onChange={event => setEndDate(event.target.value)}
/>
</div>
<div className="form-group absence-reason-field">
<label htmlFor="teacher-absence-reason">Причина</label>
<input
type="text"
id="teacher-absence-reason"
maxLength="2000"
placeholder="Болезнь, командировка или другая причина"
required
value={reason}
onChange={event => setReason(event.target.value)}
/>
</div>
<button type="submit" className="btn btn-md btn-primary absence-create-submit">
Зарегистрировать
</button>
</div>
<FormAlert id="teacher-absence-form-alert" alert={formAlert} />
</form>
</section>
<section className="card absence-registry-card">
<div className="card-header-row absence-registry-head">
<div>
<span className="absence-section-number">02</span>
<h2>Реестр инцидентов</h2>
</div>
<div className="absence-registry-actions">
<select
id="teacher-absence-status-filter"
aria-label="Фильтр по статусу"
value={statusFilter}
onChange={event => {
setStatusFilter(event.target.value || '');
void loadAbsences(event.target.value || '');
}}
>
<option value="">Все статусы</option>
<option value="PENDING">На подтверждении</option>
<option value="APPROVED">Согласованы</option>
<option value="RESOLVED">Разрешены</option>
<option value="REJECTED">Отклонены</option>
<option value="CANCELLED">Отменены</option>
</select>
<button
type="button"
className="btn btn-md btn-secondary"
id="teacher-absence-refresh"
onClick={() => void loadAbsences()}
>
Обновить
</button>
</div>
</div>
<div className="absence-registry" id="teacher-absence-registry" aria-live="polite">
{registryContent}
</div>
</section>
<section className="teacher-dialogue-grid">
<article className="card teacher-dialogue-card">
<header className="teacher-dialogue-head">
<div>
<span className="absence-section-number">03</span>
<h2>Пожелания на семестр</h2>
<p>Строгие ограничения после согласования участвуют в проверке правил. Мягкие отметки остаются видимой подсказкой.</p>
</div>
<div className="teacher-dialogue-counter">
<strong id="teacher-preference-pending-count">
{preferenceItems.filter(item => item.status === 'PENDING').length}
</strong>
<span>ожидают</span>
</div>
</header>
<div className="teacher-dialogue-toolbar">
<select
id="teacher-preference-semester-filter"
aria-label="Семестр пожеланий"
value={preferenceSemesterId}
onChange={event => {
setPreferenceSemesterId(event.target.value || '');
void loadPreferenceReviews({ semesterId: event.target.value || '' });
}}
>
<option value="">Все семестры</option>
{preferenceSemesters.map(semester => (
<option key={String(semester.id)} value={String(semester.id)}>
{absenceSemesterLabel(semester)}
</option>
))}
</select>
<select
id="teacher-preference-status-filter"
aria-label="Статус пожеланий"
value={preferenceStatusFilter}
onChange={event => {
setPreferenceStatusFilter(event.target.value || '');
void loadPreferenceReviews({ status: event.target.value || '' });
}}
>
<option value="PENDING">На рассмотрении</option>
<option value="APPROVED">Согласованные</option>
<option value="">Все статусы</option>
</select>
<button
className="btn btn-md btn-secondary"
id="teacher-preference-refresh"
type="button"
onClick={() => void loadPreferenceReviews()}
>
Обновить
</button>
</div>
<div className="teacher-dialogue-list" id="teacher-preference-review-list" aria-live="polite">
{preferenceContent}
</div>
</article>
<article className="card teacher-dialogue-card change-review-card">
<header className="teacher-dialogue-head">
<div>
<span className="absence-section-number">04</span>
<h2>Заявки на изменение</h2>
<p>Каждое одобрение проходит повторную проверку конфликтов и применяется через точечное изменение расписания.</p>
</div>
<div className="teacher-dialogue-counter">
<strong id="teacher-change-pending-count">
{changeItems.filter(item => item.status === 'PENDING').length}
</strong>
<span>в очереди</span>
</div>
</header>
<div className="teacher-dialogue-toolbar">
<select
id="teacher-change-status-filter"
aria-label="Статус заявок"
value={changeStatusFilter}
onChange={event => {
setChangeStatusFilter(event.target.value || '');
void loadChangeReviews(event.target.value || '');
}}
>
<option value="PENDING">Ожидают решения</option>
<option value="APPROVED">Одобренные</option>
<option value="REJECTED">Отклонённые</option>
<option value="">Все статусы</option>
</select>
<button
className="btn btn-md btn-secondary"
id="teacher-change-refresh"
type="button"
onClick={() => void loadChangeReviews()}
>
Обновить
</button>
</div>
<div className="teacher-dialogue-list" id="teacher-change-review-list" aria-live="polite">
{changeContent}
</div>
</article>
</section>
{wizardOpen ? (
<div
className="absence-wizard-overlay"
id="absence-wizard-overlay"
onClick={event => {
if (event.target === event.currentTarget) closeWizard();
}}
>
<section className="absence-wizard" role="dialog" aria-modal="true" aria-labelledby="absence-wizard-title">
<header className="absence-wizard-header">
<div>
<span className="absence-kicker">Мастер замены</span>
<h2 id="absence-wizard-title">
{wizardData ? (wizardData.absence.teacherName || 'Мастер замены') : 'Затронутые занятия'}
</h2>
<p id="absence-wizard-summary">
{wizardData
? `${formatAbsenceDate(wizardData.absence.startDate)} — ${formatAbsenceDate(wizardData.absence.endDate)} · ${wizardData.absence.reason}`
: 'Загрузка...'}
</p>
</div>
<button
type="button"
className="btn btn-icon-md btn-ghost"
id="absence-wizard-close"
aria-label="Закрыть"
onClick={closeWizard}
>
&times;
</button>
</header>
<div className="absence-wizard-progress" aria-hidden="true">
<span className="done">Инцидент</span><i></i><span className="done">Занятия</span><i></i><span>Решения</span>
</div>
<FormAlert id="absence-wizard-alert" alert={wizardAlert} />
<div className="absence-wizard-lessons" id="absence-wizard-lessons">
{wizardLoading
? <div className="loading-row">Подбираем свободные ресурсы...</div>
: (wizardData
? (wizardData.lessons.length
? wizardData.lessons.map((lesson, index) => (
<LessonDecision
key={index}
lesson={lesson}
index={index}
locked={wizardData.absence.status !== 'APPROVED' || Boolean(lesson.decisionStatus)}
value={decisions[index] || ''}
onChange={value => setDecisions(previous => ({ ...previous, [index]: value }))}
/>
))
: (
<div className="absence-empty">
<strong>Затронутых занятий нет</strong>
<span>Инцидент можно завершить без точечных изменений.</span>
</div>
))
: null)}
</div>
<section className="absence-journal">
<div className="card-header-row">
<h3>Журнал решений</h3>
<span className="muted" id="absence-journal-count">
{wizardData ? decisionCountLabel((wizardData.journal || []).length) : '0 записей'}
</span>
</div>
<div id="absence-journal-list" className="absence-journal-list">
{wizardData ? (
(wizardData.journal || []).length ? (wizardData.journal || []).map((item, index) => (
<article key={index}>
<span className={`absence-journal-mark ${item.status === 'APPLIED' ? 'applied' : 'rejected'}`}></span>
<div>
<strong>{resolutionLabel(item.resolution)}</strong>
<small>{formatAbsenceDate(item.lessonDate)}</small>
</div>
<span>
{decisionStatusLabel(item.status)}{item.scheduleOverrideId ? ` · override №${item.scheduleOverrideId}` : ''}
</span>
</article>
)) : <div className="loading-row">Решений пока нет</div>
) : null}
</div>
</section>
<footer className="absence-wizard-footer">
<span className="muted">Пустые строки останутся без изменений</span>
<div>
<button type="button" className="btn btn-md btn-ghost" id="absence-wizard-cancel" onClick={closeWizard}>
Закрыть
</button>
<button
type="button"
className="btn btn-md btn-primary"
id="absence-wizard-apply"
hidden={wizardData?.absence.status !== 'APPROVED'}
disabled={wizardApplying}
onClick={() => void applyWizard()}
>
Применить выбранные
</button>
</div>
</footer>
</section>
</div>
) : null}
{alert ? (
<div
className="project-dialog-backdrop"
onClick={event => {
if (event.target === event.currentTarget) setAlert(null);
}}
>
<section
className="project-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="project-dialog-title"
aria-describedby="project-dialog-message"
>
<header>
<h2 id="project-dialog-title">{alert.title}</h2>
<button
type="button"
className="btn btn-icon-md btn-ghost"
aria-label="Закрыть"
onClick={() => setAlert(null)}
>
&times;
</button>
</header>
<p id="project-dialog-message">{alert.message}</p>
<footer>
<button type="button" className="btn btn-md btn-primary" onClick={() => setAlert(null)}>
Понятно
</button>
</footer>
</section>
</div>
) : null}
</>
);
}
function LessonDecision({ lesson, index, locked, value, onChange }) {
const options = decisionOptions(lesson);
return (
<article className="absence-lesson">
<div className="absence-lesson-time">
<strong>{trimTime(lesson.startTime)}</strong>
<span>{trimTime(lesson.endTime)}</span>
<small>{formatAbsenceDate(lesson.lessonDate)}</small>
</div>
<div className="absence-lesson-copy">
<span className="absence-lesson-type">{lesson.lessonTypeName || 'Занятие'}</span>
<h3>{lesson.subjectName || 'Дисциплина не указана'}</h3>
<p>
{(lesson.groupNames || []).join(', ') || 'Группы не указаны'} · {lesson.classroomName || 'Без аудитории'}
</p>
{lesson.existingOverrideId
? <small className="absence-warning">У пары уже есть точечное изменение — доступна только запись отклонения.</small>
: null}
</div>
<div className="absence-lesson-decision">
<label htmlFor={`absence-decision-${index}`}>Решение</label>
<select
id={`absence-decision-${index}`}
className="absence-decision-select"
disabled={locked}
value={value}
onChange={event => onChange(event.target.value)}
>
<option value="">Оставить без изменений</option>
{options.map(option => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
{lesson.decisionStatus
? <span className="absence-decision-fixed">{decisionStatusLabel(lesson.decisionStatus)}</span>
: null}
</div>
</article>
);
}

View File

@@ -0,0 +1,372 @@
import { useEffect, useRef, useState } from 'react';
import { readUrlState, writeUrlState } from '../../../admin/js/url-state.js';
import { trackUxEvent } from '../../../telemetry.js';
import { PromptDialog } from '../../shared/ui/ConfirmDialog.jsx';
import { Pagination } from '../../shared/ui/Pagination.jsx';
import { requestCountLabel } from '../admin-service.js';
import {
REQUEST_STATUS_OPTIONS,
REQUEST_TABLE_COLUMNS,
approvePayloadFromFields,
approveRequest,
fetchDepartments,
fetchTeacherRequestsPage,
initialTeacherRequestsState,
pendingCountDetail,
rejectRequest,
requestDepartmentLabel,
reviewedStatusLabel,
teacherRequestsEmptyMessage,
teacherRequestsUrlState
} from '../teacher-requests-service.js';
const SEARCH_DEBOUNCE_MS = 320;
export function TeacherRequestsTab() {
const [state, setState] = useState(() => initialTeacherRequestsState(
typeof window === 'undefined' ? {} : readUrlState({
requestQuery: '',
requestStatus: 'PENDING',
requestPage: '0',
requestSize: '25'
})
));
const [searchInput, setSearchInput] = useState(() => state.query);
const [departments, setDepartments] = useState([]);
const [departmentsError, setDepartmentsError] = useState(false);
const [pageData, setPageData] = useState(null);
const [tableError, setTableError] = useState('');
const [tableLoading, setTableLoading] = useState(true);
const [alert, setAlert] = useState({ text: '', isError: false });
const [rejectState, setRejectState] = useState(null);
const searchTimer = useRef(null);
const requestNumber = useRef(0);
const stateRef = useRef(state);
stateRef.current = state;
useEffect(() => {
let active = true;
(async () => {
try {
const loadedDepartments = await fetchDepartments();
if (!active) return;
setDepartments(loadedDepartments || []);
await loadRequests(stateRef.current);
} catch {
if (!active) return;
setTableLoading(false);
setDepartmentsError(true);
}
})();
return () => {
active = false;
requestNumber.current += 1;
};
}, []);
useEffect(() => () => {
window.clearTimeout(searchTimer.current);
}, []);
async function loadRequests(nextState) {
const currentRequest = requestNumber.current + 1;
requestNumber.current = currentRequest;
setTableLoading(true);
setTableError('');
setAlert(current => (current.text ? { text: '', isError: false } : current));
try {
let page = await fetchTeacherRequestsPage(nextState);
if (!(page.items || []).length && page.totalItems > 0 && nextState.page > 0) {
nextState = { ...nextState, page: Math.max(0, page.totalPages - 1) };
page = await fetchTeacherRequestsPage(nextState);
}
if (requestNumber.current !== currentRequest) return;
setState(nextState);
setPageData(page);
setTableLoading(false);
writeUrlState(teacherRequestsUrlState(nextState));
document.dispatchEvent(new CustomEvent('teacherRequestsChanged', {
detail: pendingCountDetail(nextState, page.totalItems)
}));
trackUxEvent('реестр_заявок_загружен', {
total: Number(page.totalItems || 0),
page: nextState.page
});
} catch {
if (requestNumber.current !== currentRequest) return;
setState(nextState);
setPageData(null);
setTableLoading(false);
setTableError('Не удалось загрузить заявки преподавателей');
}
}
function applyState(patch) {
void loadRequests({ ...stateRef.current, ...patch });
}
function handleSearchInput(value) {
setSearchInput(value);
window.clearTimeout(searchTimer.current);
searchTimer.current = window.setTimeout(() => {
applyState({ query: value.trim(), page: 0 });
}, SEARCH_DEBOUNCE_MS);
}
async function approve(requestId, fields) {
const { payload, error } = approvePayloadFromFields(fields);
if (error) {
setAlert({ text: error, isError: true });
return;
}
try {
await approveRequest(requestId, payload);
setAlert({ text: 'Преподаватель создан, заявка одобрена', isError: false });
await loadRequests(stateRef.current);
} catch (err) {
setAlert({ text: err.message || 'Ошибка одобрения заявки', isError: true });
}
}
async function confirmReject(reviewComment) {
const requestId = rejectState?.requestId;
setRejectState(null);
if (!requestId) return;
try {
await rejectRequest(requestId, reviewComment);
setAlert({ text: 'Заявка отклонена', isError: false });
await loadRequests(stateRef.current);
} catch (err) {
setAlert({ text: err.message || 'Ошибка отклонения заявки', isError: true });
}
}
const requests = pageData?.items || [];
const countLabel = requestCountLabel(pageData ? Number(pageData.totalItems || 0) : 0);
return (
<div className="card">
<div className="card-header-row">
<h2>Заявки на преподавателей</h2>
<div className="teacher-request-toolbar">
<span className="muted">{countLabel}</span>
<button
type="button"
className="btn btn-sm btn-secondary"
onClick={() => void loadRequests(stateRef.current)}
>
Обновить
</button>
</div>
</div>
<div
className={`form-alert${alert.text ? (alert.isError ? ' error' : ' success') : ''}`}
role="alert"
>
{alert.text}
</div>
<div className="registry-toolbar">
<div className="form-group registry-search">
<label htmlFor="teacher-requests-search">Поиск</label>
<input
type="search"
id="teacher-requests-search"
placeholder="ФИО, логин, должность или кафедра"
value={searchInput}
onChange={event => handleSearchInput(event.target.value)}
/>
</div>
<div className="form-group">
<label htmlFor="teacher-requests-status">Статус</label>
<select
id="teacher-requests-status"
value={state.status}
onChange={event => applyState({ status: event.target.value, page: 0 })}
>
{REQUEST_STATUS_OPTIONS.map(option => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</div>
</div>
<div className="table-wrap">
<table>
<thead>
<tr>
<th>Кафедра</th>
<th>Логин</th>
<th>ФИО</th>
<th>Должность</th>
<th>Пароль</th>
<th>Комментарий</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="teacher-requests-tbody">
{departmentsError ? (
<tr>
<td colSpan={REQUEST_TABLE_COLUMNS} className="loading-row">
Не удалось загрузить заявки преподавателей
</td>
</tr>
) : tableError ? (
<tr>
<td colSpan={REQUEST_TABLE_COLUMNS} className="loading-row view-state-error">
<span>{tableError}</span>
<button
type="button"
className="btn btn-sm btn-secondary"
onClick={() => void loadRequests(stateRef.current)}
>
Повторить
</button>
</td>
</tr>
) : tableLoading ? (
<tr>
<td colSpan={REQUEST_TABLE_COLUMNS} className="loading-row">
<span className="state-spinner" aria-hidden="true" />Загрузка заявок...
</td>
</tr>
) : requests.length === 0 ? (
<tr>
<td colSpan={REQUEST_TABLE_COLUMNS} className="loading-row view-state-empty">
{teacherRequestsEmptyMessage(state)}
</td>
</tr>
) : requests.map(request => (
<TeacherRequestRow
key={request.id}
request={request}
departments={departments}
onApprove={approve}
onReject={requestId => setRejectState({ requestId })}
/>
))}
</tbody>
</table>
</div>
{pageData && (
<Pagination
page={pageData.page}
size={pageData.size}
totalItems={pageData.totalItems}
totalPages={pageData.totalPages}
label="заявок"
onPage={page => applyState({ page })}
onSize={size => applyState({ size, page: 0 })}
/>
)}
{rejectState && (
<PromptDialog
title="Отклонить заявку?"
message="Комментарий увидит кафедра, отправившая заявку."
confirmLabel="Отклонить"
danger
input={{
label: 'Причина отклонения',
placeholder: 'Укажите, что нужно исправить',
required: true,
requiredMessage: 'Укажите причину отклонения'
}}
onConfirm={value => void confirmReject(value)}
onClose={() => setRejectState(null)}
/>
)}
</div>
);
}
// Строка заявки хранит черновик полей локально: значения инпутов строки
// отправляются при одобрении — как в legacy-реестре.
function TeacherRequestRow({ request, departments, onApprove, onReject }) {
const [departmentId, setDepartmentId] = useState(String(request.departmentId ?? ''));
const [username, setUsername] = useState(request.username || '');
const [fullName, setFullName] = useState(request.fullName || '');
const [jobTitle, setJobTitle] = useState(request.jobTitle || '');
const [password, setPassword] = useState('');
function approve() {
onApprove(request.id, { departmentId, username, fullName, jobTitle, password });
}
return (
<tr>
<td>
<select
value={departmentId}
onChange={event => setDepartmentId(event.target.value)}
>
{departments.map(department => (
<option key={department.id} value={department.id}>
{requestDepartmentLabel(department)}
</option>
))}
</select>
</td>
<td>
<input
type="text"
maxLength={50}
autoComplete="username"
value={username}
onChange={event => setUsername(event.target.value)}
/>
</td>
<td>
<input
type="text"
maxLength={255}
value={fullName}
onChange={event => setFullName(event.target.value)}
/>
</td>
<td>
<input
type="text"
maxLength={255}
value={jobTitle}
onChange={event => setJobTitle(event.target.value)}
/>
</td>
<td>
<input
type="password"
className="teacher-request-password"
placeholder="Минимум 8 символов"
autoComplete="new-password"
value={password}
onChange={event => setPassword(event.target.value)}
/>
</td>
<td>{request.comment || '-'}</td>
<td>
<div className="teacher-request-actions">
{request.status === 'PENDING' ? (
<>
<button
type="button"
className="btn btn-sm btn-primary teacher-request-approve"
onClick={approve}
>
Одобрить
</button>
<button
type="button"
className="btn btn-sm btn-danger-subtle teacher-request-reject"
onClick={() => onReject(request.id)}
>
Отклонить
</button>
</>
) : (
<span className="badge">{reviewedStatusLabel(request.status)}</span>
)}
</div>
</td>
</tr>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,399 @@
import { useEffect, useRef, useState } from 'react';
import { readUrlState, writeUrlState } from '../../../admin/js/url-state.js';
import { trackUxEvent } from '../../../telemetry.js';
import { AlertDialog, ConfirmDialog } from '../../shared/ui/ConfirmDialog.jsx';
import { Pagination } from '../../shared/ui/Pagination.jsx';
import {
ROLE_FILTER_OPTIONS,
USER_SORT_OPTIONS,
USER_TABLE_COLUMNS,
archiveUser,
createUser,
fetchUsersPage,
initialUsersState,
restoreUser,
roleBadgeClass,
roleLabel,
usersEmptyMessage,
usersUrlState
} from '../users-service.js';
const SEARCH_DEBOUNCE_MS = 320;
export function UsersTab() {
const [state, setState] = useState(() => initialUsersState(
typeof window === 'undefined' ? {} : readUrlState({
userQuery: '',
userRole: '',
userPage: '0',
userSize: '25',
userSort: 'fullName,asc'
})
));
const [searchInput, setSearchInput] = useState(() => state.query);
const [pageData, setPageData] = useState(null);
const [tableError, setTableError] = useState('');
const [tableLoading, setTableLoading] = useState(true);
const [createUsername, setCreateUsername] = useState('');
const [createPassword, setCreatePassword] = useState('');
const [createRole, setCreateRole] = useState('STUDENT');
const [createFullName, setCreateFullName] = useState('');
const [createJobTitle, setCreateJobTitle] = useState('');
const [createDepartmentId, setCreateDepartmentId] = useState('');
const [createAlert, setCreateAlert] = useState({ text: '', isError: false });
const [confirmState, setConfirmState] = useState(null);
const [alertState, setAlertState] = useState(null);
const searchTimer = useRef(null);
const requestNumber = useRef(0);
const stateRef = useRef(state);
stateRef.current = state;
useEffect(() => {
void loadUsers(stateRef.current);
return () => {
requestNumber.current += 1;
};
}, []);
useEffect(() => () => {
window.clearTimeout(searchTimer.current);
}, []);
async function loadUsers(nextState) {
const currentRequest = requestNumber.current + 1;
requestNumber.current = currentRequest;
setTableLoading(true);
setTableError('');
try {
let page = await fetchUsersPage(nextState);
if (!(page.items || []).length && page.totalItems > 0 && nextState.page > 0) {
nextState = { ...nextState, page: Math.max(0, page.totalPages - 1) };
page = await fetchUsersPage(nextState);
}
if (requestNumber.current !== currentRequest) return;
setState(nextState);
setPageData(page);
setTableLoading(false);
writeUrlState(usersUrlState(nextState));
trackUxEvent('реестр_пользователей_загружен', {
total: Number(page.totalItems || 0),
page: nextState.page
});
} catch (error) {
if (requestNumber.current !== currentRequest) return;
setState(nextState);
setTableLoading(false);
setTableError(error.message || 'Не удалось загрузить пользователей');
}
}
function applyState(patch) {
void loadUsers({ ...stateRef.current, ...patch });
}
function handleSearchInput(value) {
setSearchInput(value);
window.clearTimeout(searchTimer.current);
searchTimer.current = window.setTimeout(() => {
applyState({ query: value.trim(), page: 0 });
}, SEARCH_DEBOUNCE_MS);
}
async function submitCreate(event) {
event.preventDefault();
const username = createUsername.trim();
const password = createPassword;
const fullName = createFullName.trim();
const jobTitle = createJobTitle.trim();
if (!username || !password || !fullName || !jobTitle || !createDepartmentId) {
setCreateAlert({ text: 'Заполните все поля', isError: true });
return;
}
if (password.length < 8) {
setCreateAlert({ text: 'Пароль должен быть не короче 8 символов', isError: true });
return;
}
try {
const created = await createUser({
username,
password,
role: createRole,
fullName,
jobTitle,
departmentId: createDepartmentId
});
setCreateAlert({ text: `Пользователь «${created.username}» создан`, isError: false });
setCreateUsername('');
setCreatePassword('');
setCreateFullName('');
setCreateJobTitle('');
setCreateDepartmentId('');
await loadUsers(stateRef.current);
} catch (error) {
setCreateAlert({ text: error.message || 'Ошибка соединения', isError: true });
}
}
function confirmArchiveUser(user) {
setConfirmState({
title: 'Архивировать пользователя?',
message: 'Доступ будет отключён, а история расписания и связей сохранится.',
confirmLabel: 'Архивировать',
danger: true,
action: async () => {
try {
await archiveUser(user.id);
await loadUsers(stateRef.current);
} catch (error) {
setAlertState({ title: 'Ошибка архивирования', message: error.message || 'Ошибка архивирования' });
}
}
});
}
async function restore(user) {
try {
await restoreUser(user.id);
await loadUsers(stateRef.current);
} catch (error) {
setAlertState({ title: 'Ошибка восстановления', message: error.message || 'Ошибка восстановления' });
}
}
async function runConfirm() {
const confirm = confirmState;
setConfirmState(null);
await confirm.action();
}
const users = pageData?.items || [];
return (
<>
<div className="card create-card">
<h2>Новый пользователь</h2>
<form onSubmit={submitCreate}>
<div className="form-row">
<div className="form-group">
<label htmlFor="new-username">Имя пользователя</label>
<input
type="text"
id="new-username"
placeholder="Имя пользователя"
autoComplete="username"
required
value={createUsername}
onChange={event => setCreateUsername(event.target.value)}
/>
</div>
<div className="form-group">
<label htmlFor="new-password">Пароль</label>
<input
type="password"
id="new-password"
placeholder="Минимум 8 символов"
autoComplete="new-password"
required
value={createPassword}
onChange={event => setCreatePassword(event.target.value)}
/>
</div>
<div className="form-group">
<label htmlFor="new-role">Роль</label>
<select
id="new-role"
value={createRole}
onChange={event => setCreateRole(event.target.value)}
>
<option value="STUDENT">Студент</option>
<option value="TEACHER">Преподаватель</option>
<option value="DEPARTMENT">Кафедра</option>
<option value="EDUCATION_OFFICE">Учебный отдел</option>
<option value="SCHEDULE_VIEWER">Просмотр расписаний</option>
<option value="ADMIN">Администратор</option>
</select>
</div>
<div className="form-group">
<label htmlFor="new-fullname">ФИО пользователя</label>
<input
type="text"
id="new-fullname"
placeholder="Иванов Иван Иванович"
required
value={createFullName}
onChange={event => setCreateFullName(event.target.value)}
/>
</div>
<div className="form-group">
<label htmlFor="new-jobtitle">Должность</label>
<input
type="text"
id="new-jobtitle"
placeholder="Студент / Доцент"
required
value={createJobTitle}
onChange={event => setCreateJobTitle(event.target.value)}
/>
</div>
<div className="form-group">
<label htmlFor="new-department">ID Кафедры</label>
<input
type="number"
id="new-department"
placeholder="ID"
required
value={createDepartmentId}
onChange={event => setCreateDepartmentId(event.target.value)}
/>
</div>
<button type="submit" className="btn btn-md btn-primary">Создать</button>
</div>
<div
className={`form-alert${createAlert.text ? (createAlert.isError ? ' error' : ' success') : ''}`}
role="alert"
>
{createAlert.text}
</div>
</form>
</div>
<div className="card">
<h2>Все пользователи</h2>
<div className="registry-toolbar">
<div className="form-group registry-search">
<label htmlFor="users-search">Поиск</label>
<input
type="search"
id="users-search"
placeholder="Логин, ФИО или должность"
value={searchInput}
onChange={event => handleSearchInput(event.target.value)}
/>
</div>
<div className="form-group">
<label htmlFor="users-role-filter">Роль</label>
<select
id="users-role-filter"
value={state.role}
onChange={event => applyState({ role: event.target.value, page: 0 })}
>
<option value="">Все роли</option>
{ROLE_FILTER_OPTIONS.map(option => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</div>
<div className="form-group">
<label htmlFor="users-sort">Сортировка</label>
<select
id="users-sort"
value={state.sort}
onChange={event => applyState({ sort: event.target.value, page: 0 })}
>
{USER_SORT_OPTIONS.map(option => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</div>
</div>
<div className="table-wrap">
<table id="users-table">
<thead>
<tr>
<th>ID</th>
<th>Имя пользователя</th>
<th>ФИО</th>
<th>Должность</th>
<th>Кафедра</th>
<th>Роль</th>
<th className="th-actions">Действия</th>
</tr>
</thead>
<tbody>
{tableError ? (
<tr>
<td colSpan={USER_TABLE_COLUMNS} className="loading-row view-state-error">
<span>{tableError}</span>
<button
type="button"
className="btn btn-sm btn-secondary"
onClick={() => void loadUsers(stateRef.current)}
>
Повторить
</button>
</td>
</tr>
) : tableLoading ? (
<tr><td colSpan={USER_TABLE_COLUMNS} className="loading-row"><span className="state-spinner" aria-hidden="true" />Загрузка пользователей...</td></tr>
) : users.length === 0 ? (
<tr><td colSpan={USER_TABLE_COLUMNS} className="loading-row view-state-empty">{usersEmptyMessage(state)}</td></tr>
) : users.map(user => (
<tr key={user.id}>
<td>{user.id}</td>
<td>{user.username}</td>
<td>{user.fullName || '-'}</td>
<td>{user.jobTitle || '-'}</td>
<td>{user.departmentName || '-'}</td>
<td><span className={`badge ${roleBadgeClass(user.role)}`}>{roleLabel(user.role)}</span></td>
<td className="table-cell-actions">
{user.status === 'ARCHIVED' ? (
<button
type="button"
className="btn btn-sm btn-secondary btn-restore-user"
onClick={() => void restore(user)}
>
Восстановить
</button>
) : (
<button
type="button"
className="btn btn-sm btn-danger-subtle btn-archive-user"
onClick={() => confirmArchiveUser(user)}
>
Архивировать
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{pageData && (
<Pagination
page={pageData.page}
size={pageData.size}
totalItems={pageData.totalItems}
totalPages={pageData.totalPages}
label="пользователей"
onPage={page => applyState({ page })}
onSize={size => applyState({ size, page: 0 })}
/>
)}
</div>
{confirmState && (
<ConfirmDialog
title={confirmState.title}
message={confirmState.message}
confirmLabel={confirmState.confirmLabel}
danger={confirmState.danger}
onConfirm={() => void runConfirm()}
onClose={() => setConfirmState(null)}
/>
)}
{alertState && (
<AlertDialog
title={alertState.title}
message={alertState.message}
onClose={() => setAlertState(null)}
/>
)}
</>
);
}

View File

@@ -0,0 +1,236 @@
import { apiClient } from '../shared/api-client.js';
import { formatLocalDate } from '../../admin/js/utils.js';
// Срез «Отсутствия преподавателей» (этап 5, волна 5): чистая логика и контракты API,
// перенесённые из legacy admin/js/views/teacher-absences.js без изменения поведения.
export const MANAGER_ROLES = new Set(['ADMIN', 'EDUCATION_OFFICE', 'DEPARTMENT']);
// --- Поиск преподавателя для регистрации отсутствия ---
export function teacherAbsenceTeacherOptionsUrl({ query = '', selectedId = '', role = '', departmentId = '' } = {}) {
const params = new URLSearchParams({ query, limit: '30' });
if (role === 'DEPARTMENT' && departmentId) params.set('departmentId', departmentId);
if (selectedId) params.set('selectedId', selectedId);
return `/api/users/teachers/options?${params}`;
}
// --- Решения мастера замены ---
export function decisionOptions(lesson) {
const options = [];
(lesson.teacherCandidates || []).forEach(candidate => options.push({
value: `REPLACE_TEACHER|${candidate.id}`,
label: `Заменить: ${candidate.name}`
}));
(lesson.moveCandidates || []).forEach(candidate => options.push({
value: `MOVE_TIME|${candidate.targetDate}|${candidate.timeSlotId}`,
label: `Перенести: ${formatAbsenceDate(candidate.targetDate)}, ${trimTime(candidate.startTime)}–${trimTime(candidate.endTime)}`
}));
(lesson.classroomCandidates || []).forEach(candidate => options.push({
value: `CHANGE_CLASSROOM|${candidate.id}`,
label: `Другая аудитория: ${candidate.name}`
}));
if (lesson.cancellationAvailable) {
options.push({ value: 'CANCEL', label: 'Отменить занятие' });
}
options.push({ value: 'REJECT', label: 'Отклонить предложения' });
return options;
}
export function parseDecisionOption(value) {
const [resolution, first, second] = String(value || '').split('|');
if (resolution === 'REPLACE_TEACHER') return { resolution, newTeacherId: Number(first) };
if (resolution === 'MOVE_TIME') {
return { resolution, targetLessonDate: first, newTimeSlotId: Number(second) };
}
if (resolution === 'CHANGE_CLASSROOM') return { resolution, newClassroomId: Number(first) };
if (resolution === 'CANCEL' || resolution === 'REJECT') return { resolution };
throw new Error('Неизвестное решение мастера замены');
}
// --- Подписи и статусы ---
export function statusLabel(status) {
return {
PENDING: 'На подтверждении',
APPROVED: 'Требует решения',
REJECTED: 'Отклонено',
RESOLVED: 'Разрешено',
CANCELLED: 'Отменено'
}[status] || status || '—';
}
export function statusClass(status) {
return `is-${String(status || '').toLowerCase()}`;
}
export function genericStatusLabel(status) {
return {
PENDING: 'На рассмотрении',
APPROVED: 'Согласовано',
REJECTED: 'Отклонено',
CANCELLED: 'Отменено'
}[status] || status || '—';
}
export function decisionStatusLabel(status) {
return status === 'APPLIED' ? 'Применено' : status === 'REJECTED' ? 'Отклонено' : status || '—';
}
export function decisionCountLabel(count) {
const value = Number(count) || 0;
const tail = value % 100;
if (tail >= 11 && tail <= 14) return `${value} решений`;
if (value % 10 === 1) return `${value} решение`;
if ([2, 3, 4].includes(value % 10)) return `${value} решения`;
return `${value} решений`;
}
export function resolutionLabel(resolution) {
return {
REPLACE_TEACHER: 'Замена преподавателя',
MOVE_TIME: 'Перенос занятия',
CHANGE_CLASSROOM: 'Замена аудитории',
CANCEL: 'Отмена занятия',
REJECT: 'Предложение отклонено'
}[resolution] || resolution || 'Решение';
}
export function changeTypeLabel(type) {
return {
MOVE: 'Перенос даты или времени',
CHANGE_CLASSROOM: 'Смена аудитории',
CANCEL: 'Отмена занятия'
}[type] || type || 'Изменение';
}
export function preferenceDescription(item) {
const type = {
HARD_UNAVAILABLE: 'Строгая недоступность',
SOFT_PREFERRED: 'Предпочтительное время',
SOFT_UNWANTED: 'Нежелательное время',
CONSECUTIVE: 'Пожелание «пары подряд»',
NO_GAPS: 'Пожелание «без окон»'
}[item.preferenceType] || item.preferenceType;
if (item.preferenceDate) return `${type}: ${formatAbsenceDate(item.preferenceDate)}`;
if (item.dayName) return `${type}: ${item.dayName}, ${trimTime(item.startTime)}–${trimTime(item.endTime)}`;
return type;
}
export function absenceSemesterLabel(semester) {
const type = semester.semesterType === 'AUTUMN' ? 'осенний' : semester.semesterType === 'SPRING' ? 'весенний' : semester.semesterType;
return `${semester.academicYearTitle} · ${type} · ${formatAbsenceDate(semester.startDate)}–${formatAbsenceDate(semester.endDate)}`;
}
export function formatAbsenceDate(value) {
if (!value) return '—';
const [year, month, day] = String(value).split('-');
return `${day}.${month}.${year}`;
}
export function dayOfMonth(value) {
return String(value || '').split('-')[2] || '—';
}
export function monthShort(value) {
const month = Number(String(value || '').split('-')[1]);
return ['янв', 'фев', 'мар', 'апр', 'май', 'июн', 'июл', 'авг', 'сен', 'окт', 'ноя', 'дек'][month - 1] || '';
}
export function trimTime(value) {
return String(value || '').slice(0, 5);
}
// --- Сводка и цель заявки ---
export function absenceStats(items) {
const list = Array.isArray(items) ? items : [];
return {
active: list.filter(item => item.status === 'APPROVED').length,
pending: list.filter(item => item.status === 'PENDING').length,
resolved: list.filter(item => item.status === 'RESOLVED').length
};
}
export function changeReviewTarget(item) {
if (item.requestType === 'MOVE') {
return `${formatAbsenceDate(item.targetLessonDate || item.lessonDate)} · ${trimTime(item.requestedStartTime)}–${trimTime(item.requestedEndTime)}`;
}
if (item.requestType === 'CHANGE_CLASSROOM') return item.requestedClassroomName;
return 'Отмена занятия';
}
// --- Контракты API ---
export function teacherAbsencesEndpoint(status) {
return `/api/teacher-absences${status ? `?status=${encodeURIComponent(status)}` : ''}`;
}
export function teacherPreferencesEndpoint({ semesterId = '', status = '' } = {}) {
const params = new URLSearchParams();
if (semesterId) params.set('semesterId', semesterId);
if (status) params.set('status', status);
return `/api/teacher-preferences${params.size ? `?${params}` : ''}`;
}
export function teacherChangeRequestsEndpoint(status) {
return `/api/teacher-change-requests${status ? `?status=${encodeURIComponent(status)}` : ''}`;
}
export async function fetchTeacherAbsences(status, client = apiClient) {
return client.get(teacherAbsencesEndpoint(status), { errorMessage: 'Не удалось загрузить отсутствия' });
}
export async function createTeacherAbsence(payload, client = apiClient) {
return client.post('/api/teacher-absences', payload, { errorMessage: 'Не удалось зарегистрировать отсутствие' });
}
export async function reviewTeacherAbsence(id, payload, client = apiClient) {
return client.post(`/api/teacher-absences/${encodeURIComponent(id)}/review`, payload, { errorMessage: 'Не удалось выполнить действие' });
}
export async function deleteTeacherAbsence(id, client = apiClient) {
return client.delete(`/api/teacher-absences/${encodeURIComponent(id)}`, { errorMessage: 'Не удалось выполнить действие' });
}
export async function fetchTeacherAbsenceWizard(id, client = apiClient) {
return client.get(`/api/teacher-absences/${encodeURIComponent(id)}/wizard`, { errorMessage: 'Не удалось открыть мастер замены' });
}
export async function resolveTeacherAbsence(id, decisions, client = apiClient) {
return client.post(
`/api/teacher-absences/${encodeURIComponent(id)}/resolve`,
{ decisions },
{ errorMessage: 'Не удалось применить решения' }
);
}
export async function fetchTeacherPreferenceMeta(client = apiClient) {
return client.get('/api/teacher-preferences/meta', { errorMessage: 'Не удалось загрузить семестры' });
}
export async function fetchTeacherPreferences(filters, client = apiClient) {
return client.get(teacherPreferencesEndpoint(filters), { errorMessage: 'Не удалось загрузить пожелания' });
}
export async function reviewTeacherPreference(id, payload, client = apiClient) {
return client.post(`/api/teacher-preferences/${encodeURIComponent(id)}/review`, payload, { errorMessage: 'Не удалось рассмотреть пожелание' });
}
export async function fetchTeacherChangeRequests(status, client = apiClient) {
return client.get(teacherChangeRequestsEndpoint(status), { errorMessage: 'Не удалось загрузить заявки' });
}
export async function reviewTeacherChangeRequest(id, payload, client = apiClient) {
return client.post(`/api/teacher-change-requests/${encodeURIComponent(id)}/review`, payload, { errorMessage: 'Не удалось применить решение' });
}
export async function fetchAbsenceTeacherOptions({ query, selectedId, role, departmentId }, client = apiClient) {
return client.get(
teacherAbsenceTeacherOptionsUrl({ query, selectedId, role, departmentId }),
{ errorMessage: 'Ошибка поиска преподавателей' }
);
}
export { formatLocalDate };

View File

@@ -0,0 +1,118 @@
import { apiClient } from '../shared/api-client.js';
export const REQUEST_TABLE_COLUMNS = 7;
export const REQUEST_PAGE_SIZES = [25, 50, 100];
export const REQUEST_STATUS_OPTIONS = Object.freeze([
{ value: 'PENDING', label: 'Ожидают рассмотрения' },
{ value: 'APPROVED', label: 'Одобрены' },
{ value: 'REJECTED', label: 'Отклонены' },
{ value: '', label: 'Все статусы' }
]);
export function normalizeTeacherRequestsState(restored = {}) {
const size = Number(restored.requestSize);
return {
query: restored.requestQuery || '',
status: restored.requestStatus || 'PENDING',
page: Math.max(Number(restored.requestPage) || 0, 0),
size: REQUEST_PAGE_SIZES.includes(size) ? size : 25
};
}
export function initialTeacherRequestsState(restored = {}) {
return normalizeTeacherRequestsState(restored);
}
export function teacherRequestsQueryParams(state) {
const params = new URLSearchParams({
query: state.query || '',
page: String(state.page || 0),
size: String(state.size || 25),
sort: 'createdAt',
direction: 'desc'
});
if (state.status) params.set('status', state.status);
return `/api/teacher-requests/page?${params}`;
}
export function teacherRequestsUrlState(state) {
return {
requestQuery: state.query || '',
requestStatus: state.status === 'PENDING' ? '' : state.status,
requestPage: state.page || '',
requestSize: state.size === 25 ? '' : state.size
};
}
// --- Справочники ---
export function fetchDepartments(client = apiClient) {
return client.get('/api/departments', { errorMessage: 'Не удалось загрузить список кафедр' });
}
// Кафедра в строке заявки подписывается только названием — как в legacy-реестре.
export function requestDepartmentLabel(department) {
return department.departmentName || department.name || department.id;
}
// --- Реестр заявок ---
export function fetchTeacherRequestsPage(state, client = apiClient) {
return client.get(teacherRequestsQueryParams(state), {
errorMessage: 'Не удалось загрузить заявки преподавателей'
});
}
export function teacherRequestsEmptyMessage(state) {
return state.query ? 'Заявки не найдены' : 'Заявок с выбранным статусом нет';
}
export function reviewedStatusLabel(status) {
return status === 'APPROVED' ? 'Одобрена' : 'Отклонена';
}
// Счётчик в sidebar обновляется событием teacherRequestsChanged: он актуален
// только когда реестр показывает нефильтрованный список ожидающих заявок.
export function pendingCountDetail(state, totalItems) {
return {
count: state.status === 'PENDING' && !state.query
? Number(totalItems || 0)
: undefined
};
}
// --- Одобрение и отклонение ---
export function approvePayloadFromFields(fields) {
const payload = {
departmentId: Number(fields.departmentId),
username: (fields.username || '').trim(),
fullName: (fields.fullName || '').trim(),
jobTitle: (fields.jobTitle || '').trim(),
password: fields.password
};
if (!payload.departmentId || !payload.username || !payload.fullName || !payload.password) {
return { error: 'Заполните кафедру, логин, ФИО и пароль' };
}
if (payload.password.length < 8) {
return { error: 'Пароль должен быть не короче 8 символов' };
}
return { payload };
}
export function approveRequest(id, payload, client = apiClient) {
return client.post(
`/api/teacher-requests/${encodeURIComponent(id)}/approve`,
payload,
{ errorMessage: 'Ошибка одобрения заявки' }
);
}
export function rejectRequest(id, reviewComment, client = apiClient) {
return client.post(
`/api/teacher-requests/${encodeURIComponent(id)}/reject`,
{ reviewComment },
{ errorMessage: 'Ошибка отклонения заявки' }
);
}

View File

@@ -0,0 +1,83 @@
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { AdminWorkspace } from '../AdminApp.jsx';
import { AcademicCalendarTab } from '../tabs/AcademicCalendarTab.jsx';
import { AuditoriumWorkloadTab } from '../tabs/AuditoriumWorkloadTab.jsx';
import { ClassroomsTab } from '../tabs/ClassroomsTab.jsx';
import { DashboardTab } from '../tabs/DashboardTab.jsx';
import { DepartmentWorkspaceTab } from '../tabs/DepartmentWorkspaceTab.jsx';
import { GroupsTab } from '../tabs/GroupsTab.jsx';
import { ScheduleQualityTab } from '../tabs/ScheduleQualityTab.jsx';
import { ScheduleTab } from '../tabs/ScheduleTab.jsx';
import { ScheduleVersionsTab } from '../tabs/ScheduleVersionsTab.jsx';
import { ScheduleViewTab } from '../tabs/ScheduleViewTab.jsx';
import { TeacherAbsencesTab } from '../tabs/TeacherAbsencesTab.jsx';
import { SubjectsTab } from '../tabs/SubjectsTab.jsx';
import { TeacherRequestsTab } from '../tabs/TeacherRequestsTab.jsx';
import { UniversityStructureTab } from '../tabs/UniversityStructureTab.jsx';
import { UsersTab } from '../tabs/UsersTab.jsx';
export function renderAdminWorkspace({ role } = {}) {
return renderToStaticMarkup(createElement(AdminWorkspace, { role: role || 'ADMIN' }));
}
export function renderClassroomsTab() {
return renderToStaticMarkup(createElement(ClassroomsTab));
}
export function renderSubjectsTab() {
return renderToStaticMarkup(createElement(SubjectsTab));
}
export function renderDashboardTab() {
return renderToStaticMarkup(createElement(DashboardTab));
}
export function renderUsersTab() {
return renderToStaticMarkup(createElement(UsersTab));
}
export function renderGroupsTab() {
return renderToStaticMarkup(createElement(GroupsTab));
}
export function renderUniversityStructureTab() {
return renderToStaticMarkup(createElement(UniversityStructureTab));
}
export function renderTeacherRequestsTab() {
return renderToStaticMarkup(createElement(TeacherRequestsTab));
}
export function renderDepartmentWorkspaceTab() {
return renderToStaticMarkup(createElement(DepartmentWorkspaceTab));
}
export function renderAuditoriumWorkloadTab() {
return renderToStaticMarkup(createElement(AuditoriumWorkloadTab));
}
export function renderAcademicCalendarTab() {
return renderToStaticMarkup(createElement(AcademicCalendarTab));
}
export function renderScheduleViewTab() {
return renderToStaticMarkup(createElement(ScheduleViewTab));
}
export function renderScheduleTab() {
return renderToStaticMarkup(createElement(ScheduleTab));
}
export function renderScheduleVersionsTab() {
return renderToStaticMarkup(createElement(ScheduleVersionsTab));
}
export function renderScheduleQualityTab() {
return renderToStaticMarkup(createElement(ScheduleQualityTab));
}
export function renderTeacherAbsencesTab() {
return renderToStaticMarkup(createElement(TeacherAbsencesTab));
}

View File

@@ -0,0 +1,150 @@
import { apiClient } from '../shared/api-client.js';
export const DEPARTMENT_TABLE_COLUMNS = 4;
export const SPECIALTY_TABLE_COLUMNS = 4;
export const PROFILE_TABLE_COLUMNS = 5;
export const LIST_PAGE_SIZES = [25, 50, 100];
// --- Загрузка ---
export function fetchDepartments(client = apiClient) {
return client.get('/api/departments', { errorMessage: 'Не удалось загрузить кафедры' });
}
export function fetchSpecialties(client = apiClient) {
return client.get('/api/specialties', { errorMessage: 'Не удалось загрузить специальности' });
}
export function fetchAllProfiles(client = apiClient) {
return client.get('/api/specialties/profiles', { errorMessage: 'Не удалось загрузить профили обучения' });
}
export function fetchProfiles(specId, client = apiClient) {
return client.get(`/api/specialties/${specId}/profiles`, { errorMessage: 'Не удалось загрузить профили обучения' });
}
// --- Кафедры ---
export function createDepartment({ name, code }, client = apiClient) {
return client.post(
'/api/departments',
{ departmentName: name, departmentCode: Number(code) },
{ errorMessage: 'Ошибка создания кафедры' }
);
}
export function updateDepartment(id, { name, code }, client = apiClient) {
return client.put(
`/api/departments/${id}`,
{ departmentName: name, departmentCode: Number(code) },
{ errorMessage: 'Ошибка обновления кафедры' }
);
}
export function deleteDepartment(id, client = apiClient) {
return client.delete(`/api/departments/${id}`, { errorMessage: 'Ошибка удаления кафедры' });
}
// --- Специальности ---
export function createSpecialty({ name, code }, client = apiClient) {
return client.post(
'/api/specialties',
{ specialityName: name, specialityCode: code },
{ errorMessage: 'Ошибка создания специальности' }
);
}
export function updateSpecialty(id, { name, code }, client = apiClient) {
return client.put(
`/api/specialties/${id}`,
{ specialityName: name, specialityCode: code },
{ errorMessage: 'Ошибка обновления специальности' }
);
}
export function deleteSpecialty(id, client = apiClient) {
return client.delete(`/api/specialties/${id}`, { errorMessage: 'Ошибка удаления специальности' });
}
// --- Профили ---
export function createProfile(specId, { name, description }, client = apiClient) {
return client.post(
`/api/specialties/${specId}/profiles`,
{ name, description },
{ errorMessage: 'Ошибка сохранения профиля' }
);
}
export function updateProfile(specId, profileId, { name, description }, client = apiClient) {
return client.put(
`/api/specialties/${specId}/profiles/${profileId}`,
{ id: Number(profileId), name, description },
{ errorMessage: 'Ошибка сохранения профиля' }
);
}
export function deleteProfile(specId, profileId, client = apiClient) {
return client.delete(`/api/specialties/${specId}/profiles/${profileId}`, { errorMessage: 'Ошибка удаления профиля' });
}
// --- Клиентская фильтрация и пагинация списков (как в legacy listPage) ---
export function filterListItems(items, query, fields) {
const normalized = String(query || '').trim().toLocaleLowerCase('ru');
if (!normalized) return [...(items || [])];
return (items || []).filter(item =>
fields(item).join(' ').toLocaleLowerCase('ru').includes(normalized));
}
export function clampPage(page, totalPages) {
return Math.min(Math.max(Number(page) || 0, 0), Math.max(0, totalPages - 1));
}
export function pageSlice(items, page, size) {
const pageSize = Number(size) || 25;
return (items || []).slice(page * pageSize, (page + 1) * pageSize);
}
export function departmentFields(department) {
return [department.id, department.departmentName || department.name, department.departmentCode ?? department.code];
}
export function specialtyFields(speciality) {
return [
speciality.id,
speciality.specialityName || speciality.name,
speciality.specialityCode || speciality.specialtyCode || speciality.specialty_code
];
}
export function profileFields(profile) {
return [profile.id, profile.name, profile.description, profile.specialityCode, profile.specialityName];
}
// --- Метки ---
export function departmentName(department) {
return department.departmentName || department.name;
}
export function departmentCode(department) {
return department.departmentCode ?? department.code;
}
export function specialtyName(speciality) {
return speciality.specialityName || speciality.name;
}
export function specialtyCode(speciality) {
return speciality.specialityCode || speciality.specialtyCode || speciality.specialty_code;
}
export function specialtyOptionLabel(speciality) {
return `${specialtyCode(speciality)} · ${specialtyName(speciality)}`;
}
export function profileSpecialtyLabel(profile) {
return `${profile.specialityCode} · ${profile.specialityName}`;
}

View File

@@ -0,0 +1,112 @@
import { apiClient } from '../shared/api-client.js';
export const USER_TABLE_COLUMNS = 7;
export const USER_PAGE_SIZES = [25, 50, 100];
export const USER_SORT_OPTIONS = Object.freeze([
{ value: 'fullName,asc', label: 'По ФИО А–Я' },
{ value: 'fullName,desc', label: 'По ФИО Я–А' },
{ value: 'username,asc', label: 'По логину' },
{ value: 'role,asc', label: 'По роли' },
{ value: 'status,asc', label: 'По статусу' }
]);
export const ROLE_LABELS = Object.freeze({
ADMIN: 'Администратор',
EDUCATION_OFFICE: 'Учебный отдел',
DEPARTMENT: 'Кафедра',
SCHEDULE_VIEWER: 'Просмотр расписаний',
TEACHER: 'Преподаватель',
STUDENT: 'Студент'
});
export const ROLE_BADGE = Object.freeze({
ADMIN: 'badge-admin',
EDUCATION_OFFICE: 'badge-admin',
DEPARTMENT: 'badge-teacher',
SCHEDULE_VIEWER: 'badge-student',
TEACHER: 'badge-teacher',
STUDENT: 'badge-student'
});
export const ROLE_FILTER_OPTIONS = Object.freeze([
{ value: 'ADMIN', label: 'Администратор' },
{ value: 'EDUCATION_OFFICE', label: 'Учебный отдел' },
{ value: 'DEPARTMENT', label: 'Кафедра' },
{ value: 'SCHEDULE_VIEWER', label: 'Просмотр расписаний' },
{ value: 'TEACHER', label: 'Преподаватель' },
{ value: 'STUDENT', label: 'Студент' }
]);
export function normalizeUsersState(restored = {}) {
const size = Number(restored.userSize);
return {
query: restored.userQuery || '',
role: restored.userRole || '',
page: Math.max(Number(restored.userPage) || 0, 0),
size: USER_PAGE_SIZES.includes(size) ? size : 25,
sort: USER_SORT_OPTIONS.some(option => option.value === restored.userSort)
? restored.userSort
: 'fullName,asc'
};
}
export function initialUsersState(restored = {}) {
return normalizeUsersState(restored);
}
export function usersQueryParams(state) {
const [sort, direction] = String(state.sort || 'fullName,asc').split(',');
const params = new URLSearchParams({
query: state.query || '',
page: String(state.page || 0),
size: String(state.size || 25),
sort,
direction,
includeArchived: 'true'
});
if (state.role) params.set('role', state.role);
return `/api/users/page?${params}`;
}
export function usersUrlState(state) {
return {
userQuery: state.query || '',
userRole: state.role || '',
userPage: state.page || '',
userSize: state.size === 25 ? '' : state.size,
userSort: state.sort === 'fullName,asc' ? '' : state.sort
};
}
export function fetchUsersPage(state, client = apiClient) {
return client.get(usersQueryParams(state), { errorMessage: 'Не удалось загрузить пользователей' });
}
export function createUser({ username, password, role, fullName, jobTitle, departmentId }, client = apiClient) {
return client.post(
'/api/users',
{ username, password, role, fullName, jobTitle, departmentId: Number(departmentId) },
{ errorMessage: 'Ошибка соединения' }
);
}
export function archiveUser(id, client = apiClient) {
return client.delete(`/api/users/${id}`, { errorMessage: 'Ошибка архивирования' });
}
export function restoreUser(id, client = apiClient) {
return client.post(`/api/users/${id}/restore`, {}, { errorMessage: 'Ошибка восстановления' });
}
export function roleLabel(role) {
return ROLE_LABELS[role] || role;
}
export function roleBadgeClass(role) {
return ROLE_BADGE[role] || '';
}
export function usersEmptyMessage(state) {
return state.query || state.role ? 'Пользователи не найдены' : 'Пользователей пока нет';
}

View File

@@ -0,0 +1,212 @@
import { useEffect, useRef, useState } from 'react';
import { clearAuthState, storeAuthState } from '../../auth-session.js';
import { initializeTelemetry } from '../../telemetry.js';
import {
LOGIN_ERROR_MESSAGES,
requestLogin,
validateLogin
} from './login-service.js';
import { FormAlert } from '../shared/ui/FormAlert.jsx';
void initializeTelemetry();
const EMPTY_CREDENTIALS = Object.freeze({ password: '', username: '' });
function BrandIcon() {
return (
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<rect width="40" height="40" rx="12" fill="url(#logo-gradient)" />
<path d="M12 20L18 26L28 14" stroke="white" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" />
<defs>
<linearGradient id="logo-gradient" x1="0" y1="0" x2="40" y2="40">
<stop stopColor="#6366f1" />
<stop offset="1" stopColor="#8b5cf6" />
</linearGradient>
</defs>
</svg>
);
}
function UserIcon() {
return (
<svg className="input-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
);
}
function LockIcon() {
return (
<svg className="input-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
);
}
function EyeIcon() {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
);
}
function Spinner() {
return (
<svg className="spinner" width="20" height="20" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" fill="none" strokeDasharray="31.4 31.4" strokeLinecap="round" />
</svg>
);
}
export function LoginApp() {
const [credentials, setCredentials] = useState(EMPTY_CREDENTIALS);
const [errors, setErrors] = useState({});
const [alert, setAlert] = useState(null);
const [loading, setLoading] = useState(false);
const [passwordVisible, setPasswordVisible] = useState(false);
const [rippleVisible, setRippleVisible] = useState(false);
const redirectTimer = useRef(null);
useEffect(() => () => {
if (redirectTimer.current) {
window.clearTimeout(redirectTimer.current);
}
}, []);
const updateField = event => {
const { name, value } = event.target;
setCredentials(current => ({ ...current, [name]: value }));
setErrors(current => ({ ...current, [name]: undefined }));
};
const submit = async event => {
event.preventDefault();
const nextErrors = validateLogin(credentials);
setErrors(nextErrors);
setAlert(null);
if (Object.keys(nextErrors).length > 0) {
return;
}
setLoading(true);
try {
const result = await requestLogin(credentials);
if (!result.ok) {
setAlert({ message: result.message, type: 'error' });
return;
}
clearAuthState();
const session = storeAuthState(result.data);
if (!session) {
setAlert({ message: LOGIN_ERROR_MESSAGES.invalidResponse, type: 'error' });
return;
}
setAlert({ message: 'Вход выполнен успешно!', type: 'success' });
redirectTimer.current = window.setTimeout(() => {
window.location.assign(session.redirect || '/');
}, 400);
} catch (error) {
setAlert({ message: LOGIN_ERROR_MESSAGES.connection, type: 'error' });
} finally {
setLoading(false);
}
};
const showRipple = () => {
setRippleVisible(false);
window.requestAnimationFrame(() => setRippleVisible(true));
};
return (
<>
<div className="background" aria-hidden="true">
<div className="shape shape-1" />
<div className="shape shape-2" />
<div className="shape shape-3" />
</div>
<main className="login-container">
<div className="login-card">
<header className="login-header">
<div className="logo"><BrandIcon /></div>
<h1>Добро пожаловать</h1>
<p className="subtitle">Войдите в свой аккаунт</p>
</header>
<form id="login-form" noValidate onSubmit={submit}>
<div className={`form-group stagger-1${errors.username ? ' has-error shake' : ''}`}>
<label htmlFor="username">Имя пользователя</label>
<div className="input-wrapper">
<UserIcon />
<input
type="text"
id="username"
name="username"
value={credentials.username}
onChange={updateField}
placeholder="Введите имя пользователя"
autoComplete="username"
aria-invalid={Boolean(errors.username)}
aria-describedby="username-error"
required
/>
</div>
<span className="error-message" id="username-error">{errors.username || ''}</span>
</div>
<div className={`form-group stagger-2${errors.password ? ' has-error shake' : ''}`}>
<label htmlFor="password">Пароль</label>
<div className="input-wrapper">
<LockIcon />
<input
type={passwordVisible ? 'text' : 'password'}
id="password"
name="password"
value={credentials.password}
onChange={updateField}
placeholder="Введите пароль"
autoComplete="current-password"
aria-invalid={Boolean(errors.password)}
aria-describedby="password-error"
required
/>
<button
type="button"
className="btn btn-icon-md btn-secondary toggle-password"
id="toggle-password"
aria-label={passwordVisible ? 'Скрыть пароль' : 'Показать пароль'}
aria-pressed={passwordVisible}
onClick={() => setPasswordVisible(visible => !visible)}
>
<EyeIcon />
</button>
</div>
<span className="error-message" id="password-error">{errors.password || ''}</span>
</div>
<FormAlert alert={alert} className="stagger-3" id="form-alert" />
<button
type="submit"
className="btn btn-lg btn-primary btn-submit stagger-4"
id="btn-submit"
disabled={loading}
onPointerDown={showRipple}
>
{loading ? <Spinner /> : <span className="btn-text">Войти</span>}
{rippleVisible && <span className="ripple" onAnimationEnd={() => setRippleVisible(false)} />}
</button>
</form>
</div>
</main>
</>
);
}

View File

@@ -0,0 +1,40 @@
export const LOGIN_ERROR_MESSAGES = Object.freeze({
connection: 'Ошибка соединения с сервером',
credentials: 'Неверное имя пользователя или пароль',
invalidResponse: 'Сервер не вернул данные авторизации'
});
export function validateLogin({ username = '', password = '' } = {}) {
const errors = {};
if (!username.trim()) {
errors.username = 'Введите имя пользователя';
}
if (!password) {
errors.password = 'Введите пароль';
} else if (password.length < 4) {
errors.password = 'Минимум 4 символа';
}
return errors;
}
export async function requestLogin({ username, password }, fetchImpl = fetch) {
const response = await fetchImpl('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({
username: username.trim(),
password
})
});
const data = await response.json().catch(() => null);
return {
data,
message: data?.message || LOGIN_ERROR_MESSAGES.credentials,
ok: response.ok
};
}

View File

@@ -0,0 +1,19 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { LoginApp } from './LoginApp.jsx';
import { AppErrorBoundary } from '../shared/ui/AppErrorBoundary.jsx';
const rootElement = document.getElementById('login-root');
if (!rootElement) {
throw new Error('Не найден корневой элемент страницы входа');
}
createRoot(rootElement).render(
<StrictMode>
<AppErrorBoundary>
<LoginApp />
</AppErrorBoundary>
</StrictMode>
);

Some files were not shown because too many files have changed in this diff Show More